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)
13718정성태8/27/20247416오류 유형: 921. Visual C++ - error C1083: Cannot open include file: 'float.h': No such file or directory [2]
13717정성태8/26/20247022VS.NET IDE: 192. Visual Studio 2022 - Windows XP / 2003용 C/C++ 프로젝트 빌드
13716정성태8/21/20246756C/C++: 167. Visual C++ - 윈도우 환경에서 _execv 동작 [1]
13715정성태8/19/20247361Linux: 78. 리눅스 C/C++ - 특정 버전의 glibc 빌드 (docker-glibc-builder)
13714정성태8/19/20246735닷넷: 2295. C# 12 - 기본 생성자(Primary constructors) (책 오타 수정) [3]
13713정성태8/16/20247454개발 환경 구성: 721. WSL 2에서의 Hyper-V Socket 연동
13712정성태8/14/20247214개발 환경 구성: 720. Synology NAS - docker 원격 제어를 위한 TCP 바인딩 추가
13711정성태8/13/20248062Linux: 77. C# / Linux - zombie process (defunct process) [1]파일 다운로드1
13710정성태8/8/20247980닷넷: 2294. C# 13 - (6) iterator 또는 비동기 메서드에서 ref와 unsafe 사용을 부분적으로 허용파일 다운로드1
13709정성태8/7/20247750닷넷: 2293. C# - safe/unsafe 문맥에 대한 C# 13의 (하위 호환을 깨는) 변화파일 다운로드1
13708정성태8/7/20247530개발 환경 구성: 719. ffmpeg / YoutubeExplode - mp4 동영상 파일로부터 Audio 파일 추출
13707정성태8/6/20247771닷넷: 2292. C# - 자식 프로세스의 출력이 4,096보다 많은 경우 Process.WaitForExit 호출 시 hang 현상파일 다운로드1
13706정성태8/5/20247881개발 환경 구성: 718. Hyper-V - 리눅스 VM에 새로운 디스크 추가
13705정성태8/4/20248153닷넷: 2291. C# 13 - (5) params 인자 타입으로 컬렉션 허용 [2]파일 다운로드1
13704정성태8/2/20248110닷넷: 2290. C# - 간이 dotnet-dump 프로그램 만들기파일 다운로드1
13703정성태8/1/20247437닷넷: 2289. "dotnet-dump ps" 명령어가 닷넷 프로세스를 찾는 방법
13702정성태7/31/20247839닷넷: 2288. Collection 식을 지원하는 사용자 정의 타입을 CollectionBuilder 특성으로 성능 보완파일 다운로드1
13701정성태7/30/20248109닷넷: 2287. C# 13 - (4) Indexer를 이용한 개체 초기화 구문에서 System.Index 연산자 허용파일 다운로드1
13700정성태7/29/20247712디버깅 기술: 200. DLL Export/Import의 Hint 의미
13699정성태7/27/20248237닷넷: 2286. C# 13 - (3) Monitor를 대체할 Lock 타입파일 다운로드1
13698정성태7/27/20248194닷넷: 2285. C# - async 메서드에서의 System.Threading.Lock 잠금 처리파일 다운로드1
13697정성태7/26/20247920닷넷: 2284. C# - async 메서드에서의 lock/Monitor.Enter/Exit 잠금 처리파일 다운로드1
13696정성태7/26/20247452오류 유형: 920. dotnet publish - error NETSDK1047: Assets file '...\obj\project.assets.json' doesn't have a target for '...'
13695정성태7/25/20247437닷넷: 2283. C# - Lock / Wait 상태에서도 STA COM 메서드 호출 처리파일 다운로드1
13694정성태7/25/20247903닷넷: 2282. C# - ASP.NET Core Web App의 Request 용량 상한값 (Kestrel, IIS)
13693정성태7/24/20247233개발 환경 구성: 717. Visual Studio - C# 프로젝트에서 레지스트리에 등록하지 않은 COM 개체 참조 및 사용 방법파일 다운로드1
1  2  3  4  5  6  7  8  [9]  10  11  12  13  14  15  ...