[C#] 37. ThreadPool의 사용법


Study/C#  2021. 9. 27. 15:38

안녕하세요. 명월입니다.


이 글은 C#에서 ThreadPool의 사용법에 대한 글입니다.


이전 글에서 Thread에 대해서 설명했습니다.

link - [C#] 36. 쓰레드(Thread)를 사용하는 방법, Thread.Sleep 함수 사용법


Thread란 프로그램 내에서 병렬처리를 위한 기능입니다.

그런데 이 Thread는 개수를 제어하지 못하면 오히려 프로그램 성능이 떨어진다는 것도 설명했습니다. 그러기 위해서 Thread 개수를 관리하는 것을 만들어야 하는데, .Net framework에서는 이 Thread의 개수를 관리해 주는 기능이 있는 데 그것이 ThreadPool입니다.

using System;
using System.Threading;

namespace Example
{
  // 쓰레드로 넘기는 파라미터 클래스
  class Node
  {
    // 콘솔 출력시 사용되는 텍스트
    public string Text { get; set; }
    // 반복문 횟수
    public int Count { get; set; }
    // Sleep의 시간틱
    public int Tick { get; set; }
  }
  class Program
  {
    // 쓰레드 실행 함수
    static void ThreadProc(Object callBack)
    {
      // 파라미터 값이 Node 클래스가 아니면 종료
      if (callBack.GetType() != typeof(Node))
      {
        return;
      }
      // Node 타입으로 강제 캐스트(자료형이 Object 타입)
      var node = (Node)callBack;
      // 설정된 반복문의 횟수만큼
      for (int i = 0; i < node.Count; i++)
      {
        // 콘솔 출력
        Console.WriteLine(node.Text + " = " + i);
        // 설정된 Sleep 시간틱
        Thread.Sleep(node.Tick);
      }
      // 완료 콘솔 출력
      Console.WriteLine("Complete " + node.Text);
    }
    // 실행 함수
    static void Main(string[] args)
    {
      // ThreadPool의 최소 쓰레드는 0, 최대 쓰레드는 2개로 설정
      // 참고, 두번째 파라미터는 비동기 I/O 쓰레드 개수입니다.(그냥 0으로 설정해도 무방)
      if (ThreadPool.SetMinThreads(0, 0) && ThreadPool.SetMaxThreads(2, 0))
      {
        // ThreadPool에 등록, 델리게이트 함수로 ThreadProc를 등록
        // 파라미터로 Node 인스턴스를 생성해서 넘긴다.
        ThreadPool.QueueUserWorkItem(ThreadProc, new Node { Text = "A", Count = 3, Tick = 1000 });
        ThreadPool.QueueUserWorkItem(ThreadProc, new Node { Text = "B", Count = 5, Tick = 10 });
        ThreadPool.QueueUserWorkItem(ThreadProc, new Node { Text = "C", Count = 2, Tick = 500 });
        ThreadPool.QueueUserWorkItem(ThreadProc, new Node { Text = "D", Count = 7, Tick = 300 });
        ThreadPool.QueueUserWorkItem(ThreadProc, new Node { Text = "E", Count = 4, Tick = 200 });
      }
      // 아무 키나 누르면 종료
      Console.WriteLine("Press Any key...");
      Console.ReadLine();
    }
  }
}

위 예제를 보시면 제가 ThreadPool 클래스를 이용해서 쓰레드를 총 5개 실행합니다.

그러나 ThreadPool에서의 쓰레드 갯수는 0개에서 최대 2개까지 생성되게 설정했습니다. 즉, Thread를 무제한으로 생성하는 게 아니고 Pool안에서 총량을 설정해서 그 이상은 대기 상태로 넘깁니다.

즉, 결과를 봐도 A와 B는 동시에 실행되지만, C부터는 B가 종료된 시점부터 실행되는 것을 확인할 수 있습니다. Thread를 최대 2개까지 생성하고 그 이상은 실행하지 않는 설정으로 ThreadPool를 관리합니다.


ThreadPool에서는 Thread의 생성을 관리하게 됩니다.

그런데 이 ThreadPool은 Join 함수가 없어서 Thread가 모두 종료할 때까지 기다리는 함수가 없습니다.

using System;
using System.Threading;

namespace Example
{
  // 쓰레드로 넘기는 파라미터 클래스
  class Node
  {
    // 콘솔 출력시 사용되는 텍스트
    public string Text { get; set; }
    // 반복문 횟수
    public int Count { get; set; }
    // Sleep의 시간틱
    public int Tick { get; set; }
  }
  class Program
  {
    // 쓰레드 실행 함수
    static void ThreadProc(Object callBack)
    {
      // 파라미터 값이 Node 클래스가 아니면 종료
      if (callBack.GetType() != typeof(Node))
      {
        return;
      }
      // Node 타입으로 강제 캐스트(자료형이 Object 타입)
      var node = (Node)callBack;
      // 설정된 반복문의 횟수만큼
      for (int i = 0; i < node.Count; i++)
      {
        // 콘솔 출력
        Console.WriteLine(node.Text + " = " + i);
        // 설정된 Sleep 시간틱
        Thread.Sleep(node.Tick);
      }
      // 완료 콘솔 출력
      Console.WriteLine("Complete " + node.Text);
    }
    // 쓰레드 풀의 Join 설정
    static void ThreadPoolJoin(int size)
    {
      // 무한 루프
      while (true)
      {
        // 1초 대기
        Thread.Sleep(1000);
        // 현재 대기중인 쓰레드를 갯수를 취득하기 위한 변수
        int count = 0;
        int iocount = 0;
        // 현재 사용 가능한 쓰레드 개수 취득
        ThreadPool.GetAvailableThreads(out count, out iocount);
        // Max Thread와 같으면 무한 루프 종료
        if (count == size)
        {
          // 무한 루프 종료
          break;
        }
      }
    }
    // 실행 함수
    static void Main(string[] args)
    {
      // ThreadPool의 최소 쓰레드는 0, 최대 쓰레드는 2개로 설정
      // 참고, 두번째 파라미터는 비동기 I/O 쓰레드 개수입니다.(그냥 0으로 설정해도 무방)
      if (ThreadPool.SetMinThreads(0, 0) && ThreadPool.SetMaxThreads(2, 0))
      {
        // ThreadPool에 등록, 델리게이트 함수로 ThreadProc를 등록
        // 파라미터로 Node 인스턴스를 생성해서 넘긴다.
        ThreadPool.QueueUserWorkItem(ThreadProc, new Node { Text = "A", Count = 3, Tick = 100 });
        ThreadPool.QueueUserWorkItem(ThreadProc, new Node { Text = "B", Count = 5, Tick = 10 });
        ThreadPool.QueueUserWorkItem(ThreadProc, new Node { Text = "C", Count = 2, Tick = 500 });
        ThreadPool.QueueUserWorkItem(ThreadProc, new Node { Text = "D", Count = 7, Tick = 300 });
        ThreadPool.QueueUserWorkItem(ThreadProc, new Node { Text = "E", Count = 4, Tick = 200 });
      }
      // 총 2개의 쓰레드가 대기 상태가 될때까지 대기
      ThreadPoolJoin(2);
      // 아무 키나 누르면 종료
      Console.WriteLine("Press Any key...");
      Console.ReadLine();
    }
  }
}

위 예제는 ThreadPoolJoin이라는 함수를 만들어서 ThreadPool에 사용할 수 있는 쓰레드 개수를 확인해서 쓰레드 사용 최대량과 같으면 무한 루프가 종료하게 하는 것으로 Join 함수를 만들었습니다.

간단한 프로그램은 위처럼 ThreadPool를 제어할 수 있습니다.


문제는 큰 프로그램이라고 한다면 ThreadPool 클래스 특성이 static이기 때문에 어느 곳에서 사용되는 지 확인하기가 어려운 것입니다.

그러니깐 설계하고 있는 곳에서 ThreadPool 사용이 종료되었다 쳐도, 다른 라이브러리나 클래스에서 ThreadPool를 사용한다고 하면, 대기 상태의 개수만으로 대기를 만드는 건, 예상치 못한 버그를 발생할 수 있겠습니다.


그래서 Task에서 사용되는 EventWaitHandle를 이용하면 부분적으로 ThreadPool를 제어할 수 있습니다.

using System;
using System.Threading;
using System.Collections.Generic;

namespace Example
{
  // 쓰레드로 넘기는 파라미터 클래스
  // EventWaitHandle 클래스의 상속
  public class Node : EventWaitHandle
  {
    // 생성자 설정
    public Node() : base(false, EventResetMode.ManualReset) { }
    // 콘솔 출력시 사용되는 텍스트
    public string Text { get; set; }
    // 반복문 횟수
    public int Count { get; set; }
    // Sleep의 시간틱
    public int Tick { get; set; }
  }
  class Program
  {
    // 쓰레드 실행 함수
    static void ThreadProc(Object callBack)
    {
      // 파라미터 값이 Node 클래스가 아니면 종료
      if (callBack.GetType() != typeof(Node))
      {
        return;
      }
      // Node 타입으로 강제 캐스트(자료형이 Object 타입)
      var node = (Node)callBack;
      // 설정된 반복문의 횟수만큼
      for (int i = 0; i < node.Count; i++)
      {
        // 콘솔 출력
        Console.WriteLine(node.Text + " = " + i);
        // 설정된 Sleep 시간틱
        Thread.Sleep(node.Tick);
      }
      // 완료 콘솔 출력
      Console.WriteLine("Complete " + node.Text);
      // EventWaitHandle의 Join 설정 종료
      node.Set();
    }
    // Node 클래스의 인스턴스를 추가한 후 List에 등록
    static EventWaitHandle AddNode(List<EventWaitHandle> list, string text, int count, int tick)
    {
      // 인스턴스 생성
      var node = new Node { Text = text, Count = count, Tick = tick };
      // list에 등록
      list.Add(node);
      // 인스턴스를 리턴
      return node;
    }
    // 실행 함수
    static void Main(string[] args)
    {
      // Join을 위한 EventWaitHandle 리스트
      var list = new List<EventWaitHandle>();
      // ThreadPool의 최소 쓰레드는 0, 최대 쓰레드는 2개로 설정
      // 참고, 두번째 파라미터는 비동기 I/O 쓰레드 개수입니다.(그냥 0으로 설정해도 무방)
      if (ThreadPool.SetMinThreads(0, 0) && ThreadPool.SetMaxThreads(2, 0))
      {
        // ThreadPool에 등록, 델리게이트 함수로 ThreadProc를 등록
        // 함수 AddNode를 호출해서 인스턴스 생성하고 위 list에 등록한다.
        ThreadPool.QueueUserWorkItem(ThreadProc, AddNode(list, "A", 3, 1000));
        ThreadPool.QueueUserWorkItem(ThreadProc, AddNode(list, "B", 5, 10));
        ThreadPool.QueueUserWorkItem(ThreadProc, AddNode(list, "C", 2, 500));
        ThreadPool.QueueUserWorkItem(ThreadProc, AddNode(list, "D", 7, 300));
        ThreadPool.QueueUserWorkItem(ThreadProc, AddNode(list, "E", 4, 200));
      }
      // list에 있는 EventWaitHandle 함수가 전부 Set이 호출되면 Join 해제
      WaitHandle.WaitAll(list.ToArray());

      // 아무 키나 누르면 종료
      Console.WriteLine("Press Any key...");
      Console.ReadLine();
    }
  }
}

list를 로컬 변수로 지정해서 WaitHandle.WaitAll에서 배열로 변환하게 되면 ThreadPool가 종료될 때까지 제어가 가능합니다.

ThreadPool이 전체 사용 중인 쓰레드 개수를 제어할 수 있다는 점은 꽤 편리합니다만, 여기서는 생성한 쓰레드를 기다리는 것을 설정하는게 꽤 불편하네요.

그래서 성능을 위해서는 Thread를 그대로 사용하는 것보다는 ThreadPool를 이용하는게 시스템 성능을 위해서는 더 나은 선택일 수 있습니다.


참고로 위 SetMinThreads 함수와 SetMaxThreads 함수로 쓰레드 개수의 제어를 설정해도 Thread로 생성되는 쓰레드에는 영향이 없습니다. 즉 2개만 설정해도 Thread로는 10개를 설정할 수 도 있다는 뜻입니다.


여기까지 C#에서 ThreadPool의 사용법에 대한 글이었습니다.


궁금한 점이나 잘못된 점이 있으면 댓글 부탁드립니다.