2010년 11월 9일 화요일

데드락피하기

데드락 피하기 (blog/100318) to upper level
데드락은 다른 멀티스레드 문제와 마찬가지로 부하가 심하게 걸릴수록 나타나기 쉽다. 온라인 게임이라면 동접이 제일 높은 시간에 가장 섭다되기 쉽다는 얘기다. 안정적으로 서비스하려면 라이브 서비스를 시작하기 전에 데드락 문제를 해결해야 하는데, 평상시에 수행하는 테스트만으로는 데드락을 찾아내기 어려우니 다른 방법을 강구해야 한다. 데드락이 아니라도 터질 문제가 많을 테니 대비할 수 있는 건 미리 해 두자. 그런다고 라이브 시작해서 밤 안 새는 건 아니지만…

학부 OS 과목에서 데드락에 대해 배웠을 것이다. 데드락이 발생하기 위한 4가지 조건, 데드락 감지하기, 데드락 피하기 등 꽤 많은 내용이 있다. 그런데 C++로 멀티스레드 게임 서버를 짤 때는 그런 지식들은 별 쓸모가 없고, 딱 한가지 조건만 철저하게 지키면 된다:

락을 잡는 순서가 일관성 있어야 한다. 예를 들어, 어디서는 a b 순서로 잡고 딴데서는 b a 순서로 잡으면 안 된다.

간단하다. 데드락 문제를 다 해결했다! 야 신난다!

아니다. 사실, 간단하지 않다. 프로그램이 좀 커지면 이 조건을 깨뜨리기가 무척 쉬워지고, 깨뜨린 사실을 발견하기는 무척 어려워진다. 예를 들어 보겠다. 어떤 함수 Ouch()가 있는데 그 함수는 안에서 락 a를 잡는다고 치자. 그런데 프로그램 전체에서 락을 a b c 순서로 잡기로 했다. 그러면, 락 b 혹은 c를 잡은 상태에서 Ouch()를 호출하면 데드락이 일어날 수 있다. 만약 Ouch()가 직접 락을 잡는 것이 아니라 콜스택을 5단계쯤 타고 들어가서 호출된 전혀 다른 곳의 함수가 락 a를 잡더라도 마찬가지다.

어떤 함수를 호출하는 코드를 쓸 때, 현재 맥락에서 어떤 락을 잡은 상태인지, 호출할 함수와 그것이 간접적으로 호출할 모든 함수들이 어떤 락을 잡을지를 일일이 확인해야 한다. 게다가 코드에 변경이 일어날 때마다 영향받는 호출 경로를 모두 확인해야 한다. 사람이 직접 하기에는 너무 힘든 일이다. 간단한 줄 알았던 문제가 프로그램의 규모 때문에 어려운 문제가 된다.

우리네 소프트웨어 세계는 모듈의 인터페이스를 알고 그대로 따르면, 세부 구현에 신경쓰지 않아도 행복하게 살 수 있다는 가정에 기반해서 모듈화의 성을 쌓아올렸다. 그러나 락 기반 멀티스레드 환경에서는 함수를 호출할 때 그 호출경로에서 잡힐 락에 대해 신경써야 한다. 곧 함수가 어떤 락을 잡을까 하는 것이 (어떤 예외를 던질까처럼) 함수의 인터페이스의 일부가 된다. 하지만 지금 널리 쓰이고 있는 언어들은 함수의 락잡기를 명시적으로 표기하도록 하지 않는다. 헤더에 기록된 인터페이스만 보고는 프로그래밍할 수 없는 것이다.

그러나 맥주가 미지근하면 얼음을 넣어 마시면 되듯이, 컴파일러가 검사해 주지 않으면 실행시간에 확인하면 된다. 개별 스레드가 잡고 있는 락을 모두 기록하고, 새로이 락을 잡으려 할 때 기존에 잡은 락 목록을 확인해서 순서 규칙을 깨뜨리지 않는지 assert() 하는 것이다. assert()에 걸리면 디버거로 들어가든지 즉시 덤프를 남기도록 해서, 어떤 호출 경로에서 데드락이 생기는지 분석하면 된다.
  1. # TLS 타입의 객체는 포인터 한 개 크기의 스레드별 저장 공간을 제공한다.
  2. # TLS 객체를 담고 있는 lockhistory 전역변수가 선언되어 있다.
  3. # 락 객체에는 enter(), leave() 멤버 함수와 type 필드가 있다. type 필드는 락의 순서를 나타내며, type이 작은 것부터 큰 것 순서로 잡도록 규칙을 정했다.
  4. # 전처리기를 적절히 써서 서비스용 빌드에서는 데드락 검사 코드를 빼 버릴 수 있도록 하자.
  5. # 스레드를 시작할 때:
  6. lockhistory := create new set
  7. # 락 lock에 진입하려 할 때:
  8. for( oldlocktype in lockhistory ):
  9. assert( oldlocktype <= lock.type )
  10. lockhistory.insert( lock.type )
  11. lock.enter()
  12. # 락 lock에서 탈출하려 할 때:
  13. lock.leave()
  14. assert( lockhistory.has( lock.type ) )
  15. lockhistory.erase( lock.type )
위와 같이 락을 잘못된 순서로 잡는지 각 스레드별로 감시하면 실제로 데드락이 일어나기를 기다리는 것보다 훨씬 결정론적으로 데드락의 여지를 알아낼 수 있다.

이 방법은 프로그램의 호출 경로가 대체로 변하지 않는다는 가정을 전제로 하고 있다. 만약 테스트에서 한번도 수행되지 않았던 경로로 프로그램이 작동할 수 있다면, 테스트 환경에서 한번도 락 순서를 위반하지 않았더라도 서비스에서 데드락이 발생할 수 있으니 백 퍼센트 안심할 수는 없다. 그러나 그런 경우는 단순 실수보다는 설계 문제에 가까우므로 미리 파악할 수 있을 것이다.

다른 이야기
  • 그럼에도 불구하고 데드락에 걸린다면, CPU 점유율이 갑자기 낮아진다(스핀락을 썼다면 갑자기 높아진다). 차라리 크래시하면 서버를 바로 다시 띄우고 덤프를 받아와서 분석하면 되는데, 데드락은 서버가 잘 돌다가 잠시 멈칫하는 건지 데드락에 걸린 건지 명확하게 구분하기 어렵다. 스레드의 작동 상태를 감시하는 코드를 짜 두든지, 장애 전화를 받고 달려와서 수동으로 서버를 죽였다 띄워야 한다. 콜스택 없이는 데드락 분석이 불가능하니 프로세스를 죽이기 전에 덤프를 확보해야 하는데, 수동으로 서버를 죽이기 전에 프로세스 외부에서 덤프를 떠야 한다. user mode process dump를 검색해 보라.

  • 함수가 어떤 락을 잡을지가 함수의 인터페이스의 일부인 것처럼, 함수에 진입할 때 어떤 락이 잡혀 있는 상태여야 하는지도 함수의 인터페이스의 일부다. 우리는 이것도 명시적으로 표현하고 실행시간에 검사하고 싶어서 need_lock 매크로를 만들어 썼다.

  • 프로그램 정적분석기를 써서 데드락을 찾아내는 것도 이론적으로 가능한 일이다. 한번 찾아보라.

  • 인텔 패러렐 인스펙터라는 제품이 데드락을 찾아준다고 한다.

  • 락 순서를 전역적으로 정의하지 않고, 프로그램이 동작하는 동안 락들의 부분 순서를 수집해서 모순이 생기는지를 검사하는 방법도 가능하다. 순서를 미리 저장해 둔 파일이 없으니 재컴파일 문제를 피할 수 있다. 그러나 락의 순서는 반드시 문서화해 두어야 한다. 다른 프로그래머가 락 순서 위반 assert()에 걸렸을 때 무엇을 어떻게 고쳐야 하는지 헤메지 않게 하기 위해서다.

  • 위에서는 간결하게 설명하기 위해 제외했지만, 한 프로세스 내에 여러 개의 인스턴스가 존재하는 클래스를 위해 같은 종류의 락들에 대한 정책도 필요하다. 어떻게든 일관성 있는 순서를 정하고 지키면 되는데, 간단하게 락 객체의 주소를 기준으로 하는 정도면 충분하다. lockhistory에 그 값도 같이 저장해 두고 검사하도록 하자.

  • DB의 데드락도 간단한 문제는 아니다. 다만 내가 잘 모르는 분야라 자세한 설명은 생략한다.