2009. 10. 1. 02:55

DLL을 만들때의 최고의 습관 (DLLMain deadlock 회피) - (1)


본 글은, Best Practices for Creating DLLs를 번역하였으며, 원문인 DLL_bestprac.doc를 통해 직접 내려받으실 수 있습니다. 본 글에 워낙 이론적인 내용이 많다 보니 이해가 어려울 수 있는데, 실제 코드와 디버깅을 통한 설명을 다음 기회에 공유하도록 하겠습니다.

dynamic-link library(DLL)은 응용프로그램이 실행중에 로드하고 호출할 수 있는 공유된 코드데이터로 정의할 수 있습니다. 전형적인 DLL은 응용프로그램을 위해 루틴들을 노출(=Export)시키며, 그 내부(즉, DLL)에서 사용할(internal use) 루틴도 역시 포함되어 있습니다. 이러한 기술은 여러 응용프로그램에서 공통 기능으로 공유할 수 있게 라이브러리 형태로 재사용 가능하게 하여 필요시 로드를 할 수 있도록 해줍니다. DLL 사용의 장점은 코드가 차지하는 공간(code footprint)을 줄이고, 단일 복사본을 공유함으로서 메모리 사용량을 낮추며, 개발과 테스트가 용이하게 하고 모듈화를 가능하게 합니다.

DLL을 만드는 일은 개발자에게 많은 도전을 가져다 줍니다. DLL은 시스템을 통한 강제적인 버전관리가 이뤄지지 않습니다. 즉, 여러개의 DLL이 한 시스템에 있을때, 이러한 버전관리 체크의 부족으로 인한 overwrite는 의존성과 API 충돌을 야기합니다. 개발 환경, 로더 구현 그리고 의존성의 복잡성은 로드 순서와 응용프로그램 행위에 취약성을 만듭니다. 그래도 많은 응용프로그램들은 복잡한 의존성을 가지는 DLL에 의지하고 있습니다. 이 문서는 DLL 개발자들을 위해 가이드라인을 제공하여 견고하고 이식성 있으며 확장성 있는 DLL로 만드는데 도움을 줄 것입니다.

■ 3개의 주요한 DLL 컴포넌트 개발 모델은 다음과 같습니다.
  1. Library Loader
    DLL은 가끔 복잡한 내부의존성(interdependency)을 자니는데, 이는 그들이 로드되어야 하는 순서를 정의합니다. Library Loader는 효과적으로 이러한 의존성을 분석하고, 정확한 로드 순서를 계산한뒤 그 순서대로 로드를 합니다.
  2. DLLMain entry-point function
    이 함수는 Loader에 의해 호출되며, 그 시점은 DLL의 Load 혹은 Unload일때 입니다. Loader는 한 시점에 단 하나의 DLLMain만 호출하도록 연속으로 호출합니다. 더 많은 정보
  3. Loader Lock
    Loader가 순서대로 로드할때 사용되는 프로세스 단위의 동기화 객체입니다. 프로세스 단위의 Library Loader Data를 반드시 읽거나 써야 하는 함수는 반드시 이 Lock을 획득해야 합니다. 물론 이러한 operation을 수행하기 전에 이뤄져야 합니다. Loader Lock은 recursive이며, 이는 같은 쓰레드에서 다시 Lock의 획득이 가능함을 의미합니다.

그림 1

그림 1. DLL 로드시 어떤일이 이뤄지는가?

DLLMain에서의 부적합한 동기화 시도는 응용프로그램에게 deadlock 혹은 초기화 되지 않은 DLL의 data와 code의 접근을 야기하게 됩니다. DLLMain에서의 특정 함수 호출은 이러한 문제를 잃으킵니다.

일반적인 최고의 습관


DLLMain은 Loader Lock이 획득되었을때 호출됩니다. 따라서, DLLMain 내부에서의 호출은 중요한 제약이 강요됩니다. DLLMain은 최소의 초기화 작업을 수행하도록 디자인 되었는데, 이는 Windows API의 몇몇 함수군 호출에 의해서 입니다. DLLMain에서 직접적이든 간접적이든 Loader Lock 획득을 시도하는 어떤 함수도 호출할 수 없습니다. 다시 말해, 이 경우가 발생하면 당신은 deadlock 혹은 crash를 경험하게 됩니다. DLLMain 구현에서의 에러는 해당 프로세스와 그 내부의 쓰레드를 위험에 빠트리게 됩니다.

이상적인 DLLMain은 "그냥 비우는것" 입니다. 그러나, 많은 응용 프로그램의 복잡도를 고려할때 이는 너무한 제약이 됩니다. DLLMain을 다루는 좋은 방법은 많은 초기화 과정을 가능한 뒤로 미뤄라라는 것입니다. 이러한 미뤄진 초기화는 응용프로그램을 더욱더 견고히 해주는데, 그 이유는 Loader Lock가 획득된 동안의 초기화가 이뤄지지 않았기 때문입니다. 역시 이러한 방법은 Windows API의 사용에도 훨씬 많은 안정성을 제공합니다.

몇몇 초기화 작업은 뒤로 미룰순 없을 것입니다. 예를 들어, 설정 파일에 의존성이 있는 DLL이 있는데, 해당 파일이 좋지 않거나 쓰레기 내용이 포함되었을때 그 DLL의 Load가 실패되야 하는 경우가 있을 것입니다. 이런 종류의 초기화 방식은, 다른 작업의 자원 낭비를 하느니 DLL이 그 행위를 시도해보고 빨리 실패하는 것이 좋다고 개념이라 보여집니다.

■ 다음과 같은 작업을 절대로 DLLMain에서 수행해서는 안됩니다.

  • LoadLibrary 혹은 LibraryEx의 직접적 혹은 간접적 호출. 이는 deadlock 혹은 crash를 유발한다.
  • 다른 쓰레드와의 동기화 시도. 이는 deadlock을 유발한다.
  • Loader Lock을 획득하기 위해 기다리는 코드가 획득한 다른 사설 동기화 객제를 획득하려고 하는 시도. 이는 deadlock을 유발한다.
  • CoInitializeEx 사용에 의한 COM 쓰레드 초기화. 특정 상황이 되면 이 함수는 LoadLibrary를 호출한다.
  • 레지스트리 함수군의 호출. 이 함수는 Advapi32.dll에 구현되어 있는데, 만약 AdvApi32.dll이 아직 당신 DLL에서 초기화되지 않았다면, 그 DLL은 메모리를 초기화해제 하며, crash를 유발한다.
  • CreateProces 호출. 이는 다른 DLL을 Load할 수 있다.
  • ExitThread 호출. DLL Detach 과정에서 Exit가 진행중인 Thread는 Loader Lock을 다시 획득하려고 하는 시도가 발생하여 deadlock 혹은 crash가 발생할 수 있다.
  • CreateThread 호출. 다른 Thread와 동기화 작업을 하지 않는다면, 생성중인 Thread가 할 수 있는데, 이는 위험할 수 있다.
  • Named Pipe 혹은 다른 Named Object의 생성(Windows 2000만 해당). Windows 200에서는 Named Object는 Terminal Service DLL에 의해 제공되는데, 만일, 이 DLL이 초기화되지 않았다면, DLL을 로드하게 되어 crash가 유발될 수 있다.
  • 메모리 관리 CRT 함수 호출. 만약 CRT DLL이 초기화 되지 않았다면, crash가 유발된다.
  • User32.dll 혹은 Gdi32.dll 함수 호출. 몇몇 함수들은 아직 초기화되지 않은 DLL을 로드한다.
  • 관리 코드의 사용

■ DLLMain 내에서 안전한 작업은 다음과 같습니다.

  • compile time의 static data의 초기화
  • 동기화 객체의 생성과 초기화
  • 메모리 할당과 dynamic data의 초기화 (위 금지 함수 이외)
  • Thread local storage(TLS) 초기화
  • File의 열기/읽기/쓰기
  • kernel32.dll 함수의 호출 (위 금지 함수 제외)
  • 전역 포인터 변수를 NULL로 할당

Lock 순서의 역(Lock order inversion)에 의한 deadlock



Lock과 같은 다중 동기화 객체 사용을 구현할 때, Lock 순서를 따르는 것은 굉장히 중요합니다. 어느 시점에서 한개 이상의 Lock을 획득하는 것이 필요할때 반드시 Lock hierachy 혹은 Lock 순서라 불리는 명시적인 순서를 정의해야 합니다. 예를 들어, Lock A가 Lock B이전에 획득되었고, Lock C 이전에 Lock B가 획득되었다면 Lock 순서는 A,B,C가 되고, 이 순서는 코드에서 지켜줘야 됩니다. 만약 Lock 순서가 역으로 되는경우가 발생했다면, 예를 들어, Lock A를 획득 하기 전에 Lock B가 획득되었을 경우, 이는 Lock 순서의 역에 의한 deadlock이 발생하게 됩니다. 이렇게 발생한 deadlock은 디버깅하기 힘든 면이 있습니다. 이것을 방지하기 위해 모든 쓰레드에서는 같은 순서대로 Lock을 획득해야만 합니다.

Loader는 이미 획득한 Loader Lock으로 DLLMain을 호출한다는 사실은 굉장히 중요합니다. 그래서 Loader Lock은 Locking hierachy의 가장 높은 우선순위가 되어야 합니다. 그와 마찬가지로 적합한 동기화를 위해 요구된 Lock을 획득하해야 하는 것도 알아야 합니다. 물론 hierachy에 정의된 모든 단일 Lock을 획득해야 할 필요는 없습니다. 예를 들어, A와 C를 적합한 동기화를 위해 획득하였다면, C를 획득하기 이전에 A를 획득해야 하며, B를 획득할 필요는 없습니다. 더 나아가 설명하자면, 프로그램 코드에서는 Loader Lock을 명시적으로 획득 할 수 없습니다. 만약 사적인 Lock을 획득한 상황에서 Loader Lock을 간접적으로 획득하려는 ::GetModuleFileName(...)과 같은 API를 호출해야 한다면, 사설 Lock을 획득하기 이전에 ::GetModuleFileName(...)을 획득해야만 합니다. 이는 Load 순서를 따르게 하기 위함입니다.

그림 2

그림 2. Lock 순서의 역에 의한 deadlock



그림 2. 는 이러한 Lock 순서의 역을 보여주고 있습니다. DLLMain을 포함하는 Main 쓰레드를 가지는 DLL을 생각해 보십시요. Library Loader는 Lock L을 획득했으며, DLLMain을 호출하려고 합니다. Main 쓰레드에서는 동기화 객체인 A, B 그리고 공유 데이터를 접근하기 위해 필요한 G를 생성하고 G를 획득하기 위해 시도하려고 합니다. 그와 별도로, Worker 쓰레드에서는 이미 G를 획득한 상황이며 ::GetModuleHandle(...)를 호출하여 Loader Lock인 L을 획득하려고 시도할 것입니다. 그러면, Worker 쓰레드는 L에 의해 Block 되며, Main 쓰레드는 G에 의해 Block 되며, 이로 인해 deadlock이 발생하게 됩니다.

이러한 상황을 방지하기 위해서는, 모든 쓰레드에서는 항상 순서에 맞게 동기화 객체를 획득하도록 시도해야 합니다.

동기화를 위한 최고의 습관


초기화의 한 부분으로 DLL이 Worker 쓰레드를 생성해야 하는 경우를 생각해 보십시요. DLL이 Cleanup되면 data의 무결성(consistent)을 확신하기 위해 모든 Worker 쓰레드의 동기화가 필요하며, 그 다음 Worker 쓰레드는 종료하게 됩니다. 오늘날, 멀티쓰레드 환경의 DLL을 종료하고 동기화하는데에는 완벽하고 정확한 방법은 없습니다. 다음은 DLL 종료를 하는 동안 이루어질 쓰레드 동기화를 위해 현재까지 나와있는 최고의 습관을 설명하고 있습니다.

■ 프로세스 종료시 DLLMain에서의 쓰레드 동기화
  • 프로세스 종료시 DLLMain이 호출되었다면 모든 프로세스의 쓰레드들은 Clean up이 이뤄지며 주소 공간(Address Space)는 더이상 유지되지 않습니다. 동기화는 이런 경우에는 필요하지 않습니다. 다시 말해 DLL_PROCESS_DETACH는 비워둬도 됩니다.
  • Windows Vista에서는 핵심 data들(환경 변수, 현재 디렉토리, 프로세스 힙, ...)의 유지가 보장됩니다. 그러나 다른 동적 할당된 사설 data는 망가져서 더이상 안전하지 않습니다.
  • 저장이 필요한 영구 유지될 상태들은 저장 매체에 플러쉬되어야 합니다.

■ DLL UnLoad시의 DLL_THREAD_DETACH를 위한 DLLMain의 쓰레드 동기화

  • DLL이 UnLoad될때 주소 공간(Address Space)는 사라지는 것은 아닙니다. 따라서 DLL은 Clean될 예정인 상태입니다. 이것은 쓰레드 동기화, Open된 핸들, 영구 유지해야 하는 상태 그리고 할당된 자원들을 포함합니다.
  • 쓰레드 동기화는 종잡을 수 없는데, DLLMain에서 쓰레드의 종료를 기다리는 것은 deadlock을 유발할 수 있기 때문입니다. 예를 들어, DLL A가 Loader Lock을 획득했습니다. 그리고 Thread T를 종료시키기 위해 Signal을 보냈고 종료를 기다리도록 합니다. 쓰레드 T는 종료가 되며, Loader는 DLL A의 DLL_THREAD_DETACH 호출을 위해 Loader Lock 획득을 시도할 것입니다. 이것이 deadlock을 유발시키게 됩니다. 이러한 리스크를 최소화 하는 방법은 다음과 같습니다.
    • DLL A는 DLLMain에서 DLL_THREAD_DETACH 메시지를 받고, 쓰레드 T에게 종료해라는 Signal을 보냅니다.
    • Thread T는 현재의 작업을 마치고 스스로 상태를 유지하며 DLL A에게 Signal을 보냅니다. 단, 이러한 유지된 상태 체크를 위해서 DLLMain의 deadlock 회피를 위한 제약을 지켜야 합니다.
    • DLL A가 쓰레드 T를 종료 시켰으며, 그것이 아직 유지된 상태임을 알수 있습니다.

만약 DLL이 그것의 모든 쓰레드를 생성하고 나서 UnLoad되었고 실행이 시작되기 전이었다면, 그 쓰레드들은 crash가 발생할 수 있습니다. 만약 DLL이 초기화의 단계로 DLLMain에서 Thread를 생성하였다면, 몇몇 쓰레드들이 아직 초기화가 완료되지 못했고 그들의 DLL_THREAD_ATTACH 메시지가 여전히 DLL에게 전달되기를 기다리고 있을 것입니다. 이런 상황에서 DLL이 UnLoad된다면 쓰레드들의 Terminate가 시작될 것입니다. 그러나 몇몇 쓰레드들은 Loader Lock에 의해 block되어 있을 겁니다. 그들의 DLL_THREAD_ATTACH 메시지들은 DLL이 unmap된 이후 진행될 것이며, 이는 crash를 유발할 것입니다.

추천


■ 다음의 가이드라인을 추천합니다.
  • Application Verifier를 사용하며 DLLMain의 통상적인 오류를 찾으세요.
  • DLLMain에서의 사설 Lock을 사용하신다면, Lock hierachy를 정의하고 유지하십시요. Loader Lock은 가장 마지막에 있어야 합니다.
  • 어떤 호출도 아직 로드되지 않은 다른 DLL로의 의존성이 없다는 것을 확인하세요.
  • 간단한 초기화는 DLLMain 보다는 compile time에서 수행하도록 하세요.
  • DLLMain에서 기다리는 호출을 뒤로 미루십시요.
  • 초기화 작업을 뒤로 미루십시요. 어떤 에러 조건은 빨리 발견되는데, 이는 응용프로그램의 에러처리를 멋지게 해줍니다. 그러나 빨리 발견되는 것과 견고함의 손실은 Trade off가 있습니다. 초기화를 뒤로 미루는 것이 최고의 방법입니다.