C# 이것저것/C# 기초

[C#] ThreadPool & EventWaitHandle 기초

agingcurve 2024. 2. 22. 10:38
반응형

System.Threading.ThreadPool

 

스레드 동작 방식은 Thread 타입의 생성자에 전달되는 메서드의 코드 유형에 따라 크게 두가지로 나뉜다.

 

1. 상시 실행 

 - 스레드가 일단 생성되면 비교적 오랜 시간 동안 생성돼 있는 유형, 예를 들어 특정 디렉터리의 변화를 감시하는 스레드가 필요하면 이는 그 동작이 필요 없어질 때까지 스레드가 유지돼야 함

 

2. 일회성의 임시 실행

 - 특정 연산만 수행하고 바로 종료되는 유형

 

상시 실행이 되는 유형을 위해서는 스레드를 생성하는게 당연하지만, 일회성 실행에 있어서 스레드를 생성하는 것은 자원낭비로 이어진다.

임시적인 목적으로 원할 때, 스레드를 사용할 수 있도록 만든 것이 스레드 풀(Thread Pool)이다. 

프로그래밍에서 풀(pool)이라는 용어는 일반적으로 "재생할 수 있는 자원의 집합"을 의미한다. 즉, 스레드 풀은 스레드를 꺼내 쓰고 필요없어지면 다시 풀에 스레드가 반환되는 기능을 뜻한다.

 

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Threading;


namespace ThreadPool예시
{
    class Program
    {
        int number = 0;
        static void Main(string[] args)
        {
            Program pg = new Program();
            ThreadPool.QueueUserWorkItem(threadFunc, pg);
            ThreadPool.QueueUserWorkItem(threadFunc, pg);

            Thread.Sleep(1000);

            Console.WriteLine(pg.number);
        }

        static void threadFunc(object state)
        {
            //함수실행
            Program data = state as Program;

            for (int i = 0; i< 10; i++)
            {
                data.number += 1;
            }
        }
    }

}

 

해당 코드를 보면 스레드 생성 코드가 생략됐다. 대신 생성자에 전달됐던 메서드를 곧바로 ThreadPool 타입의 QueuUserWorkItem 메서드에 전달한다. 이렇게 두 번 호출 했기 때문에 스레드 풀에는 2개의 스레드가 자동으로 생성되고 각 스레드에 threadFunc 메서드가 할당되어 실행된다.

 

그렇다면 언제 Thread를 사용하고 언제 ThreadPool를 사용해야 할까?

이를 판단하기 위해 ThreadPool의 내부동작 방식에 대해 이해하는 것이 도움이 될 것이다.

 1. ThreadPool은 프로그램 시작과 함께 0개의 스레드를 가지며 생성

 2. 첫 번째 QueueUserWorkItem을 호출하면 ThreadPool에 자동으로 1개의 스레드를 생성해 threadFunc를 할당해 실행된다.


 3. 두 번째 QueueUserWorkItem을 호출하면 THreadPool에 일을 하고 있지 않은 스레드가 있는지 확인하고 그런 스레드가 있으면, threadFunc를 할당해서 수행한다. 없다면 새롭게 스레드를 생성하고 threadFunc를 할당해 실행한다.


 4. trheadFunc 메서드의 실행을 마친 스레드는 곧바로 종료되지 않고 스레드 풀에 일정 시간 동안 보관된다. 보관돼 있는 시간동안 다시 QueueUserWorkItem이 실행되어 스레드가 필요해지면 곧바로 활성화 되어 주어진 메서드를 실행한다.


 5. threadFunc 메서드의 실행을 마친 스레드는 곧바로 종료되지 않고 스레드 풀에 일정시간 보관되며, 보관돼 있는 동안 QueueUserWorkItem이 실행되어 스레드가 필요해지면 곧바로 활성화 되어 주어진 매서드를 실행


 6. 일정 시간 동안 재사용되지 않는다면 스레드는 풀로부터 제거되어 완전히 종료된다.


 

 

즉, 한번 생성된 스레드는 일정 시간 동안 재사용된다는 점이 ThreadPool의 주요 특징 중 하나다. 여기서 스레드가 윈도우 운영체제의 커널 자원으로 생성된다는 점을 염두에 둘 필요가 있다. 

이는 스레드 하나를 생성/종료하는 데 소비되는 CPU 사용량이 크다는 것을 의미한다.

따라서 스레드를 자주 생성해서 사용하는 프로그램이 있다면 매번 Thread객체를 생성하기보다는 ThreadPool로부터 재사용 했을 때 더 나은 성능을 보인다.

 

System.Threading.EventWaitHandle

EventWaitHandle은 Monitor 타입처럼 스레드 동기화 수단이다. 스레드로 하여금 이벤트를 기다리게 만들 수 있고, 다른 스레드에서 원하는 이벤트를 발생시키는 시나리오에 적합하다.

이때 이벤트 객체는 두 가지 상태만 갖는데, 바로 Signal과 Non-Signal로 나뉘고 서로간 상태 변화는 Set, Reset 매서드로 전환 할 수 있다.

 

이벤트 객체 상태의 변화

이와 함께 이벤트 객체는 WaitOne 매서드를 제공한다. 어떤 스레드가 WaitOne 메서드를 호출하는 시점에 이벤트 객체가 Signal 상태면, 메서드에서 곧바로 제어가 반환 되지만, Non-Signal 상태면, 이벤트 객체가 Signal 상태로 바뀔 때까지 WaitOne 메서드는 제어를 반환하지 않는다. 위의 ThreadPool를 사용한 개선된 예제를 만들어 보자

 

using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Threading;


namespace ThreadPool예시
{
    class Program
    {       

        static void Main(string[] args)
        {
            Mydata data = new Mydata();
            Hashtable ht1 = new Hashtable();
            ht1["data"] = data;
            ht1["evt"] = new EventWaitHandle(false, EventResetMode.ManualReset); 
            // 데이터와 함께 이벤트 객체를 스레드 풀의 스레드에 전달
            ThreadPool.QueueUserWorkItem(threadFunc, ht1);

            Hashtable ht2 = new Hashtable();
            ht2["data"] = data;
            ht2["evt"] = new EventWaitHandle(false, EventResetMode.ManualReset);
            // 데이터와 함께 이벤트 객체를 스레드 풀의 스레드에 전달
            ThreadPool.QueueUserWorkItem(threadFunc, ht2);

            (ht1["evt"] as EventWaitHandle).WaitOne();
            (ht2["evt"] as EventWaitHandle).WaitOne();


            Console.WriteLine(data.Number);
        }

        static void threadFunc(object state)
        {
            Hashtable ht = state as Hashtable;
            Mydata data = ht["data"] as Mydata;

            for (int i = 0; i< 10; i++)
            {
                data.Increment();
            }

            // 주어진 이벤트 객체를 Signal 상태로 전환
            (ht["evt"] as EventWaitHandle).Set();
        }
    }

    class Mydata
    {
        int number = 0;

        public object _numberLock = new object();
        public int Number { get { return number; } }

        public void Increment()
        {
            lock (_numberLock)
            {
                number++;
            }
        }
    }
}

 

QueueUserWorkItem이 1개의 인자만을 스레드 메서드에 전달 할 수 있도록 허용하기 때문에 Hashtable을 이용해 인자를 담아 전달하는 식으로 바뀌어 소스코드가 좀 복잡해졌다. 

 

이벤트는 크게 수동 리셋(manual reset) 이벤트와 자동 리셋 (auto reset) 이벤트로 나뉜다. 두 리셋 방식의 차이점을 간단하게 설명하면 EventWaitHandle.Set 메서드를 호출해 Signal 상태로 전환된 이벤트가 Non-Signal 상태로 자동으로 전환되느냐에 있다. 즉, Set을 호출한 후 자동으로 Non-Signal로 돌아오면 자동으로 리셋 이벤트이고, 명시적으로 개발자가 Reset 메서드를 호출해야 Non-Signal로 돌아오면 수동 리셋 이벤트다.