Microsoft MVP성태의 닷넷 이야기
.NET Framework: 209. AutoReset, ManualReset, Monitor.Wait의 차이 [링크 복사], [링크+제목 복사],
조회: 29017
글쓴 사람
정성태 (techsharer at outlook.com)
홈페이지
첨부 파일
(연관된 글이 5개 있습니다.)
(시리즈 글이 4개 있습니다.)
.NET Framework: 209. AutoReset, ManualReset, Monitor.Wait의 차이
; https://www.sysnet.pe.kr/2/0/1015

.NET Framework: 1120. C# - BufferBlock<T> 사용 예제
; https://www.sysnet.pe.kr/2/0/12845

.NET Framework: 1172. .NET에서 Producer/Consumer를 구현하는 기초 인터페이스 - IProducerConsumerCollection<T>
; https://www.sysnet.pe.kr/2/0/12993

.NET Framework: 1173. .NET에서 Producer/Consumer를 구현한 BlockingCollection<T>
; https://www.sysnet.pe.kr/2/0/12995




AutoReset, ManualReset, Monitor.Wait의 차이

대개의 경우, Producer/Consumer 큐를 구현한다면 다음과 같은 식으로 구조를 잡을 것입니다.

threadpool_exit_event_1.png

즉, Consumer에 해당하는 스레드들은 특정 이벤트를 대기하고 있다가 Producer가 WorkItem을 큐잉하고 이벤트 Signal을 해주면 깨어나서 큐에 담겨 있던 WorkItem을 꺼내서 작업을 하는 구조입니다.

이런 환경에서, AutoReset/ManualReset/Monitor.Wait의 차이를 알아보기 위해 Consumer 용도로 사용되는 스레드들을 모두 '자발적인 종료'가 되도록 해보겠습니다.




1. AutoReset으로 이벤트를 구현하는 경우


Consumer 스레드들이 사용하는 대기 개체를 AutoReset 유형의 이벤트로 구현하는 경우를 가정해 보겠습니다. 스레드 함수들은 다음과 같이 대기할 것이고,

EventWaitHandle waitEvent = new EventWaitHandle(false, EventResetMode.AutoReset);

void ThreadRun(object state)
{
    while (true)
    {
        waitEvent.WaitOne();
        if (threadAllExit == true)
        {
            break;
        }

        WaitCallback callback = workList.Dequeue();
        callback();
    }
}

모든 스레드에 대한 '자발적인 종료'를 시키기 위해 waitEvent.Set을 호출하게 될 것입니다.

threadAllExit = true;
waitEvent.Set();

위의 코드 만으로 Consumer 스레드들이 모두 종료하게 될까요? 물론 아닙니다. waitEvent.Set은 WaitOne으로 대기하고 있는 단 하나의 스레드만을 깨울 뿐이기 때문에, 만약 5개의 Consumer 스레드들이 있다면 이런 경우 4개는 그대로 WaitOne 상태에 머물게 됩니다.

오호... 그렇다면 waitEvent.Set()을 스레드 숫자만큼 루프를 도는 것은 어떨까요?

threadAllExit = true;
for (int i = 0; i < threadCount; i ++)
{
    waitEvent.Set();
}

이론상으로 보면, AutoReset 이벤트는 한번 Signaled 상태가 되면 대기 중인 스레드가 "깨어나는 동작"이 있어야만 다시 Unsignaled 상태로 돌아가게 되므로 5개의 스레드는 모두 깨어나야 하는 것이 정상입니다.

하지만, Set 메서드를 연이어 실행하는 것으로 인해 최악의 경우 모든 Consumer 스레드들이 WorkItem을 처리중에 있다고 가정한다면 연속적인 5번의 Set 호출은 결국 한번 호출한 것과 같은 효과만 있을 뿐이어서 4개의 스레드가 WaitOne 호출로 인해 대기 상태에 빠지게 됩니다.

개인적인 의견으로, AutoReset 이벤트로는 여기까지가 최선인 것 같습니다. 연이은 Set 메서드 호출 사이에 예상되는 WorkItem 처리 시간만큼의 delay를 준다거나 하는 식으로 개선할 수는 있겠지만, 100%의 신뢰성을 얻으려면 그러한 처리 이후에도 살아있는 스레드가 있다면 아예 강제종료를 해주어야 합니다.

(예제 코드: 첨부 파일의 "WindowsFormsAppliation1.sln" 프로젝트)




2. ManualReset으로 이벤트를 구현하는 경우


다들 아시는 것처럼, ManualReset 이벤트는 명시적인 Reset을 해주지 않는 한 계속해서 Signaled 상태로 머물러 있게 됩니다.

그래서, Consumer 스레드는 WaitOne에서 깨어난 후 다음과 같이 Reset을 호출해 주어야 합니다.

EventWaitHandle waitEvent = new EventWaitHandle(false, EventResetMode.AutoReset);

void ThreadRun(object state)
{
    while (true)
    {
        waitEvent.WaitOne();
        if (threadAllExit == true)
        {
            break;
        }

        waitEvent.Reset();

        WaitCallback callback = workList.Dequeue();
        callback();
    }
}

이러한 ManualReset 동작 방식으로 인해, 다음과 같이 이벤트 개체를 signal 상태로 한 번만 상태 변경을 해주면 모든 스레드들이 쉽게 깨어나게 됩니다.

threadAllExit = true;
waitEvent.Set();

하지만, Consumer 스레드가 다중으로 깨어날 수 있다는 약간의 부작용이 있습니다. Reset이 될 때까지 시간이 걸리기 때문에 그사이 대기 중이던 다른 스레드들이 역시 signaled 된 이벤트 개체로 인해 깨어날 수 있기 때문입니다.

따라서, 만약 100개의 스레드 풀을 운영하고 있는데 Reset 되는 시간까지의 예측할 수 없는 지연 시간에 따라 최악의 경우 100개의 스레드 모두 깨어날 수 있습니다. 만약, 이런 일이 빈번하게 일어나는 구조라면 ManualReset 방식을 쓰기에는 다소 껄끄러울 수 있습니다.

즉, ManualReset 이벤트는 WaitOne으로 해당 이벤트를 기다리고 있는 스레드를 모두 깨우게 됩니다. 반면 WaitOne 이외의 처리를 하던 스레드들은 Set/Reset의 시간 차 안에 WaitOne 호출이 이뤄지느냐에 따라 동작 여부가 갈리기도 합니다.

(예제 코드: 첨부 파일의 "WindowsFormsAppliation2.sln" 프로젝트)




3. Monitor.Wait/Pulse로 구현하는 경우


마지막으로 살펴볼 Monitor.Wait/Pulse입니다. EventWaitHandle 사용법과 비교해서 다소 복잡한데요. 혹시나 이 방법에 대해 들어보신 적이 없다면 다음의 글을 한번 읽고 진행하는 것이 좋겠습니다.

Nonblocking Synchronization 
; http://www.albahari.com/threading/part4.aspx

그럼, 시작해 볼까요?

우선 Consumer 스레드 먼저 살펴보면 아래와 같이 lock과 Monitor.Wait을 병행해 주어야 합니다.

object waitEvent = new object();

void ThreadRun(object state)
{
    while (true)
    {
        lock (waitEvent)
        {
            Monitor.Wait(waitEvent);
        }

        if (threadAllExit == true)
        {
            break;
        }

        WaitCallback callback = workList.Dequeue();
        callback();
    }
}

종료를 하려면 Monitor.Pulse를 다음과 같이 호출해 주어야 하는데요.

threadAllExit = true;

lock (waitEvent)
{
    Monitor.Pulse(waitEvent);
}

위와 같이 하면, "대기 중에 있는 스레드" 하나만 깨어납니다. 따라서, "대기 중에 있는 모든 스레드"를 깨어나게 하려면 다음과 같이 해주면 됩니다.

threadAllExit = true;

lock (waitEvent)
{
    Monitor.PulseAll(waitEvent);
}

하지만, PulseAll 메서드가 Signaled 상태로 계속 머물게 하지는 않습니다. MSDN의 설명에 의하면,

The Monitor class does not maintain state indicating that the Pulse method has been called. Thus, if you call Pulse when no threads are waiting, the next thread that calls Wait blocks as if Pulse had never been called. If two threads are using Pulse and Wait to interact, this could result in a deadlock. Contrast this with the behavior of the AutoResetEvent class: If you signal an AutoResetEvent by calling its Set method, and there are no threads waiting, the AutoResetEvent remains in a signaled state until a thread calls WaitOne, WaitAny, or WaitAll. The AutoResetEvent releases that thread and returns to the unsignaled state.


Monitor.Wait/Pulse 모델이 AutoResetEvent 유형보다 상태 관리면에서 보면 부실한 것을 볼 수 있습니다. 결국 적절한 해결 방법이 될 수 없습니다.

(예제 코드: 첨부 파일의 "WindowsFormsAppliation3.sln" 프로젝트)




4. 개선 방법 = AutoReset + ManualReset


결국, 이렇게 해서 Consumer/Producer 방식에서 만족스럽게 사용할 만한 동기화 방식이 없다는 것을 알 수 있는데요. 그렇다고 포기할 수는 없지요. ^^ 다행히 이리저리 방법을 궁리하다 보면 답이 나오는데, 바로 AutoReset 이벤트와 ManualReset 이벤트를 조합하면 되는 것입니다.

ManualReset Event: 모든 스레드를 종료하는 신호로 사용
AutoReset Event: 작업 항목이 도착했음을 알리는 신호로 사용

이에 따라 다음과 같이 EventWaitHandle.Any와 적절하게 조합해서 사용하면 우리가 원하는 효과를 얻을 수 있습니다.

EventWaitHandle waitEvent = new EventWaitHandle(false, EventResetMode.AutoReset);
EventWaitHandle closeAllEvent = new EventWaitHandle(false, EventResetMode.ManualReset);
EventWaitHandle[] waitsEvent;

private void Form1_Load(object sender, EventArgs e)
{
    this.waitsEvent = new EventWaitHandle[] { waitEvent, closeAllEvent };
}

void ThreadRun(object state)
{
    while (true)
    {
        int result = EventWaitHandle.WaitAny(waitsEvent);

        if (result == 1) // when closeAllEvent signaled.
        {
            break;
        }

        // 작업 아이템을 받아서 동작
        WaitCallback callback = null;
        lock (lock_work)
        {
            callback = workList.Dequeue();
        }

        if (callback != null)
        {
            callback(null);
        }
    }
}

(예제 코드: 첨부 파일의 "WindowsFormsAppliation4.sln" 프로젝트)




5. 결론은... 차이점 이해!


생각보다 간단하지요! ^^

위의 예에서는 각각의 동기화 모델 간의 차이점을 설명하기 위해 'Consumer 스레드들의 자발적인 종료'라는 가정을 내세웠기 때문에 "AutoReset + ManualReset"이 적절하다고 결론이 나오긴 했지만, 여러분들이 만드는 Producer/Consumer 모델에서 반드시 이를 따라야 할 필요는 없기 때문에 상황에 따라 적절한 동기화 모델을 사용하시면 되겠습니다. 중요한 것은, '각각의 차이점을 이해하고 적용'하자는 것입니다.

(이 글의 다이어그램을 만들기 위해 제작된 vsd 원본 파일과 모든 소스 코드는 첨부된 압축 파일에 포함되어 있으며, 본문의 예제 소스에서 누락된 동기화 부분이 좀 더 들어가 있는 차이가 있습니다.)



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

[연관 글]






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

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

비밀번호

댓글 작성자
 




... 61  62  63  64  65  66  67  68  69  70  71  72  [73]  74  75  ...
NoWriterDateCnt.TitleFile(s)
12111정성태1/12/202020542디버깅 기술: 155. C# - KernelMemoryIO 드라이버를 이용해 실행 프로그램을 숨기는 방법(DKOM: Direct Kernel Object Modification) [16]파일 다운로드1
12110정성태1/11/202019851디버깅 기술: 154. Patch Guard로 인해 블루 스크린(BSOD)가 발생하는 사례 [5]파일 다운로드1
12109정성태1/10/202016583오류 유형: 588. Driver 프로젝트 빌드 오류 - Inf2Cat error -2: "Inf2Cat, signability test failed."
12108정성태1/10/202017424오류 유형: 587. Kernel Driver 시작 시 127(The specified procedure could not be found.) 오류 메시지 발생
12107정성태1/10/202018603.NET Framework: 877. C# - 프로세스의 모든 핸들을 열람 - 두 번째 이야기
12106정성태1/8/202019641VC++: 136. C++ - OSR Driver Loader와 같은 Legacy 커널 드라이버 설치 프로그램 제작 [1]
12105정성태1/8/202018141디버깅 기술: 153. C# - PEB를 조작해 로드된 DLL을 숨기는 방법
12104정성태1/7/202019348DDK: 9. 커널 메모리를 읽고 쓰는 NT Legacy driver와 C# 클라이언트 프로그램 [4]
12103정성태1/7/202022478DDK: 8. Visual Studio 2019 + WDK Legacy Driver 제작- Hello World 예제 [1]파일 다운로드2
12102정성태1/6/202018813디버깅 기술: 152. User 권한(Ring 3)의 프로그램에서 _ETHREAD 주소(및 커널 메모리를 읽을 수 있다면 _EPROCESS 주소) 구하는 방법
12101정성태1/5/202019068.NET Framework: 876. C# - PEB(Process Environment Block)를 통해 로드된 모듈 목록 열람
12100정성태1/3/202016551.NET Framework: 875. .NET 3.5 이하에서 IntPtr.Add 사용
12099정성태1/3/202019419디버깅 기술: 151. Windows 10 - Process Explorer로 확인한 Handle 정보를 windbg에서 조회 [1]
12098정성태1/2/202019165.NET Framework: 874. C# - 커널 구조체의 Offset 값을 하드 코딩하지 않고 사용하는 방법 [3]
12097정성태1/2/202017223디버깅 기술: 150. windbg - Wow64, x86, x64에서의 커널 구조체(예: TEB) 구조체 확인
12096정성태12/30/201919883디버깅 기술: 149. C# - DbgEng.dll을 이용한 간단한 디버거 제작 [1]
12095정성태12/27/201921598VC++: 135. C++ - string_view의 동작 방식
12094정성태12/26/201919341.NET Framework: 873. C# - 코드를 통해 PDB 심벌 파일 다운로드 방법
12093정성태12/26/201918911.NET Framework: 872. C# - 로딩된 Native DLL의 export 함수 목록 출력파일 다운로드1
12092정성태12/25/201917667디버깅 기술: 148. cdb.exe를 이용해 (ntdll.dll 등에 정의된) 커널 구조체 출력하는 방법
12091정성태12/25/201919971디버깅 기술: 147. pdb 파일을 다운로드하기 위한 symchk.exe 실행에 필요한 최소 파일 [1]
12090정성태12/24/201920083.NET Framework: 871. .NET AnyCPU로 빌드된 PE 헤더의 로딩 전/후 차이점 [1]파일 다운로드1
12089정성태12/23/201919037디버깅 기술: 146. gflags와 _CrtIsMemoryBlock을 이용한 Heap 메모리 손상 여부 체크
12088정성태12/23/201917975Linux: 28. Linux - 윈도우의 "Run as different user" 기능을 shell에서 실행하는 방법
12087정성태12/21/201918448디버깅 기술: 145. windbg/sos - Dictionary의 entries 배열 내용을 모두 덤프하는 방법 (do_hashtable.py) [1]
12086정성태12/20/201920970디버깅 기술: 144. windbg - Marshal.FreeHGlobal에서 발생한 덤프 분석 사례
... 61  62  63  64  65  66  67  68  69  70  71  72  [73]  74  75  ...