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

비밀번호

댓글 작성자
 




... 121  122  123  124  125  126  127  128  129  130  [131]  132  133  134  135  ...
NoWriterDateCnt.TitleFile(s)
1781정성태10/21/201420694디버깅 기술: 64. new/delete의 짝이 맞는 경우에도 메모리 누수가 발생한다면?
1780정성태10/15/201424270오류 유형: 249. The application-specific permission settings do not grant Local Activation permission for the COM Server application with CLSID
1779정성태10/15/201419789오류 유형: 248. Active Directory에서 OU가 지워지지 않는 경우
1778정성태10/10/201418282오류 유형: 247. The Netlogon service could not create server share C:\Windows\SYSVOL\sysvol\[도메인명]\SCRIPTS.
1777정성태10/10/201421375오류 유형: 246. The processing of Group Policy failed. Windows attempted to read the file \\[도메인]\sysvol\[도메인]\Policies\{...GUID...}\gpt.ini
1776정성태10/10/201418380오류 유형: 245. 이벤트 로그 - Name resolution for the name _ldap._tcp.dc._msdcs.[도메인명]. timed out after none of the configured DNS servers responded.
1775정성태10/9/201419488오류 유형: 244. Visual Studio 디버깅 (2) - Unable to break execution. This process is not currently executing the type of code that you selected to debug.
1774정성태10/9/201426676개발 환경 구성: 246. IIS 작업자 프로세스의 20분 자동 재생(Recycle)을 끄는 방법
1773정성태10/8/201429853.NET Framework: 471. 웹 브라우저로 다운로드가 되는 파일을 왜 C# 코드로 하면 안되는 걸까요? [1]
1772정성태10/3/201418627.NET Framework: 470. C# 3.0의 기본 인자(default parameter)가 .NET 1.1/2.0에서도 실행될까? [3]
1771정성태10/2/201428129개발 환경 구성: 245. 실행된 프로세스(EXE)의 명령행 인자를 확인하고 싶다면 - Sysmon [4]
1770정성태10/2/201421747개발 환경 구성: 244. 매크로 정의를 이용해 파일 하나로 C++과 C#에서 공유하는 방법 [1]파일 다운로드1
1769정성태10/1/201424162개발 환경 구성: 243. Scala 개발 환경 구성(JVM, 닷넷) [1]
1768정성태10/1/201419566개발 환경 구성: 242. 배치 파일에서 Thread.Sleep 효과를 주는 방법 [5]
1767정성태10/1/201424736VS.NET IDE: 94. Visual Studio 2012/2013에서의 매크로 구현 - Visual Commander [2]
1766정성태10/1/201422542개발 환경 구성: 241. 책 "프로그래밍 클로저: Lisp"을 읽고 나서. [1]
1765정성태9/30/201426100.NET Framework: 469. Unity3d에서 transform을 변수에 할당해 사용하는 특별한 이유가 있을까요?
1764정성태9/30/201422330오류 유형: 243. 파일 삭제가 안 되는 경우 - The action can't be comleted because the file is open in System
1763정성태9/30/201423900.NET Framework: 468. PDB 파일을 연동해 소스 코드 라인 정보를 알아내는 방법파일 다운로드1
1762정성태9/30/201424666.NET Framework: 467. 닷넷에서 EIP/RIP 레지스터 값을 구하는 방법 [1]파일 다운로드1
1761정성태9/29/201421696.NET Framework: 466. 윈도우 운영체제의 보안 그룹 이름 및 설명 문자열을 바꾸는 방법파일 다운로드1
1760정성태9/28/201419924.NET Framework: 465. ICorProfilerInfo::GetILToNativeMapping 메서드가 0x80131358을 반환하는 경우
1759정성태9/27/201431122개발 환경 구성: 240. Visual C++ / x64 환경에서 inline-assembly를 매크로 어셈블리로 대체하는 방법파일 다운로드1
1758정성태9/23/201437928개발 환경 구성: 239. 원격 데스크톱 접속(RDP)을 기존의 콘솔 모드처럼 사용하는 방법 [1]
1757정성태9/23/201418530오류 유형: 242. Lync로 모임 참여 시 소리만 들리지 않는 경우 - 두 번째 이야기
1756정성태9/23/201427499기타: 48. NVidia 제품의 과다한 디스크 사용 [2]
... 121  122  123  124  125  126  127  128  129  130  [131]  132  133  134  135  ...