본 글은, 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를 유발할 것입니다.



///////////// 두번째


오랜만에 블로그에 글을 쓰고 있습니다. 일종의 생존신고랄까요? 
VS2005 로 처음 작업할때도 이런 비슷한 문제로 필요없는 삽질을 꽤 했었습니다.  여기 ㅜ.ㅜ
요즘 VS2008 로 해야할 작업이 있어서 쪼물딱 거리는 와중에 이상한 현상이 있어서 삽질하고, 구글링한 결과를 정리해 볼까 합니다.
어떤 X 같은 일이 생겼나면.. 

개발머신   : VS2008, SP1 
테스트머신: 윈도우 XP SP3 

이렇게 PC 가 두대가 있습니다. 
당연히 VS2005 에서 작업하던 대로 Debug 빌드시에는 
Microsoft Visual Studio 9.0\VC\redist\Debug_NonRedist\x86\Microsoft.VC90.DebugCRT
아래 있는 파일들을 실행파일과 함께 테스트 머신에 복사하고, 실행을 했습니다. (private assembly 형태의 배포죠)
그런데 실행이 안되는겁니다.

뭐 요딴 메세지만 나오는 군요. 
아래 그림에서처럼 DLL 이 뭔가 없는것도 아니고요. 아무 문제 없습니다. (VLdr.exe 는 VLdr.dll 을 사용합니다)


Manifest 파일과 배포해야 하는 DLL 들도 잘 복사되어있고요.


대체 뭐가 문제란 말입니까.. ㅠ.ㅠ

한참을 원인을 찾던 중.. (뭐 딱히 구글링할 키워드도 떠오르지 않더군요. -_-)
뭐 급한대로 VLdr.exe.config 파일을 만들어서 dll 들을 redirect 시켜서 대충 해결이 되긴 하더군요. 
하지만 dll 이 앞으로 한두개도 아니고.. 3rd party library 라도 사용하게 되면 대략 난감해 지겠죠. 
config 파일로 해결이 된다는것은 결국 바인딩 되는 dll 의 버전 문제 아니겠는가 라는 결론이 나오죠.

VS 링커 옵션에 보면 minifest 를 자동생성하도록 되어있습니다.(디폴트로)
역시 평소에 VS 각 옵션 설정을 꼼꼼히 보아두었던 것이 도움이 되는군요 ^^.
바인딩되는 dll 들은 링크타임에 manifest 형태로 만들어지는 것이고요. 생성된 manifest 는 exe 나 dll 에 내장되는것이겠죠. (아님 말구요)

결국 debug 디렉토리(중간 오브젝트 생성경로)에 생성된 ****.exe.embed.manifest 를 살펴보니 (****.exe.intermediate.manifest 는 뭐 중간에 잠깐 만들어지는 넘 같으니 패스) 아래와 같은 내용이 있네요.


그런데 C:\Program Files\Microsoft Visual Studio 9.0\VC\redist\Debug_NonRedist\x86\Microsoft.VC90.DebugCRT 에 있는 minifest 파일을 열어보면 (private assembly 로 배포하던 파일이죠)



이런 ...ㅅㅂ... 욕나옵니다. -_-;; 
결국 SP 1 이 설치되었는데도 VS 는 예전 버전(시스템 런타임 버전)으로 바인딩을 하고 있는겁니다. 
이러니 당연히 실행이 안될 수 밖에요 (이해가 안되시는 분들은 WinSxS 나 Strong named assembly 라는 키워드로 검색을.. ).

결국 이런 저런 삽질과 구글링을 통해 알아낸 결과
 - VS 2008 SP1 설치 미디어에 있는 VC_x86Runtime.exe 을 디버기에서 실행하거나
 - 아래 전처리기를 stdafx.h 같은 곳에 (가능하면 가장 위에) 정의해 주는 겁니다.
#define _BIND_TO_CURRENT_MFC_VERSION 1
#define _BIND_TO_CURRENT_CRT_VERSION 1
프로젝트 속성 -> C/C++ -> 명령줄에 추가하는 것이 더 좋은것 같습니다. 
/D "_BIND_TO_CURRENT_MFC_VERSION=1"
/D "_BIND_TO_CURRENT_CRT_VERSION=1"
MFC 프로젝트에서 stdafx.h 에 추가했더니 여전히 잘못된 manifest 를 만들어내는군요. -_-;;

이 전처리기를 이용하면 정상적으로 ***.exe.embed.manifest  에 버전이 SP 1 용 MFC, CRT 의 버전으로 만들어집니다.
이게 VS 2005 와 VS2008 에서 이렇게 요상하게 다르게 동작하는 원인은 고객들의 요구에 의한거라고 하는군요. -_-




'게임개발공부 > 무작정퍼오기' 카테고리의 다른 글

explicit 키워드에 대하여  (0) 2014.01.09
함수객체  (0) 2013.12.29
volatile (퍼온글)  (0) 2013.12.21
extern "C" 이건 뭔가? (퍼온글)  (0) 2013.12.21
dll의 기본. (퍼온글)  (0) 2013.12.21
Posted by JJOREG