「특정 환경에서만 앱이 기동 때 멈춘다」. 「자체 DLL을 로드하면 LoadLibrary가 가끔 돌아오지 않는다」. 「서비스 시작 타이밍에서만 데드락이 난다」── 이런 조사를 끝까지 따라가면, 대개 같은 곳에 도착합니다. DLL의 초기화 코드, 즉 DllMain.
Microsoft 문서는 DllMain을 유난히 강한 어조로 경고합니다. LoadLibrary를 호출하지 마십시오. 다른 스레드와 동기화하지 마십시오. User, Shell, COM 함수를 호출하지 마십시오. 이상적인 DllMain은 빈 스텁이다── 왜 이렇게 강한 말인가. 이유는 하나의 내부 메커니즘, 로더 락에 모입니다. Windows에서 DLL, 플러그인, C++/CLI 래퍼를 쓰는 개발자를 대상으로, 로더 락이 어떻게 움직이는지, 데드락이 성립하는 구조, 안전한 설계와 조사 절차를 1차 정보로 설명합니다.
1. 먼저 결론
DllMain은 프로세스당 정확히 하나인 공유 락, 로더 락을 쥔 채로 호출됩니다. 따라서DllMain에서 로더 락을 잡으려는 작업(직접이든 간접이든)을 호출하면 데드락의 가능성, 또는 아직 초기화되지 않은 DLL을 건드려 크래시가 납니다.1LoadLibrary/FreeLibrary호출은 금지입니다. 순환 로드 순서 의존이 생기고, 자기 초기화가 아직 돌지 않은 DLL에 대해 초기화 코드가 실행될 수 있습니다.2- 다른 스레드와의 동기화도 금지입니다. DLL 알림은 직렬화되므로,
DllMain안에서 스레드의 시작이나 종료를 기다리면 그 스레드 자신이 로더 락을 기다리며 멈춰 데드락이 납니다.23 - 안전하게 호출할 수 있는 것은 사실상 Kernel32.dll의 일부뿐입니다. 그리고 공식 문서는 「안전 함수의 완전한 목록은 존재하지 않는다」고 분명히 말합니다. User, Shell, COM 함수는 다른 구성 요소를 로드해 액세스 위반을 일으킵니다.2
- CRT와 링크된 DLL에서는 전역의 생성자와 소멸자에도 같은 제약이 적용됩니다. 사실상
DllMain의 일부로 돕니다.2 - 올바른 설계는 「미루기」입니다. 할 수 있는 초기화는 컴파일 때(정적으로) 하고, 할 수 없는 것은 첫 사용까지 미룹니다. 그것이 공식 베스트 프랙티스입니다.1
- C++/CLI 혼합 DLL은 특히 위험합니다. 로더 락 아래에서 MSIL을 돌리지 않으려면
DllMain과 그 호출 트리를 네이티브로 컴파일해야 합니다.4
2. DllMain은 언제, 어떻게 호출되는가
DllMain은 DLL이 프로세스나 스레드에 들어가거나 나올 때 OS 로더가 호출하는 진입점입니다. 알림은 네 가지입니다.
| 알림 | 타이밍 |
|---|---|
| DLL_PROCESS_ATTACH | DLL이 프로세스에 로드될 때 |
| DLL_THREAD_ATTACH | 프로세스에서 새 스레드가 시작될 때 |
| DLL_THREAD_DETACH | 스레드가 정상 종료할 때 |
| DLL_PROCESS_DETACH | DLL이 언로드되거나 프로세스가 종료할 때 |
놓치기 쉬운 사실이 둘 있습니다. 첫째, 스레드 하나가 만들어질 때마다, 이미 로드된 모든 DLL의 DllMain이 DLL_THREAD_ATTACH로 호출됩니다. 즉 DllMain은 「내 DLL이 로드될 때 한 번 도는 것」이 아니라, 프로세스의 스레드 활동에 맞춰 계속 호출되는 코드입니다. 그것이 필요 없으면 DLL_PROCESS_ATTACH 안에서 DisableThreadLibraryCalls를 호출해 멈출 수 있습니다(정적 CRT와 링크된 DLL에서는 호출하지 마십시오).5
둘째, CRT(C/C++ 런타임)와 링크된 DLL에서는 전역·정적 C++ 객체의 생성자와 소멸자가 CRT의 진입점을 거쳐 DllMain의 일부로 실행됩니다.2 「우리 DllMain은 비어 있으니 안전하다」고 생각해도, 정교한 초기화가 있는 전역 객체는 그 작업을 DllMain에서 돌리는 것과 같습니다.
flowchart TB
accTitle: DllMain이 호출되는 네 타이밍
accDescr: DLL_PROCESS_ATTACH는 DLL 로드 때, DLL_THREAD_ATTACH와 DETACH는 프로세스의 스레드 시작·종료마다 이미 로드된 모든 DLL에서, DLL_PROCESS_DETACH는 언로드나 프로세스 종료 때 돌며, 정적 객체의 생성자도 CRT를 통해 이 안에서 돈다
load["DLL 로드"] --> pa["DLL_PROCESS_ATTACH"]
pa --> ta["DLL_THREAD_ATTACH(스레드 시작마다)"]
ta --> td["DLL_THREAD_DETACH(스레드 종료마다)"]
td --> pd["DLL_PROCESS_DETACH(언로드 또는 종료)"]
pa -.-> crt["정적 객체의 생성도 여기서 돈다"]
그림 1: DllMain은 로드 때뿐 아니라 스레드 시작·종료마다 호출되고, 정적 객체의 초기화도 그 일부로 돈다.
3. 로더 락 ── 모든 알림을 직렬화하는 락 하나
왜 DllMain만의 제약이 이렇게 심한가. 답은 로더의 구조에 있습니다.
DLL 로드, 언로드, 각종 알림이라는 일련의 작업을 일관되게 유지하기 위해, OS 로더는 프로세스당 하나의 로더 락으로 작업을 직렬화합니다. 그리고 중요한 점은 DllMain이 이 로더 락을 쥔 채로 호출된다는 것입니다.1 DllMain 안에 있는 동안, 그 프로세스의 다른 모든 DLL 로드와 스레드 시작 알림은 이 락이 풀리기를 기다립니다.
그 구조에서 금지의 이유가 연달아 나옵니다.
LoadLibrary를 호출해서는 안 되는 이유는 로더 락 재진입, 또는 순환 로드 순서 의존이 생기기 때문입니다. 초기화가 아직 끝나지 않은 DLL의 함수를 호출하는 결과도 납니다.2- 다른 스레드와의 동기화가 위험한 이유는, 기다리는 상대 스레드가 로더 락이 필요한 순간(시작·종료 알림,
GetModuleHandle계열 API 호출 등)이 있기 때문입니다. 이쪽은 로더 락을 쥐고 상대를 기다리고, 상대는 로더 락을 기다립니다── 전형적인 락 순서 역전입니다.6 - User, Shell, COM 함수가 위험한 이유는 내부에서 다른 시스템 구성 요소를 로드하기 때문입니다. 초기화 전이나 해체 후의 구성 요소를 건드려 액세스 위반이 납니다.2
sequenceDiagram
accTitle: DllMain 안에서 스레드를 기다리면 데드락이 나는 이유
accDescr: 로더 락을 쥔 DllMain이 워커 스레드의 종료를 기다리지만, 종료하려는 워커는 DLL_THREAD_DETACH를 받기 위해 로더 락이 풀리기를 기다려, 서로 기다리며 데드락이 난다
participant L as Loader(락을 쥠)
participant D as DllMain
participant W as Worker
L->>D: DLL_PROCESS_DETACH
D->>W: 종료를 요청하고 기다린다
W->>W: 작업을 끝낸 뒤 종료한다
Note over W: 종료 알림에 락이 필요하다
Note over D,W: DllMain이 락을 쥐고, W는 기다린다
그림 2: 「DllMain이 스레드 종료를 기다린다」는 구조적 데드락이다. 스레드 종료 자체가 로더 락을 필요로 하기 때문이다.
핵심은 이것이 「운이 나쁘면 일어나는」 종류가 아니라 구조적으로 확정되어 성립한다는 점입니다. 문서는 로더 락을 앱이 정의하는 락 계층의 꼭대기(가장 먼저 잡는 것)로 다루라고 말합니다. DllMain 안에서는 이미 그 최상위 락을 쥐고 있으므로, 거기서 더 나아가 다른 것을 기다리는 행위는 모두 위험하다── 그렇게 기억하는 것이 유용합니다.6
flowchart TB
accTitle: 로더 락과 사설 락 사이의 락 순서 역전
accDescr: 로더 락을 쥔 DllMain이 사설 락을 잡으러 가고, 그 사설 락을 쥔 워커가 GetModuleHandle 등을 위해 로더 락을 잡으러 가, 획득 순서가 역전되어 데드락이 난다
d["DllMain: 로더 락을 쥐고 있음"] --> dg["사설 락 G를 잡으러 간다"]
w["Worker: 사설 락 G를 쥐고 있음"] --> wl["로더 락을 잡으러 간다"]
dg -.-> dead["획득 순서 역전에 의한 데드락"]
wl -.-> dead
wl -.-> api["GetModuleHandle 등이 내부에서 필요로 한다"]
그림 3: GetModuleHandle 같은 무해해 보이는 API도 내부에서 로더 락을 필요로 하므로, 사설 락과의 순서 역전이 성립할 수 있다.
또한 DllMain 안에서 CreateThread를 호출하는 것 자체도 권장되지 않습니다. 만들어진 스레드는 DLL_THREAD_ATTACH 알림을 처리하려면 로더 락이 필요하므로, 현재 실행 중인 DllMain이 돌아와 락을 풀 때까지 실행을 시작할 수 없습니다. 따라서 DllMain 안에서 그 스레드의 시작이나 종료를 기다리는 것은 즉시 데드락입니다. 수명 문제도 있습니다── DllMain이 돌아온 뒤, 아직 실행을 시작하지 않은 스레드가 남은 채로 DLL이 언로드되면, 스레드의 시작 주소가 이미 해제된 코드를 가리켜 크래시합니다.3
4. C++ 개발자가 쉽게 밟는 두 지뢰
지뢰 1: 전역 객체의 동적 초기화. 2장에서 말한 대로, 정적 객체의 생성자는 DllMain 제약 아래에서 돕니다. 설정 파일을 읽고, 로깅 시설을 세우고, COM을 초기화하고, 스레드를 시작하는── 그런 일을 하는 생성자가 있는 전역을 DLL에 두는 순간, 「DllMain에서 해서는 안 되는 일」을 실행하게 됩니다. 컴파일 때 고정되는 상수 초기화(constexpr로 만들 수 있는 것)는 안전하고, 함수 호출이 끼는 초기화는 미뤄야 합니다.
flowchart TB
accTitle: 전역 객체 초기화가 지뢰가 되는 경로
accDescr: DLL 로드 때 로더 락이 잡히고, 전역 객체의 생성자가 CRT 진입점을 통해 돌므로, 그 생성자 안의 LoadLibrary, 스레드 동기화, COM 초기화는 DllMain 금지의 실행이 된다
load["DLL 로드(로더 락 획득)"] --> crt["CRT 진입점"]
crt --> ctor["전역 객체의 생성자"]
ctor --> ng1["LoadLibrary에 해당하는 작업"]
ctor --> ng2["스레드를 시작해 끝나기를 기다림"]
ctor --> ng3["COM 또는 User32 사용"]
ng1 -.-> risk["모두 DllMain 금지에 들어간다"]
ng2 -.-> risk
ng3 -.-> risk
그림 4: 「DllMain이 비어 있으니 안전하다」도, 정교한 초기화가 있는 전역이 있는 순간 같은 위험이 되살아난다.
지뢰 2: C++/CLI(혼합 어셈블리). 네이티브 DLL을 C++/CLI로 감싸는 구성(래퍼 기사에서 다룬 형태)에서는, 로더 락 아래에서 MSIL(매니지드 코드)을 실행할 위험이 있습니다. MSIL 실행은 CLR 초기화나 다른 어셈블리 로드를 유발할 수 있습니다. 컴파일러는 DllMain이 MSIL을 직접 실행하는 코드에 경고 C4747을 내지만, 다른 모듈의 함수를 통한 간접 실행은 감지하지 못합니다. DllMain과 거기서 호출하는 함수를 #pragma unmanaged로 네이티브 컴파일하거나, DllMain을 아예 두지 않는 구성을 쓰십시오.4
flowchart TB
accTitle: 로더 락 아래의 MSIL 실행을 감지할 수 있는가
accDescr: DllMain이 MSIL을 직접 실행하는 코드는 컴파일러가 경고 C4747로 감지할 수 있지만, 다른 모듈의 함수를 통한 간접 실행은 감지하지 못하므로, 호출 트리를 검토하고 네이티브 컴파일을 고집해 막아야 한다
d2["DllMain에서의 호출"] --> dir["MSIL을 직접 실행"]
d2 --> ind["다른 모듈을 통해 실행"]
dir --> c47["경고 C4747로 감지 가능"]
ind --> nc["컴파일러는 감지하지 못한다"]
nc -.-> rv["검토와 #pragma unmanaged로 막는다"]
그림 5: C4747은 직접 실행만 지켜 준다. 간접 경로는 검토로만 잡을 수 있다.
5. 올바른 설계 ── 「미루기」를 기본 방침으로
공식 베스트 프랙티스의 권고는 명확합니다.1
- 할 수 있는 초기화는 컴파일 때(정적으로) 끝냅니다. 먼저 동적 초기화를 정적으로 바꿀 수 있는지 묻습니다.
- 나머지는 첫 사용까지 미룹니다. 첫 사용이 DLL 로드가 끝난 뒤 호출되는 평범한 API에서 일어나면, 초기화는 로더 락 밖에서 돌고 Windows API 거의 전체를 안전하게 쓸 수 있습니다. 첫 접근의 배타에는
INIT_ONCE(일회 초기화) 또는 C++ magic statics(함수 로컬 static)를 쓸 수 있습니다. 다만 미루기가 만능은 아닙니다── 그 첫 접근 자체가DllMain이나 정적 초기화기에서 이루어지면, 초기화기는 여전히 로더 락 아래에서 돌고 같은 제약으로 돌아갑니다. - 일찍 감지해야 하는 실패에만 예외를 둡니다. 깨진 설정 파일이면 로드 자체가 실패해야 한다는 요구가 있을 수 있습니다. 그때도 「시도하고 즉시 실패」의 최소에 머무릅니다.
- DLL_PROCESS_ATTACH에서
DisableThreadLibraryCalls를 검토합니다. DLL이 스레드 알림을 쓰지 않으면 알림 비용 자체를 없앨 수 있습니다(정적 CRT나 정적 TLS를 쓸 때는 제외).5 - Application Verifier로 검사합니다.
DllMain안의 위험한 호출 상당수는 Application Verifier가 런타임에 감지합니다.1
flowchart TB
accTitle: DLL 초기화의 설계 지침
accDescr: 먼저 초기화를 컴파일 시 정적 초기화로 할 수 있는지 보고, 안 되면 기본은 첫 사용까지 미루며, DllMain에는 로드 실패로 일찍 감지해야 하는 최소만 남긴다
q1{"컴파일 때 정할 수 있는가?"} -->|"예"| s["정적 초기화로 만든다"]
q1 -->|"아니오"| q2{"실패를 로드 때 감지해야 하는가?"}
q2 -->|"아니오"| lazy["첫 사용까지 미룬다(기본)"]
q2 -->|"예"| min["DllMain에서 최소만 한다"]
lazy -.-> once["INIT_ONCE 또는 함수 로컬 static으로 배타"]
그림 6: 판단 순서는 「정적으로 할 수 있는가 → 미룰 수 있는가」이고, DllMain에 남기는 것은 일찍 감지해야 하는 최소뿐이다.
DisableThreadLibraryCalls를 적용할지는 다음 분기로 기계적으로 정할 수 있습니다.
flowchart TB
accTitle: DisableThreadLibraryCalls를 호출할지
accDescr: 정적 CRT와 링크된 DLL에서는 호출하지 않고, 정적 TLS가 유효하면 호출 자체가 실패하므로 호출하지 않으며, 둘 다 아니고 DLL이 스레드 알림을 쓰지 않으면 DLL_PROCESS_ATTACH에서 반환값을 확인하며 호출해 알림 비용을 줄인다
q1{"정적 CRT와 링크되었는가?"} -->|"예"| no2["호출해서는 안 된다"]
q1 -->|"아니오"| q2{"정적 TLS를 쓰는가?"}
q2 -->|"예"| eff["호출은 어차피 실패한다(FALSE)"]
q2 -->|"아니오"| q3{"스레드 알림이 필요한가?"}
q3 -->|"아니오"| yes["ATTACH에서 호출한다(반환값 확인)"]
q3 -->|"예"| keep["호출하지 않고 알림을 처리한다"]
그림 7: 정적 CRT, 정적 TLS, 알림 필요 여부 세 조건이 호출해야 하는지를 유일하게 정한다.
언로드 때 스레드를 멈추는 일에 공식 문서는 구체적인 프로토콜을 줍니다. DLL_PROCESS_DETACH(FreeLibrary를 통한 언로드)에서 워커 스레드의 종료를 「기다리지」 말고, (1) 이벤트로 종료를 신호하고, (2) 스레드 측이 작업을 일관된 상태로 접고, 신호를 되돌린 뒤 무한 대기에 들어가며, (3) DllMain 측이 일관된 상태를 확인한 뒤 TerminateThread로 스레드를 접는 형태입니다.3 거칠어 보이지만, 「DllMain 안에서 스레드의 자연 종료를 기다려서는 안 된다」는 제약 안의 현실적 답으로 문서화되어 있습니다.
sequenceDiagram
accTitle: 언로드 때 스레드를 멈추는 프로토콜
accDescr: DllMain이 이벤트로 워커 스레드에 종료를 신호하고, 워커는 작업을 일관된 상태로 접은 뒤 신호를 되돌리고 무한 대기에 들어가며, DllMain이 일관된 상태를 확인한 뒤 스레드를 종료한다
participant D as DllMain(DETACH 처리)
participant W as 워커 스레드
D->>W: 이벤트로 종료를 신호한다
W->>W: 작업을 일관된 상태로 접는다
W->>D: 일관 완료를 신호하고 영원히 기다린다
D->>W: TerminateThread로 종료한다
Note over D,W: 자연 종료를 기다리지 않으므로 데드락이 없다
그림 8: 「자연 종료를 기다리지」 않고 「일관 신호를 기다린 뒤 끊는다」면 로더 락과의 충돌을 피한다.
제1원칙으로서, 가장 안전한 설계는 언로드될 수 있는 DLL이 스레드를 소유하지 않고, 스레드 소유를 EXE 쪽에 두는 것입니다.
프로세스 종료의 DLL_PROCESS_DETACH는 반대입니다. 아무것도 하지 않고 돌아가는 것이 이상적입니다. 이 시점에는 다른 모든 스레드가 이미 강제 종료되었고, 의존 DLL이나 런타임의 상태도 믿을 수 없습니다. 여기서의 정교한 작업은 데드락과 크래시만 일으킵니다. 반드시 남겨야 하는 데이터는 앱 자신의 종료 경로에서 쓰고, 이 알림에 의존하지 마십시오.3
6. 맞닥뜨렸을 때의 조사 방법
로더 락 행에는 알아볼 수 있는 지문이 있습니다.
행 덤프의 스택을 봅니다. 멈춘 순간의 덤프를 떠서 각 스레드의 스택을 검사합니다. ntdll.dll 로더 함수(Ldr로 시작하는 이름 계열) 안에서 락을 기다리는 스레드와, DllMain이나 정적 초기화기(dynamic initializer) 안에서 다른 것을 기다리는 스레드의 쌍을 찾으면 거의 확실합니다. LoadLibrary 호출 한가운데에서 멈춘 스레드도 또 하나의 전형입니다.
flowchart TB
accTitle: 로더 락 행의 지문
accDescr: 행 덤프에서 ntdll 로더 함수 안에서 락을 기다리는 스레드와 DllMain 또는 정적 초기화기 안에서 다른 것을 기다리는 스레드가 둘 다 있으면, 거의 확실하게 로더 락 데드락으로 다룰 수 있다
dump["행 덤프"] --> t1["Ldr 계열 함수에서 락을 기다리는 스레드"]
dump --> t2["DllMain 또는 정적 초기화기 안에서 기다리는 스레드"]
t1 --> pair{"둘 다 있는가?"}
t2 --> pair
pair -->|"예"| conf["거의 확실히 로더 락 데드락"]
pair -->|"아니오"| other["다른 종류의 행으로 조사한다"]
그림 9: 로더 락 행에는 「Ldr에서 대기 + DllMain 안에서 대기」라는 알아볼 수 있는 지문이 있다.
「타이밍 의존」 성격을 의심합니다. 로더 락 데드락은 DLL 로드가 스레드 시작이나 종료와 겹치는 순간에만 성립합니다. 「기동 때 가끔」, 「특정 머신에서만」, 「서비스로 돌릴 때만」 같은 재현 조건은 이런 문제의 징후입니다.
예방 검사를 돌립니다. Application Verifier를 켜고 테스트를 돌리면 DllMain 안의 위험한 호출을 런타임에 감지할 수 있습니다.1 C++/CLI에서는 경고 C4747을 무시하지 말고, DllMain에서 도달 가능한 함수의 검토에 「간접적으로 LoadLibrary를 호출하는 함수」(COM 초기화, 일부 CRT 기능, delay-loaded import 등)의 각도를 검토 체크리스트에 더하면, 출하 전에 사고를 잡을 수 있습니다. delay-loaded import의 첫 호출이 내부에서 LoadLibrary가 되는 점은 놓치기 쉽습니다.
7. 정리
DllMain은 로더 락(프로세스당 하나, 모든 DLL 알림을 직렬화하는 락)을 쥔 채로 호출됩니다. 모든 제약은 거기서 나옵니다.- 금지의 핵심은 「
LoadLibrary/FreeLibrary를 호출하지 말 것」, 「다른 스레드와 동기화하지 말 것」, 「Kernel32 이외의 DLL에 의존하는 함수를 호출하지 말 것」입니다. CRT를 통해 도는 정적 객체의 생성자와 소멸자도 같은 제약에 들어갑니다. - 기본 설계 방침은 미루기입니다. 정적으로 할 수 있는 초기화는 정적으로 하고, 나머지는 첫 사용까지 미룹니다.
DisableThreadLibraryCalls와 Application Verifier를 씁니다. - 언로드 때 스레드를 멈추는 일은 공식 프로토콜(신호 → 일관 확인 → 종료)을 따릅니다. 프로세스 종료의 DLL_PROCESS_DETACH는 이상적으로 비어 있습니다.
- C++/CLI에서 로더 락 아래의 MSIL 실행은 그 자체의 지뢰입니다.
DllMain호출 트리의 네이티브 컴파일을 고집하십시오.
DllMain 제약은 처음에는 부당한 금지 목록처럼 보입니다. 그러나 「최상위 락인 로더 락을 쥔 채로 호출된다」는 한 점을 쥐면, 모든 금지는 같은 원칙의 재진술입니다. 원칙으로 기억하면, 문서에 없는 엣지 케이스를 만나도 「이 작업은 락을 쥔 채로 해도 되는 일인가?」라는 올바른 질문을 할 수 있어야 합니다.
관련 기사
- Windows에서 DLL 이름 해결의 메커니즘 - 검색 순서, Known DLLs, API set, SxS를 실무용으로 정리
- C#에서 네이티브 DLL을 쓴다면 C++/CLI 래퍼가 유력한 이유 - P/Invoke와 비교해 정리
- 멀티스레드 실무 베스트 프랙티스 C++ 편 ── RAII와 jthread로 사고를 구조적으로 없애기
- 스퓨리어스 웨이크업 ── 조건 변수가 「알림 없이」깨어나는 이유와 Windows에서 올바르게 대기하는 방법
- WinDbg + SOS로 크래시 덤프 읽기 ── 수집 이후 실무 분석 입문
- COM STA/MTA 기초 - 스레드 모델과 행(hang)을 피하는 사고방식
관련 상담 영역
합동회사 코무라소프트에서는 기동 시나 DLL 로드 시의 행·데드락 원인 조사(덤프 분석), DllMain과 정적 초기화 주변의 설계 리뷰, C++/CLI 래퍼와 플러그인 DLL을 안전한 초기화 설계로 고치는 작업을 다루고 있습니다. 「특정 환경에서만 기동 때 멈춘다」는 재현이 어려운 단계에서도 상담해 주십시오.
참고 링크
-
Microsoft Learn, Dynamic-Link Library Best Practices. DllMain이 로더 락을 쥔 채로 호출되어 호출할 수 있는 함수가 심하게 제한된다는 점, 이상적인 DllMain은 빈 스텁이며 초기화는 가능한 한 미룬다는 점, 컴파일 시 정적 초기화의 권고, 일찍 감지해야 하는 실패에는 최소만 한다는 점, Application Verifier로 전형적인 DllMain 실수를 감지한다는 점에 대해. ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
Microsoft Learn, DllMain entry point. 진입점에서는 단순한 초기화와 종료만 수행한다는 점, LoadLibrary / FreeLibrary를 호출해서는 안 되는 이유(순환 로드 순서와 초기화 전·종료 후 DLL 사용), Kernel32.dll이 이미 로드되어 있음이 보장되어 다른 DLL을 로드하지 않는 범위에서 호출할 수 있다는 점, 안전 함수의 망라 목록이 없다는 점, User, Shell, COM 함수가 액세스 위반을 일으킨다는 점, DLL 알림이 직렬화되어 다른 스레드나 프로세스와의 통신이 데드락을 일으킨다는 점, CRT가 링크되면 정적 객체의 생성자와 소멸자에도 같은 제약이 적용된다는 점에 대해. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7
-
Microsoft Learn, Dynamic-Link Library Best Practices - Best Practices for Synchronization. DllMain 안에서 스레드 종료를 기다리면 데드락이 나는 구조(스레드 종료의 DLL_THREAD_DETACH 알림에 로더 락이 필요), 언로드 때 스레드를 멈추는 프로토콜(이벤트로 신호, 일관 상태 확인, 그다음 종료), 프로세스 종료의 DLL_PROCESS_DETACH에서는 다른 스레드가 이미 강제 종료되고 주소 공간 일관성의 보장이 없어 이상적인 핸들러는 비어 있다는 점, DllMain에서 스레드를 만들면 초기화가 미완성인 채 알림이 큐에 남아 문제를 일으킨다는 점에 대해. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, Initialization of Mixed Assemblies. 로더 락 아래에서 MSIL을 실행하지 않는다는 점, DllMain과 그 호출 트리를 MSIL로 컴파일하지 않고 #pragma unmanaged로 대처한다는 점, DllMain이 MSIL을 직접 실행하려 하면 경고 C4747이 나오지만 다른 모듈을 통한 간접 실행은 감지되지 않는다는 점, 정적 객체의 동적 초기화기도 같은 문제를 일으킬 수 있다는 점에 대해. ↩ ↩2
-
Microsoft Learn, DisableThreadLibraryCalls function (libloaderapi.h). DLL_THREAD_ATTACH / DLL_THREAD_DETACH 알림을 끄면 스레드 생성·파괴 때의 오버헤드가 줄어든다는 점, 정적 CRT와 링크된 DLL에서는 호출하지 않는다는 점, 정적 TLS(thread_local 또는 __declspec(thread))가 유효하면 최적화가 수행되지 않는다는 점에 대해. ↩ ↩2
-
Microsoft Learn, Dynamic-Link Library Best Practices - Deadlocks Caused by Lock Order Inversion. 락 계층을 정의하고 항상 같은 순서로 획득한다는 점, 로더가 DllMain을 호출하기 전에 로더 락을 획득하므로 로더 락이 락 계층의 꼭대기에 있어야 한다는 점, GetModuleFileName처럼 간접적으로 로더 락을 잡는 API와 사설 락 사이의 획득 순서를 지킨다는 점, 락 순서 역전에 의한 데드락의 구체적 예에 대해. ↩ ↩2
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
Win32 스레드 풀 API ── CreateThreadpoolWork로 「스레드를 만들지 않는」병렬 처리
네이티브 코드에서 CreateThread를 여기저기 만들고 있지는 않은가요. Vista에서 개편된 Win32 스레드 풀 API의 work·timer·wait·io 네 객체, 클린업 그룹, 콜백에서 해서는 안 되는 일까지 1차 정보를 바탕으로 해설...
「응답 없음」의 정체 ── Windows가 앱의 행을 판단하는 방식과 멈추지 않는 설계
Windows의 「응답 없음」은 창이 5초 동안 메시지를 꺼내지 않았다고 OS가 판단해 고스트 창으로 바꾸는 메커니즘입니다. 그 판단의 내부, 행의 고전적 원인, 무거운 작업을 UI 스레드에서 떼어 내는 설계, 행 조사 절차를 정리합니다.
스퓨리어스 웨이크업 ── 조건 변수가 「알림 없이」깨어나는 이유와 Windows에서 올바르게 대기하는 방법
조건 변수의 대기는 알림이 오지 않아도 돌아올 수 있습니다(스퓨리어스 웨이크업). Windows 구현에서 사양이 그것을 허용하는 이유를 설명하고, while 루프와 술어로 올바르게 대기하는 방법을 Win32, C++, C#으로 보입니다.
네임드 파이프의 실무 ── Windows 프로세스 간 통신의 정석을 설계부터 보안까지
Windows 프로세스 간 통신의 정석인 네임드 파이프를 실무 관점에서 정리합니다. 바이트/메시지 모드 선택, 여러 클라이언트를 받는 서버 설계, ACL과 위장 보안, .NET NamedPipeStream까지 1차 정보를 바탕으로 설명합니다.
절전에서 재개하면 깨지는 앱 ── Windows 전원 이벤트의 구조와 살아남는 업무 앱
노트북을 열었더니 업무 앱의 연결이 죽어 있었다 ── 원인은 절전을 전제하지 않은 설계입니다. WM_POWERBROADCAST 알림의 흐름, Modern Standby의 동작, 끊김/재연결 설계, 절전 억제, 조사 명령을 1차 정보로 정리합니다.
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
상주 처리, 장비 연동, 운영 로그, 유지 보수 가능한 구조가 필요한 Windows 데스크톱 애플리케이션을 지원합니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- DllMain에서는 정말로 아무것도 할 수 없나요?
- 「아무것도 하지 말라」는 과장이 아니라 공식 설계 자세이며, Microsoft 자신도 이상적인 DllMain은 거의 빈 스텁이라고 말합니다. 안전한 것은 Kernel32.dll 함수의 일부── DllMain이 돌 때 Kernel32는 이미 로드되어 있음이 보장됩니다── 중에서 다른 DLL을 로드하지 않는 범위입니다. 크리티컬 섹션이나 뮤텍스를 만들고 TLS를 쓰는 것은 할 수 있는 일의 예입니다. 반대로 LoadLibrary/FreeLibrary, 다른 스레드와의 동기화, User32·Shell·COM 등의 함수 호출은 데드락과 액세스 위반을 일으키므로 금지입니다. 확신이 없는 초기화는 DllMain에서 하지 말고, 처음 쓰일 때까지 미루십시오.
- C++ 전역(정적 객체)의 생성자도 DllMain 제약에 들어가나요?
- 들어갑니다. DLL이 CRT(C++ 런타임)와 링크되어 있으면, 전역·정적 객체의 생성자와 소멸자는 CRT가 제공하는 진입점을 거쳐 사실상 DllMain의 일부로 실행됩니다. 따라서 생성자에서 LoadLibrary를 호출하거나, 다른 스레드를 시작해 끝나기를 기다리거나, COM을 초기화하는 일은 모두 DllMain에서 하는 것과 같은 위험이 있습니다. 자명하지 않은 초기화가 있는 전역 객체는 포인터를 두고 첫 접근 때 생성하거나, 함수 로컬 static을 써서 작업이 DllMain 밖에서 돌게 합니다.
- DisableThreadLibraryCalls를 호출해야 하나요?
- 조건부로 예입니다. DLL이 DLL_THREAD_ATTACH/DETACH 알림을 필요로 하지 않으면, DLL_PROCESS_ATTACH에서 DisableThreadLibraryCalls를 호출하면 스레드 생성·종료마다의 알림이 멈추고, 스레드를 자주 만드는 프로세스의 오버헤드가 줄어듭니다. 예외가 둘 있습니다. 정적 CRT와 링크된 DLL에서는 호출하지 마십시오(정적 CRT는 스레드 알림이 필요합니다). 또한 thread_local이나 __declspec(thread)를 통한 정적 TLS가 유효하면 호출 자체가 실패하고 FALSE를 반환하므로, 반환값을 확인하는 습관을 들이십시오. 동적 링크 CRT를 쓰는 전형적인 DLL에서, 스레드 알림에 의존하는 것이 없음을 확인한 뒤에 쓰십시오.
- C++/CLI(혼합 매니지드) DLL이 기동 때 멈추는 이유는 무엇인가요?
- 전형적인 원인은 로더 락을 쥔 채로 MSIL(매니지드 코드)을 실행하려 하는 것입니다. C++/CLI 혼합 어셈블리에서 DllMain, 거기서 호출하는 함수, 전역의 동적 초기화기가 MSIL로 컴파일되면, 로더 락 아래에서 CLR 초기화나 다른 어셈블리 로드가 필요해져 데드락이 날 수 있습니다. 컴파일러는 DllMain 자체가 MSIL을 직접 실행하려 하면 경고 C4747을 내지만, 다른 모듈을 통한 간접 실행은 감지하지 못합니다. 대책은 DllMain과 그 호출 트리를 #pragma unmanaged로 네이티브 컴파일하거나, DllMain을 아예 두지 않는 것입니다.
- DLL_PROCESS_DETACH에서 리소스를 정리해도 되나요?
- 답은 「프로세스 종료」와 「FreeLibrary를 통한 언로드」에서 달라집니다. 프로세스 종료의 DLL_PROCESS_DETACH에서는 다른 스레드가 이미 종료되었고, 주소 공간이 여전히 일관하다는 보장도 없으므로, 메모리 해제 같은 정리는 오히려 위험합니다. 공식 지침은 「이상적인 핸들러는 비어 있다」입니다. 반드시 남겨야 하는 데이터는 앱 자신의 종료 경로에서 쓰고, 여기서는 본질적으로 아무것도 하지 않고 돌아갑니다. FreeLibrary를 통한 언로드에서는 프로세스가 계속되므로, 스레드 정지·핸들 닫기 등 완전한 정리가 필요합니다. 다만 DllMain 안에서 스레드 종료를 기다리면 데드락이 나므로, 신호를 보내고, 일관된 상태까지 기다린 뒤, DllMain 밖에서 작업을 끝내는 공식 프로토콜을 따라야 합니다.