(C#) .NET의 ThreadPool은 두개의 ThreadPool이 있습니다.

 

아티클의 제목처럼 CLR에는 두개의 ThreadPool이 존재 합니다.
첫번째 ThreadPool은 일반적인 Worker Thread가 사용되는 Pool이고 두번째 ThreadPool은 IoCompletionPort(IOCP)를 지원하는 Thread로 비동기 I/O 작업을 처리하기 위한 Thread 입니다.
CLR ThreadPool에서 제공되는 I/O Thread는 흔히 File을 비동기로 읽고/쓰고 처리를 할때 FileStream에서 BeginRead/BeginWrite 구현 부분이 I/O Thread로 처리 됩니다.
I/O Thread 커널에서 관리되는 스레드가 아니고 Worker Thread와 마찬가지로 CLR에서 관리되는 매니지드 스레드인데 커널차원에서 제공되는 IOCP 호출기능이 제공되는 스레드 입니다.

ThreadPool의 Worker Thread

그럼 어떻게 I/O Thread를 사용할 수 있는지 간단한 예제를 통해 알아보도록 하겠습니다.
먼저 I/O Thread 사용 예제 전에 ThreadPool의 Worker Thread는 어떻게 사용되는지 간단한 코드를 통해 살펴 보도록 하겠습니다.
Worker Thread는 System.Threading.ThreadPool 클래스의 정적 메서드인 QueueUserWorkItem() 메서드를 사용해서 쉽게 사용 가능합니다.

static void Main(string[] args)
{
    var workTH = new WorkTH();
    workTH.Start();
}
using System.Collections.Concurrent;

namespace ThreadPool_Work_IO_Test;

internal class WorkTH
{
    private BlockingCollection<String> _workingCollection = new();

    public void Start()
    {
        ThreadPool.QueueUserWorkItem(this.DoWork, null);

        while (true)
        {
            string? text = Console.ReadLine();
            if (string.IsNullOrEmpty(text))
                break;

            _workingCollection.TryAdd(text);
        }
    }

    private void DoWork(object? state)
    {
        while (true)
        {
            string? text;
            if (_workingCollection.TryTake(out text))
                Console.WriteLine($"> {text}");
        }
    }
}

코드를 보면 사용자에게 입력받은 텍스트를 작업 스레드에서 그대로 출력하게 되는 코드 입니다.
Start()를 하게 되면 System.Threading.ThreadPool 클래스의 정적 메서드인 QueueUserWorkItem() 메서드를 통해 ThreadPool로 실행할 메서드를 콜백으로 받아 대기열에 대기시켜 DoWork()메서드는 ThreadPool로 처리하게 됩니다.

ThreadPool의 I/O Thread

그럼 I/O Thread에 대해 살펴 보겠습니다. I/O Thread도 이미 System.Threading.ThreadPool 클래스로 제공되고 있습니다.
바로 정적 메서드인 RegisterWaitForSingleObject() 메서드를 통해 사용 가능합니다. RegisterWaitForSingleObject() 메서드는 System.Threading.WaitHandle 클래스에서 신호를 받으면 대기열에 등록된 콜백 메서드를 I/O 스레드로 처리되는 메서드 입니다.
RegisterWaitForSingleObject() 메서드를 이용해서 위에서 구현된 코드를 I/O Thread 사용으로 바꿔보면 다음과 같이 처리할 수 있습니다.

static void Main(string[] args)
{
    var ioth = new IOTH();
    ioth.Start();
}
using System.Collections.Concurrent;

namespace ThreadPool_Work_IO_Test;

internal class IOTH
{
    private AutoResetEvent _are = new(false);
    private BlockingCollection<String> _workingCollection = new();

    public void Start()
    {
        var regWaitHandle =
            ThreadPool.RegisterWaitForSingleObject(_are, this.DoWork, null, -1, false);

        while (true)
        {
            string? text = Console.ReadLine();
            if (string.IsNullOrEmpty(text))
                break;

            _workingCollection.TryAdd(text);
            _are.Set();
        }

        regWaitHandle.Unregister(_are);
    }

    private void DoWork(object? state, bool timeOut)
    {
        string? text;
        if (_workingCollection.TryTake(out text))
            Console.WriteLine($"> {text}");
    }
}

System.Threading.AutoResetEvent 클래스를 사용해서(System.Threading.WaitHandle 클래스를 상속한 개체면 사용할 수 있습니다.) 신호를 보내고 해당 신호를 받으면 DoWork() 메서드를 호출하도록 등록해 놓습니다. 그리곤 사용자가 입력한 텍스트를 받으면 신호를 보내 I/O Thread로 동작 됩니다.
그럼 실제 DoWork() 메서드를 호출하는 스레드가 정말로 I/O Thread 인지 확인해 보겠습니다. 확인해 보는 방법은 정성태님 블로그에서 자주 사용되는 ThreadPool의 Thread개수를 임의로 제한해 두고 그 이상으로 Thread를 사용하도록 호출해보고 확인하는 방법입니다.
먼저 ThreadPool의 비동기 I/O Thread개수를 1개로 임의 제한해 봅니다.

static void Main(string[] args)
{
    ThreadPool.SetMinThreads(8, 1);  // I/O Thread를 1개로 제한
    ThreadPool.SetMaxThreads(8, 1);  // I/O Thread를 1개로 제한
    
    Console.ReadLine();
    
    // I/O Thread 두번 호출
    var ioth = new IOTH();
    ioth.Start();
    var ioth2 = new IOTH();
    ioth2.Start();
    Console.ReadLine();
}

그리고 DoWork() 메서드에서는 임의로 시간 딜레이를 5초 추가 합니다. 이렇게 되면 위에서 I/O Thread를 1개로 제한해 두었기 때문에 동시에 결과가 출력 되지 않고,
5초의 텀을 두고 출력되야 정상 입니다. 이렇게 해서 최종 코드는 다음과 같이 테스트 해보겠습니다.

using System.Collections.Concurrent;

namespace ThreadPool_Work_IO_Test;

internal class IOTH
{
    private AutoResetEvent _are = new(false);
    private BlockingCollection<String> _workingCollection = new();

    public void Start()
    {
        var regWaitHandle =
            ThreadPool.RegisterWaitForSingleObject(_are, this.DoWork, null, -1, false);

        _workingCollection.TryAdd("1");
        _are.Set();

        regWaitHandle.Unregister(_are);
    }

    private void DoWork(object? state, bool timeOut)
    {
        string? text;
        if (_workingCollection.TryTake(out text))
        {
            Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] {DateTime.Now} > {text}");
            Thread.Sleep(5000);
        }
    }
}

결과는 다음과 같이 나오게 됩니다.
image

8번 스레드가 51초에 결과를 출력하고 5초후에 똑같은 8번 스레드가 결과를 출력한걸 확인해 볼 수 있습니다. 한번 더 확실하게 확인해 보겠습니다.
이번엔 I/O Thread 개수를 2개로 제한하고 결과를 보면
image

9번, 8번 스레드가 같은 시간에 동시에 결과를 출력하는걸 확인할 수 있습니다.

위 코드는 다음 Repository에서 확인할 수 있습니다.
ThreadPool_IOThread_Test