Microsoft MVP성태의 닷넷 이야기
글쓴 사람
정성태 (seongtaejeong at gmail.com)
홈페이지
첨부 파일

(시리즈 글이 2개 있습니다.)
디버깅 기술: 218. Windbg로 살펴보는 Win32 Critical Section
; https://www.sysnet.pe.kr/2/0/13904

Windows: 281. C++ - Windows / Critical Section의 안정화를 위해 도입된 "Keyed Event"
; https://www.sysnet.pe.kr/2/0/13905




C++ - Windows / Critical Section의 안정화를 위해 도입된 "Keyed Event"

윈도우에는 문서화되지 않은 동기화 수단으로 "Keyed Event"라는 것이 있는데요,

Keyed Events
; https://locklessinc.com/articles/keyed_events/

Windows XP/2003부터 지원하기 때문에 이제는 그나마 다음과 같이 간단하게 사용할 수는 있습니다.

#define _WIN32_WINNT 0x0500 // Windows 2000 or later

#pragma comment(lib, "ntdll.lib")

#include <Windows.h>
#include <winternl.h>
#include <iostream>
#include <thread>

using namespace std;

extern "C" 
{
    NTSTATUS NTAPI NtCreateKeyedEvent(OUT PHANDLE handle, IN ACCESS_MASK access, IN POBJECT_ATTRIBUTES attr, IN ULONG flags);
    NTSTATUS NTAPI NtOpenKeyedEvent(OUT PHANDLE handle, IN ACCESS_MASK access, IN POBJECT_ATTRIBUTES attr);

    NTSTATUS NTAPI NtWaitForKeyedEvent(IN HANDLE handle, IN PVOID key, IN BOOLEAN alertable, IN PLARGE_INTEGER mstimeout);
    NTSTATUS NTAPI NtReleaseKeyedEvent(IN HANDLE handle, IN PVOID key, IN BOOLEAN alertable, IN PLARGE_INTEGER mstimeout);
}

int main()
{
    HANDLE mhandle;
    NtCreateKeyedEvent(&mhandle, -1, NULL, 0);
    cout << "Create Handle(null attr): " << mhandle << endl;

    if (mhandle)
    {
        CloseHandle(mhandle);
    }

    return 0;
}

"Keyed Event"라는 이름에서 왠지 기존의 Event와 유사하게 동작할 거라고 기대할 수 있는데 실상은 그렇지 않고, 오히려 C#의 Monitor.Wait/Pluse와 비슷합니다.

따라서, NtWaitForKeyedEvent는 무조건 대기를 하게 되고, NtReleaseKeyedEvent는 대기 중인 스레드가 1) 있다면 그중에서 오직 한 개의 스레드만을 깨우고, 2) 없다면 마찬가지로 blocking이 걸려 대기합니다. (C#의 Monitor.PulseAll에 대응하는 함수는 없는 것 같습니다.)

가령, 다음과 같이 예제 코드를 만들면,

PVOID memoryPos = &mhandle;

thread t1([&]() {
    NtWaitForKeyedEvent(mhandle, memoryPos, 0, NULL); // 1개 대기
    });

thread t2([&]() {
    NtWaitForKeyedEvent(mhandle, memoryPos, 0, NULL); // 2개 대기
    });

Sleep(1000);

thread t3([&]() {
    NtReleaseKeyedEvent(mhandle, memoryPos, 0, NULL); // 둘 중의 한 개 스레만 깨움
            // 만약 다른 스레드가 wait 호출을 하지 않았다면, blocking 상태로 대기
    });

Release를 한 번만 호출했기 때문에 Wait을 호출한 스레드 중 한 개만을 깨우게 됩니다.

깨워야 할 대상을 정하는 것은 두 번째 인자로 전달한 PVOID 값으로 구분하는데, 재미있는 점은 이 값이 꼭 유효한 메모리일 필요는 없다는 점입니다. 그래서 다음과 같은 식으로 임의의 값을 넣어도 상관없습니다.

PVOID key = (PVOID)0x0000000000000004;

thread t1([&]() { NtWaitForKeyedEvent(mhandle, key, 0, NULL); });
thread t2([&]() { NtReleaseKeyedEvent(mhandle, key, 0, NULL); });

t1.join();
t2.join();

단지, 저 값을 무작위로 선택하기보다는 프로세스가 유지되는 동안 유일할 수 있는 값을 선택하는 것이 선호되는데, 따라서 Keyed Event가 유효한 범위 내에서 함께 생존하는 변수의 주소가 적절한 대상이 될 수 있습니다.




그런데, 왜 "Keyed Event"가 나온 것일까요? 바로 Critical Section이 그 이야기의 시작이 됩니다.

Windows with C++ - The Evolution of Synchronization in Windows and C++
 - Critical Section
; https://learn.microsoft.com/en-us/archive/msdn-magazine/2012/november/windows-with-c-the-evolution-of-synchronization-in-windows-and-c#critical-section

Windows keyed events, critical sections, and new Vista synchronization features
; https://joeduffyblog.com/2006/11/28/windows-keyed-events-critical-sections-and-new-vista-synchronization-features/

이유는 알 수 없지만, 마이크로소프트는 Critical Section을 최초 설계 시 InitializeCriticalSection 함수의 반환값을 void로 만들었습니다. 의미인즉, 절대로 실패하지 않는다는 것을 보장해야 하는데, 애석하게도 그 당시 Critical Section에는 임계 영역에 접근하지 못한 스레드의 대기를 위해 AutoReset Event 유형의 커널 개체를 필요로 했으므로,

typedef struct _RTL_CRITICAL_SECTION {
    PRTL_CRITICAL_SECTION_DEBUG DebugInfo;

    LONG LockCount;
    LONG RecursionCount;
    HANDLE OwningThread;
    HANDLE LockSemaphore;
    ULONG_PTR SpinCount;
} RTL_CRITICAL_SECTION, *PRTL_CRITICAL_SECTION;

만약 메모리가 부족하거나 HANDLE이 열릴 수 있는 최대 수치를 넘어서는 상황에서 InitializeCriticalSection을 호출하면 LockSemaphore를 초기화하는 동안 실패할 수 있는 가능성이 (비록 낮지만) 있습니다.

또한, 나중에는 (Windows 2000부터) InitializeCriticalSection에서 LockSemaphore를 초기화하는 대신, 실제로 경합이 벌어져 LockSemaphore를 필요로 하게 되는 시점까지 미루는 방식으로 변경했는데 이로 인해 LockSemaphore 초기화 실패 문제가 EnterCriticalSection으로 떠맡겨졌고, 결국엔 (안정성을 높이기 위해) EnterCriticalSection 호출 시 try/catch 블록을 사용하는 책임이 개발자들에게 부과되었습니다.

위와 같은 문제를 아래의 기록에서 찾아볼 수 있는데요,

Do not use InitializeCriticalSection without exception handling wrappings [@ RtlRaiseStatus - InitializeCriticalSection - PR_NewLock] => Exception STATUS_NO_MEMORY (c0000017)
; https://bugzilla.mozilla.org/show_bug.cgi?id=295514

그래서 저 당시에는 InitializeCriticalSection에 __try / __except 블록을 사용해야 했습니다. 재미있게도 InitializeCriticalSectionAndSpinCount 함수는 반환값을 가지고 있으므로 이를 통해 실패 여부를 확인할 수 있는데요, 그나마도 Windows Me/98/95에서는 반환값이 없었다고 언급이 됩니다. ^^;

참고로 InitializeCriticalSectionAndSpinCount의 경우, spin count 설정 시 최상위 1비트를 함께 전달하면 지연시키지 않고 바로 초기화할 수 있는 방법까지 제공합니다.

// 반환값도 가지고, LockSemaphore에 대한 초기화를 지연시키지 않는 것도 가능

BOOL result = InitializeCriticalSectionAndSpinCount(&cs, 0x80000000 | realSpinCount));

어쨌든 이런 불편한 상황을 타개하기 위해 마이크로소프트는 절대로 실패하지 않는 Critical Section이 필요했고, 이를 위해 Windows Server 2003부터 Keyed Event를 도입하게 된 것입니다. 이전 예제 코드에서 다뤘듯이, Keyed Event는 PVOID 값을 key로 사용하므로 프로세스 내의 모든 Critical Section에서 1개의 "Keyed Event" HANDLE을 공유하면서 PVOID만 다르게 사용하는 방식으로 구현이 됐습니다.

              ┌────────────────────────┐
              │   Single Keyed Event   │
              │ (shared by all threads)│
              └─────────▲──────────────┘
                        |
        ┌───────────────┼────────────────┐
        |               |                |
    Key: ptr1         Key: ptr2         Key: ptr3
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ Critical Sec 1│ │ Critical Sec 2│ │ Critical Sec 3│
└───────────────┘ └───────────────┘ └───────────────┘

그런 이유로, Windows Server 2003에서는 모든 프로세스가 기본적으로 1개의 "Keyed Event"를 Critical Section 사용만을 목적으로 미리 HANDLE을 열어 제공하는데요, 실제로 Process Explorer를 통해 아무 프로세스나 HANDLE을 열거해 보면 다음과 같이 "\KernelObjects\CritSecOutOfMemoryEvent"라는 이름의 "KeyedEvent"가 보입니다.

keyed_event_1.png

재미있는 것은, Keyed Event가 처음 나온 저 당시에는 여전히 LockSemaphore를 계속 사용할 수밖에 없었다고 합니다. 왜냐하면 기존의 프로그램 중에는 Critical Section의 내부 필드였던 그 값을 직접 사용하는 경우가 있었기 때문인데, 일단은 호환성을 위해 1) LockSemaphore를 우선적으로 사용하되 그것의 초기화가 실패한 경우에만 2) Keyed Event를 사용하는 방식으로 구현이 됐습니다.

그러다 Windows Vista / Server 2008에 들어서면서 내부적으로 연결 리스트에 기반한 Keyed Event 구현을 Hash로 개선했고, 이를 바탕으로 SRWLock, Condition Variables, One-Time Initialization API 등도 함께 제공하면서, 이 시기에 Critical Section도 "\KernelObjects\CritSecOutOfMemoryEvent"에 기댄 LockSemaphore 필드를 더 이상 사용하지 않는 방식으로 변경됩니다.

실제로 현재 Windows 11에서 Critical Section으로 EnterCriticalSection을 2개의 스레드에서 호출해 진입한 후 호출 스택을 보면 이런 식으로 나오는데요,

// 스레드 A: EnterCriticalSection을 호출해 Sleep으로 대기 중

// 스레드 B: EnterCriticalSection을 호출했으나 스레드 A에 의해 점유된 lock이 해제되기를 기다리는 상태
 
   5  Id: f9bc.10968 Suspend: 1 Teb: 00000044`8fcb8000 Unfrozen
 # Child-SP          RetAddr               Call Site
00 00000044`901ff4f8 00007ffe`2ca0f5f5     ntdll!NtWaitForAlertByThreadId+0x14
01 00000044`901ff500 00007ffe`2ca1a6df     ntdll!RtlpWaitOnCriticalSection+0x5a5
02 00000044`901ff600 00007ffe`2ca19e38     ntdll!RtlpEnterCriticalSectionContended+0x1ff
03 00000044`901ff680 00007ff7`af5337b1     ntdll!RtlEnterCriticalSection+0xf8
04 00000044`901ff6c0 00007ff7`af5336fa     ConsoleApplication2!DoWork+0x31 [C:\temp\ConsoleApplication1\ConsoleApplication2\ConsoleApplication2.cpp @ 11] 
05 00000044`901ff7c0 00007ff7`af532e5c     ConsoleApplication2!`main'::`4'::<lambda_1>::operator()+0x6a [C:\temp\ConsoleApplication1\ConsoleApplication2\ConsoleApplication2.cpp @ 33] 
06 00000044`901ff8c0 00007ff7`af5326f7     ConsoleApplication2!std::invoke<`main'::`4'::<lambda_1> >+0x2c [C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC\Tools\MSVC\14.42.34433\include\type_traits @ 1695] 
07 00000044`901ff9c0 00007ffd`578a3010     ConsoleApplication2!std::thread::_Invoke<std::tuple<`main'::`4'::<lambda_1> >,0>+0x87 [C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC\Tools\MSVC\14.42.34433\include\thread @ 60] 
08 00000044`901ffb20 00007ffe`2babe8d7     ucrtbased!thread_start<unsigned int (__cdecl*)(void *),1>+0xb0 [minkernel\crts\ucrt\src\appcrt\startup\thread.cpp @ 97] 
09 00000044`901ffb80 00007ffe`2cabbf6c     KERNEL32!BaseThreadInitThunk+0x17
0a 00000044`901ffbb0 00000000`00000000     ntdll!RtlUserThreadStart+0x2c

보는 바와 같이 Keyed Event가 아닌, (Windows 8부터 도입됐으나 공개되지 않은) NtWaitForAlertByThreadId를 사용하고 있으며 이것은 예전에 한 번 소개했던 WaitOnAddress와 연동하는 방식입니다.

C# - WaitOnAddress Win32 API 사용
; https://www.sysnet.pe.kr/2/0/11064

// WaitOnAddress는 Keyed Event가 지원하지 못했던 PulseAll과 같은 기능도 제공합니다.

참고로, ReactOS의 소스 코드를 보면,

RtlpWaitForCriticalSection(PRTL_CRITICAL_SECTION CriticalSection)
; https://github.com/reactos/reactos/blob/master/sdk/lib/rtl/critical.c#L117

여전히 NtWaitForKeyedEvent로 구현된 것을 볼 수 있습니다.




그나저나, Keyed Event 자체는 Mutex처럼 WaitForSingleObject + Signal 함수의 구조는 아닌데 어떻게 Critical Section에서 사용할 수 있었는지 궁금하신 분이 계실까요? ^^ 그런 분들은 다음의 글에 제공하는 Fast Mutexes 구현을 참고하시면 좋을 듯합니다. (유사하게, WaitOnAddress를 이용한 Critical Section의 구현도 가능합니다.)

Keyed Events
; https://locklessinc.com/articles/keyed_events/

첨부 파일은 위의 글에 구현한 fast_mutex, fast_cv를 Visual C++로 사용한 예제입니다.

마지막으로, NtCreateKeyedEvent의 경우 OBJECT_ATTRIBUTES에 이름을 설정하는 것이 가능한데요, 실제로 해보면 c0000061(STATUS_PRIVILEGE_NOT_HELD) 오류가 발생합니다.

{
    UNICODE_STRING ustr;
    RtlInitUnicodeString(&ustr, L"MyKeyedEvent");
        
    OBJECT_ATTRIBUTES attr;
    InitializeObjectAttributes(&attr, &ustr, 0, NULL, NULL);
        
    HANDLE mhandle;
    NTSTATUS ntResult = NtCreateKeyedEvent(&mhandle, -1, &attr, 0);
    cout << "Create Handle(named attr): " << mhandle << endl;
    cout << "Error: " << hex << ntResult << endl; // c0000061 == STATUS_PRIVILEGE_NOT_HELD (A required privilege is not held by the client.)
                                                    // c000000d == STATUS_INVALID_PARAMETER
    if (mhandle)
    {
        CloseHandle(mhandle);
    }
}

/* 실행 결과:
Create Handle(named attr): 0000000000000000
Error: c0000061
*/

딱히 문서상 공개된 것이 없어 어떤 특권이 필요한지는 모르겠습니다. (간단하게 관리자 권한으로 테스트해도 동일한 결과가 나옵니다.) 관례적으로 보면 named kernel object라면 다른 프로세스에서도 열 수 있을 텐데요, 그런 경우 서로 다른 프로세스에서의 중복 키가 간섭을 일으키는 것은 아닌지...? 까지는 확인이 안 됩니다. 단지, Windows Server 2003의 "\KernelObjects\CritSecOutOfMemoryEvent" 개체가 모든 프로세스의 Critical Section에서 공유된다고 하는 것을 보면 아마도 프로세스별로 키가 격리되는 것이 아닐까... 추측만 해봅니다. (관련해서 아시는 분은 덧글 부탁드립니다. ^^)




[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]







[최초 등록일: ]
[최종 수정일: 3/28/2025]

Creative Commons License
이 저작물은 크리에이티브 커먼즈 코리아 저작자표시-비영리-변경금지 2.0 대한민국 라이센스에 따라 이용하실 수 있습니다.
by SeongTae Jeong, mailto:techsharer at outlook.com

비밀번호

댓글 작성자
 




1  2  [3]  4  5  6  7  8  9  10  11  12  13  14  15  ...
NoWriterDateCnt.TitleFile(s)
13868정성태1/17/20253095Windows: 277. Hyper-V - Windows 11 VM의 Enhanced Session 모드로 로그인을 할 수 없는 문제
13867정성태1/17/20254032오류 유형: 943. Hyper-V에 Windows 11 설치 시 "This PC doesn't currently meet Windows 11 system requirements" 오류
13866정성태1/16/20254230개발 환경 구성: 739. Windows 10부터 바뀐 device driver 서명 방법
13865정성태1/15/20253929오류 유형: 942. C# - .NET Framework 4.5.2 이하의 버전에서 HttpWebRequest로 https 호출 시 "System.Net.WebException" 예외 발생
13864정성태1/15/20253877Linux: 114. eBPF를 위해 필요한 SELinux 보안 정책
13863정성태1/14/20253327Linux: 113. Linux - 프로세스를 위한 전용 SELinux 보안 문맥 지정
13862정성태1/13/20253600Linux: 112. Linux - 데몬을 위한 SELinux 보안 정책 설정
13861정성태1/11/20253907Windows: 276. 명령행에서 원격 서비스를 동기/비동기로 시작/중지
13860정성태1/10/20253586디버깅 기술: 216. WinDbg - 2가지 유형의 식 평가 방법(MASM, C++)
13859정성태1/9/20253939디버깅 기술: 215. Windbg - syscall 이후 실행되는 KiSystemCall64 함수 및 SSDT 디버깅
13858정성태1/8/20254086개발 환경 구성: 738. PowerShell - 원격 호출 시 "powershell.exe"가 아닌 "pwsh.exe" 환경으로 명령어를 실행하는 방법
13857정성태1/7/20254120C/C++: 187. Golang - 콘솔 응용 프로그램을 Linux 데몬 서비스를 지원하도록 변경파일 다운로드1
13856정성태1/6/20253698디버깅 기술: 214. Windbg - syscall 단계까지의 Win32 API 호출 (예: Sleep)
13855정성태12/28/20244427오류 유형: 941. Golang - os.StartProcess() 사용 시 오류 정리
13854정성태12/27/20244528C/C++: 186. Golang - 콘솔 응용 프로그램을 NT 서비스를 지원하도록 변경파일 다운로드1
13853정성태12/26/20244011디버깅 기술: 213. Windbg - swapgs 명령어와 (Ring 0 커널 모드의) FS, GS Segment 레지스터
13852정성태12/25/20244453디버깅 기술: 212. Windbg - (Ring 3 사용자 모드의) FS, GS Segment 레지스터파일 다운로드1
13851정성태12/23/20244215디버깅 기술: 211. Windbg - 커널 모드 디버깅 상태에서 사용자 프로그램을 디버깅하는 방법
13850정성태12/23/20244710오류 유형: 940. "Application Information" 서비스를 중지한 경우, "This file does not have an app associated with it for performing this action."
13849정성태12/20/20244857디버깅 기술: 210. Windbg - 논리(가상) 주소를 Segmentation을 거쳐 선형 주소로 변경
13848정성태12/18/20244806디버깅 기술: 209. Windbg로 알아보는 Prototype PTE파일 다운로드2
13847정성태12/18/20244823오류 유형: 939. golang - 빌드 시 "unknown directive: toolchain" 오류 빌드 시 이런 오류가 발생한다면?
13846정성태12/17/20245038디버깅 기술: 208. Windbg로 알아보는 Trans/Soft PTE와 2가지 Page Fault 유형파일 다운로드1
13845정성태12/16/20244512디버깅 기술: 207. Windbg로 알아보는 PTE (_MMPTE)
13844정성태12/14/20245175디버깅 기술: 206. Windbg로 알아보는 PFN (_MMPFN)파일 다운로드1
13843정성태12/13/20244366오류 유형: 938. Docker container 내에서 빌드 시 error MSB3021: Unable to copy file "..." to "...". Access to the path '...' is denied.
1  2  [3]  4  5  6  7  8  9  10  11  12  13  14  15  ...