C#

C# BCL(Base Class Library) - 스레딩1

devrabbit22 2026. 3. 15. 01:48

스레드(thread)는 명령어를 실행하기 위한 스케줄링 단위이며, 하나의 프로세스 내부에서 생성된다.

운영체제가 멀티스레딩을 지원한다면 하나의 프로세스는 여러 개의 스레드를 가질 수 있으며, 이 스레드들은 동일한 프로세스의 자원을 공유하면서 동시에 작업을 수행할 수 있다.

윈도우는 프로세스를 생성할 때 기본적으로 하나의 스레드를 함께 생성하며, 이를 주 스레드(main thread 또는 primary thread) 라고 한다.

스레드는 CPU가 명령어를 실행하는 데 필요한 레지스터 값, 프로그램 카운터 등의 실행 정보를 보관하고 있으며, 이러한 정보를 스레드 문맥(thread context) 이라고 한다.

운영체제의 스케줄러는 실행할 스레드를 선택하여 CPU가 해당 스레드를 실행하도록 하며, 이 과정에서 두 가지 동작이 수행된다.

문맥 저장 (Context Save)

CPU는 현재 실행 중인 스레드가 이후에 다시 실행될 수 있도록 레지스터 값, 프로그램 카운터(PC) 등 CPU의 실행 환경 정보를 스레드의 문맥(thread context)에 저장한다.
이 과정을 통해 스레드는 나중에 다시 실행되더라도 이전에 실행되던 상태부터 이어서 작업을 수행할 수 있다.

문맥 교환 / 문맥 복원 (Context Switch / Restore)

운영체제의 스케줄러는 다음에 실행할 스레드를 선택하고, 해당 스레드의 문맥 정보를 CPU 내부로 다시 로드한다.
이 과정에서 이전 스레드의 문맥은 저장되고, 새로 실행될 스레드의 문맥이 CPU에 복원된다.

그 결과 CPU는 마치 해당 스레드가 계속 실행되고 있던 것처럼 이전 상태부터 작업을 이어서 수행하게 된다.

 

이러한 과정 전체를 문맥 교환(Context Switch) 이라고 한다.

더보기

현재 스레드 실행

Context Save (현재 스레드 상태 저장)

스케줄러가 다음 스레드 선택

Context Restore (새 스레드 상태 복원)

새 스레드 실행

Context Switch는 생각보다 비용이 크다.

이유 : 아래와 같은 작업이 발생하기 때문

  • 레지스터 저장
  • 레지스터 복원
  • 캐시 영향
  • 커널 모드 전환

System.Threading.Thread

프로그램이 실행되면 기본적으로 하나의 주 스레드(Main Thread) 가 생성된다.

주 스레드는 컴파일된 C# 코드를 순차적으로 실행하며 프로그램의 흐름을 시작하는 역할을 한다.

Thread 타입은 현재 명령어를 실행 중인 스레드에 접근할 수 있는 정적 속성을 제공한다.
이를 통해 프로그램을 실행하고 있는 현재 스레드의 상태나 정보를 확인할 수 있다.

Thread.CurrentThread 속성을 사용하면 현재 실행 중인 스레드 객체에 접근할 수 있다.

자주 사용되는 Thread의 정적 메서드로는 Sleep 메서드가 있다.

Thread.Sleep()은 현재 Running 상태인 스레드의 실행을 지정된 시간(밀리초) 동안 일시적으로 중단시키는 역할을 한다.

이 메서드가 호출되면 해당 스레드는 일정 시간 동안 ThreadState.WaitSleepJoin 상태로 전환되며 CPU 사용을 중단한다.

지정된 시간이 지나면 스레드는 다시 Running 상태로 돌아가 실행을 이어서 수행하게 된다.

Sleep을 사용하면 바로 출력되지 않고 1초 동안 대기 후 다시 실행을 재개했다.

스레드는 실행된 명령어가 필요하므로 명령어의 묶음인 메서드를 Thread 생성자에 전달해야하는데, 일단 스레드 개체가 생성되면 Start 메서드를 호출하는 것으로 스레드를 시작할 수 있다.

새롭게 생성된 스레드는 별도의 명령어를 실행해 나간다.

최근 다중 코어 CPU에서는 실제로 주 스레드와 t 스레드의 코드를 동시에 실행할 수 있다.

스레드의 종료는 결국 프로그램의 종료에 해당하며, 프로그램은 생성된 모든 스레드가 실행을 종료해야만 프로그램도 종료할 수 있다.

이 처럼 프로그램의 실행 종료에 영향을 미치는 스레드를 가리켜 전경 스레드(foreground thread)라고 한다.

배경 스레드(background thread)도 있으며, 이 유형은 실행 종료에 영향을 미치지 않는다.

Thread 타입의 IsBackground 속성을 true로 바꿔 전경 스레드 동작을 배경 스레드로 바꿀 수 있다.

Thread t = new Thread(threadFunc);
t.IsBackground = true;
t.Start();

다른 스레드의 실행이 종료되기 까지 기다려야 할 경우 이를 위해 Thread 타입의 Joing 메서드를 사용할 수 있다. 

새로운 스레드 t가 배경 스레드임에도 주 스레드가 Join 메서드를 호출해 t 스레드의 실행이 종료될 때 까지 기다린다.

 

스레드를 시작하는 측에서 인자를 전달하는 것도 가능하다.

이를 위해 Object 타입의 인자를 하나 전달받는 스레드 메서드를 준비해 Thread.Start 메서드에 직접 값을 넣으면 된다.

하나의 값만 절달하는 예제

전달할 값이 여러 개인 경우

threadFunc 메서드의 인자 타입이 object인 것을 감안하면 전달할 값의 수만큼 필드를 포함한 클래스를 만들어 그 객체를 전달한다.

스레드 사용의 이점을 경험할 수 있는 예시

사용자가 입력한 수 까지 루프를 돌면서 소수의 개수를 세는 프로그램

  • 스레드를 사용하지 않으면 모든 작업이 주 스레드에서 처리되기 때문에, 시간이 오래 걸리는 작업이 실행되는 동안 사용자는 프로그램과 상호작용할 수 없게 된다.
  • 이러한 문제를 해결하기 위해 시간이 오래 걸리는 작업은 별도의 스레드에서 실행하여, 주 스레드는 사용자 입력과 같은 작업을 계속 처리할 수 있도록 한다.
using System;
using System.Collections;
using System.Collections.Generic;
using System.Reflection.Metadata;
namespace BCL_Study
{
    internal class StrStudy
    {
        static void Main(string[] args)
        {
            Console.WriteLine("입력한 숫자까지의 소수 개수 출력(종료: 'x' + Enter");

            while(true)
            {
                Console.WriteLine("숫자를 입력하세요.");
                string userNumber = Console.ReadLine();

                if(userNumber.Equals("x", StringComparison.OrdinalIgnoreCase) == true)
                {
                    Console.WriteLine("프로그램 종료");
                    break;
                }
                Thread t = new Thread(CountPrimeNumbers);
                t.IsBackground = true;
                t.Start(userNumber);
            }
        }
        static void CountPrimeNumbers(object initialValue)
        {
            string value = (string)initialValue;
            int primeCandidate = int.Parse(value);

            int totalPrimes = 0;
            for(int i = 2; i<=primeCandidate; i++)
            {
                if(IsPrime(i) == true)
                {
                    totalPrimes++;
                }
            }
            Console.WriteLine("숫자 {0}까지의 소수 개수? {1}", value, totalPrimes);
        }
        //소수 판정 메서드
        static bool IsPrime(int candidate)
        {
            if((candidate & 1) == 0)
            {
                return candidate == 2;
            }

            for(int i = 3; (i * i) <= candidate; i += 2)
            {
                if ((candidate % i) == 0)
                    return false;
            }

            return candidate != 1;
        }
    }
}

프로그램을 실행하고 결과를 기다리지 않고 키 입력을 통해 프로그램 종료가 가능

스레드를 사용하지 않을 경우 프로그램의 명령을 실행할 수 있는 유일한 주 스레드(Main Thread) 가 계산 작업을 모두 처리해야 한다.
따라서 주 스레드는 계산 작업을 수행하는 동안 다른 작업을 처리할 수 없게 된다.

또한 하나의 스레드는 명령어를 순차적으로 실행하기 때문에, 계산이 완료되기 전까지는 다음 명령어를 실행할 수 없다.

반면 위의 예시처럼 계산 작업을 별도의 스레드에 맡기게 되면, 계산 작업은 새로 생성된 스레드에서 수행된다.
이때 주 스레드는 Thread.Start() 메서드를 호출하여 스레드를 실행시킨 뒤 다음 명령어를 계속 실행할 수 있다.

따라서 Console.ReadLine()이 바로 실행되기 때문에 사용자는 계산이 진행되는 동안에도 프로그램에 값을 입력할 수 있다.

 

이처럼 시간이 오래 걸리는 작업을 별도의 스레드에서 처리하면, 주 스레드는 사용자 입력이나 다른 작업을 계속 수행할 수 있어 프로그램의 응답성을 유지할 수 있다.


System.Threading.Monitor

스레드는 시스템 메모리가 허용하는 범위 내에서 여러 개를 생성할 수 있다.

일반적으로 하나의 스레드에는 약 1MB 크기의 스택 메모리가 할당된다. 따라서 스레드를 많이 생성할수록 그만큼 메모리 사용량도 증가하게 된다.

다중 스레드를 생성하려면 Thread 객체를 여러 개 생성하면 된다. 각 Thread 객체는 독립적인 실행 흐름을 가지며 동시에 작업을 수행할 수 있다.

그러나 스레드를 무분별하게 많이 생성하면 메모리 사용량 증가와 문맥 교환(Context Switch) 비용이 발생할 수 있으므로 적절한 개수를 사용하는 것이 중요하다.

실행 환경에 따라 스레드의 실행 순서가 불규칙한 모습

이 결과는 프로그램을 실행할 때마다 달라질 수 있다.
즉, 여러 스레드가 동시에 실행될 경우 각 스레드의 실행 순서를 정확하게 보장할 수 없다.

이처럼 다중 스레드 환경에서는 스레드의 실행 순서가 일정하지 않기 때문에 공유 자원에 동시에 접근할 경우 예상하지 못한 결과가 발생할 수 있다.

따라서 다중 스레드를 사용할 때는 공유 자원에 대한 접근을 적절히 제어하는 동기화(synchronization)가 필요하다.

다중 스레드를 사용할 때 주의점

  • 여러 스레드가 동시에 같은 데이터를 수정하면 예상하지 못한 결과가 발생할 수 있다.
  • 이를 Race Condition(경쟁 상태) 이라고 한다.
  • 이러한 문제를 방지하기 위해 Monitor, lock 등의 동기화 기법을 사용해야 한다.

루프 횟수가 많아질 수록 출력은 예측할 수 없는 값이 나온다.

1. 원래 기대하는 결과

코드를 보면 스레드 하나가 10번 증가시킨다.

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

스레드가 두 개니까 10 + 10 = 20 그래서 우리가 기대하는 값은 20이다.

2. 실제로 값이 달라지는 이유

문제의 연산 s.number = s.number + 1;

이건 한 번에 실행되는 연산이 아니며, 실제로 CPU에서는 이렇게 진행된다.

  • number 값 읽기
  • +1 계산
  • 결과 저장
 

3단계 연산이 실행된다.

3. Race Condition 발생

예를 들어 이렇게 될 수 있다.

더보기
초기값 number = 0
 

스레드1

number 읽기 → 0
 

스레드2

number 읽기 → 0
 

스레드1

0 + 1 = 1
저장
 

스레드2

0 + 1 = 1
저장
 

결과

number = 1

원래는 2가 되어야 하는데 1이 된다.

이걸 Race Condition (경쟁 상태) 라고 한다.

4. 루프가 많아질수록 더 심해진다.

ex) 10회, 1000회, 100000회 이렇게 증가할수록 스레드 충돌 확률 ↑ 그래서 결과가 매번 달라진다.

위와 같은 문제를 해결하기 위해서는 공유 리소스에 대한 적절한 동기화 처리가 필요하다.

위의 예시에서는 number 필드를 여러 스레드가 동시에 수정하고 있기 때문에, 한 순간에 오직 하나의 스레드만 해당 필드에 접근할 수 있도록 제어해야 한다.

이를 위해 .NET의 BCL(Base Class Library) 에서 제공하는 Monitor 클래스를 사용할 수 있다.

Monitor를 사용하면 공유 자원에 접근하는 코드 영역을 보호하여 한 번에 하나의 스레드만 실행하도록 제어할 수 있다.

즉, 공유 자원에 접근하는 코드 앞뒤에 Monitor.Enter와 Monitor.Exit을 사용하면 스레드 간의 충돌을 방지할 수 있다.

이러한 방식으로 공유 자원에 접근하는 구간을 보호하는 영역을 임계 영역(Critical Section) 이라고 한다.

Monitor 타입 사용 예시

Monitor.Enter와 Monitor.Exit는 일반적으로 다음과 같은 패턴으로 사용한다.

Monitor.Enter(obj);
try
{
    // 임계 영역 (Critical Section)
}
finally
{
    Monitor.Exit(obj);
}

Enter와 Exit 사이에 위치한 코드는 한 순간에 하나의 스레드만 진입하여 실행할 수 있다.
따라서 여러 스레드가 동시에 공유 자원에 접근하는 것을 방지할 수 있다.

또한 Enter와 Exit 메서드의 인자로 전달되는 값은 반드시 참조형 타입의 인스턴스여야 한다.
이는 Monitor가 객체를 기준으로 동기화 락(lock) 을 관리하기 때문이다.

기존 문제가 해결된 이유

Enter와 Exit 사이에 위치한 코드는 한 순간에 하나의 스레드만 진입하여 실행할 수 있다.
따라서 여러 스레드가 동시에 공유 자원에 접근하는 것을 방지할 수 있다.

또한 Enter와 Exit 메서드의 인자로 전달되는 값은 반드시 참조형 타입의 인스턴스여야 한다.
이는 Monitor가 객체를 기준으로 동기화 락(lock) 을 관리하기 때문이다.

 

즉, Monitor를 사용하면 여러 스레드가 공유 자원에 접근할 때 발생할 수 있는 Race Condition 문제를 방지할 수 있다.

lock 키워드

C#에서는 Monitor를 직접 사용하는 대신 lock 키워드를 통해 더 간단하게 스레드 동기화를 구현할 수 있다.

lock은 특정 객체에 대한 상호 배제(Mutual Exclusion) 를 보장하여, 한 순간에 하나의 스레드만 해당 코드 영역에 진입할 수 있도록 한다.

        static void threadFunc(object inst)
        {
            StrStudy s = inst as StrStudy;

            for(int i=0; i< 100000; i++)
            {
                lock(s)
                {
                    s.number = s.number + 1;
                }
            }
        }

위 코드에서 lock(s) 블록에 진입한 스레드는 해당 객체에 대한 락(lock)을 획득하게 된다.
따라서 다른 스레드는 해당 락이 해제될 때까지 lock 블록에 진입할 수 없다.

그 결과 여러 스레드가 동시에 number 필드에 접근하는 상황을 방지할 수 있으며, Race Condition 문제를 해결할 수 있다.

lock 내부 동작

lock 키워드는 내부적으로 Monitor를 사용하여 동작한다.

lock(obj)
{
    number++;
}

위 코드는 다음과 같은 코드로 변환된다.

Monitor.Enter(obj);
try
{
    number++;
}
finally
{
    Monitor.Exit(obj);
}

lock은 Monitor를 더 간단하게 사용할 수 있도록 제공되는 문법적 편의 기능이다.

주의할 점

lock에서 사용하는 객체는 공유되는 참조형 객체여야 하며, 일반적으로 별도의 락 객체를 만들어 사용하는 것이 권장된다.

private readonly object _lock = new object();

lock(_lock)
{
    number++;
}

이렇게 하면 의도하지 않은 객체 충돌을 방지할 수 있다.

스레드에 안전하지 않은(not thread-safe) 메서드

using System;
using System.Collections;
using System.Collections.Generic;
using System.Net.NetworkInformation;
using System.Reflection.Metadata;
namespace BCL_Study
{
    internal class StrStudy
    {
        static void Main(string[] args)
        {
            MyData data = new MyData();

            Thread t1 = new Thread(threadFunc);
            Thread t2 = new Thread(threadFunc);

            t1.Start(data);
            t2.Start(data);    //2개의 스레드를 시작하고

            t1.Join();
            t2.Join();  //2개의 스레드 실행이 끝날 대까지 대기
            Console.WriteLine(data.Number);
        }
        static void threadFunc(object inst)
        {
            MyData data = inst as MyData;

            for(int i = 0; i< 100000; i++)
            {
                data.Increment();
            }
        }
    }
    class MyData
    {
        int number = 0;

        public int Number { get { return number; } }

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

Increment 메서드에는 아무런 동기화 기능이 없다. 이런 메서드를 스레드에 안전하지 않은 메서드라고 표현한다.

이 메서드가 안전한(thread-safe) 메서드가 되려면 동기화 코드를 추가해야 한다.

스레드에 안전한(thread-safe) 메서드

 class MyData
 {
     int number = 0;

     public object _numberLock = new object();

     public int Number { get { return number; } }

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

처리 방법은 그 타입의 소스코드를 변경할 수 있느냐에 따라 달라진다.

lock 예약어를 사용해 코드를 직접 수정할 수 있다. 반면 소스코드를 고치지 못한다면 그 타입을 사용하는 외부에서 스레드에 안전하지 않은 메서드를 호출할 때마다 동기화 코드를 수행해야 한다.

스레드에 안전하지 않은 메서드를 외부에서 안전하게 사용하는 방법

모든 메서드가 스레드 안전(Thread-Safe)하게 구현되어 있는 것은 아니다.
여러 스레드가 동시에 접근할 경우 Race Condition이 발생할 수 있다.

이러한 경우 메서드 내부를 수정하지 않고, 외부에서 동기화를 통해 안전하게 사용할 수 있다.

class MyData
{
    public int number = 0;

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

Increment() 메서드는 단순히 number 값을 증가시키지만, 여러 스레드가 동시에 실행할 경우 예측할 수 없는 결과가 발생할 수 있다. 이러한 경우 다음과 같이 외부에서 lock을 사용하여 동기화할 수 있다.

 static void threadFunc(object inst)
 {
     MyData data = inst as MyData;

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

위 코드에서 lock(data) 블록은 한 순간에 하나의 스레드만 해당 코드 영역에 진입하도록 보장한다.
따라서 Increment() 메서드가 스레드에 안전하게 실행되며 Race Condition을 방지할 수 있다.

정리

  • 스레드에 안전하지 않은 메서드는 여러 스레드가 동시에 실행하면 문제가 발생할 수 있다.
  • 이러한 메서드는 외부에서 lock을 사용하여 임계 영역으로 보호할 수 있다.
  • 이를 통해 메서드 내부를 수정하지 않고도 안전하게 사용할 수 있다.

모든 메서드를 스레드에 안전한 방식으로 만들지 않는 이유

모든 메서드를 스레드에 안전한(Thread-Safe) 방식으로 구현하지 않는 이유는 성능 문제 때문이다.

스레드 동기화를 위해 lock이나 Monitor와 같은 메커니즘을 사용하면 추가적인 비용이 발생한다.
따라서 항상 동기화를 적용하면 단일 스레드 환경에서도 불필요한 성능 저하가 발생할 수 있다.

이러한 이유로 .NET의 BCL(Base Class Library) 에서는 대부분의 타입이 기본적으로 스레드에 안전하지 않도록 설계되어 있다.

따라서 BCL의 타입을 사용할 때는 인스턴스 멤버가 기본적으로 Thread-Safe 하지 않다는 점을 염두에 두어야 한다.

여러 스레드에서 동시에 접근할 가능성이 있는 경우에는 개발자가 직접 동기화를 적용하여 안전하게 처리해야 한다.

즉, 동기화는 항상 필요한 경우에만 적용하는 것이 성능과 안정성 측면에서 바람직하다.


System.Threading.Interlocked

Interlocked 타입은 정적(static) 클래스이다.

이 클래스는 다중 스레드 환경에서 공유 자원을 안전하게 처리할 수 있도록 원자적(Atomic) 연산을 제공하는 정적 메서드들을 포함하고 있다.

일반적으로 여러 스레드가 공유 자원에 접근할 때는 lock이나 Monitor를 사용해 동기화를 수행해야 한다.
하지만 일부 단순한 연산의 경우에는 Interlocked 클래스를 사용하여 명시적인 동기화 없이도 안전하게 처리할 수 있다.

예를 들어 다음과 같은 연산들이 있다.

  • 32비트 / 64비트 정수 값 증가
  • 값 감소
  • 값 교환
  • 값 비교 후 교체

이러한 연산들은 lock 또는 Monitor를 사용하지 않고도 원자적 연산(Atomic Operation) 으로 수행되기 때문에, 다중 스레드 환경에서도 안전하게 사용할 수 있다.

원자적 연산 (Atomic Operation)

원자적 연산이란 하나의 연산이 더 이상 나눌 수 없는 하나의 단위로 실행되는 연산을 의미한다.

즉, 하나의 스레드가 해당 연산을 수행하는 동안 다른 스레드가 중간에 개입할 수 없다.

따라서 연산이 완전히 실행되거나 아예 실행되지 않는 상태로 처리되며, 중간 단계가 다른 스레드에 의해 관찰되거나 변경되지 않는다.

lock 구문과 원자적 연산

lock 구문 안에 포함된 코드 블록은 논리적으로 하나의 원자적 연산처럼 동작한다.

즉, 어떤 스레드가 lock 블록에 진입하여 코드를 실행하는 동안에는 다른 스레드가 동일한 객체에 대한 lock을 획득할 수 없다.
따라서 다른 스레드는 해당 코드 블록의 실행이 끝날 때까지 대기 상태에 머물게 된다.

이로 인해 lock 블록 내부의 코드는 한 번에 하나의 스레드만 실행할 수 있으며, 다른 스레드가 연산 도중에 개입하는 것을 방지할 수 있다.

하지만 단순한 연산의 경우에는 lock을 사용하는 것이 불필요한 오버헤드를 발생시킬 수 있다.

예를 들어 다음과 같은 증가, 감소와 같은 단순한 연산은 Interlocked 클래스를 사용하여 처리할 수 있다.

Interlocked.Increment(ref number);

위 코드는 여러 스레드가 동시에 실행하더라도 number 값이 안전하게 1씩 증가하도록 보장한다.

정리

  • Interlocked는 다중 스레드 환경에서 간단한 연산을 안전하게 처리하기 위한 클래스이다.
  • lock보다 오버헤드가 적어 성능 면에서 유리하다.
  • 단, 단순한 연산(증가, 감소, 교환 등)에만 사용할 수 있다.

System.Threading.ThreadPool

스레드는 Thread 타입을 사용해 직접 생성할 수 있지만,
스레드의 동작 방식은 실행 목적에 따라 크게 두 가지 유형으로 나눌 수 있다.

1. 상시 실행 스레드 (Long-lived Thread)

스레드가 한 번 생성되면 비교적 오랜 시간 동안 유지되며 계속 동작하는 유형이다.

예를 들어 다음과 같은 경우에 사용된다.

  • 특정 디렉토리의 변화를 감지
  • 서버 요청 대기
  • 네트워크 연결 유지
  • 이벤트 감시

스레드는 보통 무한 루프 형태로 동작한다. 따라서 작업이 끝나도 스레드가 종료되지 않고 계속 유지된다.

2. 일회성 임시 실행 스레드 (Short-lived Thread)

특정 작업을 수행한 후 즉시 종료되는 스레드 유형이다.

예를 들어 다음과 같은 경우가 있다.

  • 데이터 계산
  • 파일 처리
  • 비동기 작업 수행

이러한 작업들은 짧은 시간 동안만 실행되고 바로 종료된다.

ThreadPool이 필요한 이유

일회성 작업을 수행할 때마다 Thread를 직접 생성하면 다음과 같은 문제가 발생한다.

  • 스레드 생성 비용이 크다
  • 스레드 관리가 어려워진다
  • 너무 많은 스레드가 생성될 수 있다

이 문제를 해결하기 위해 .NET에서는 ThreadPool을 제공한다.

ThreadPool은 미리 생성된 스레드들을 관리하다가 작업이 요청되면 유휴 상태의 스레드를 재사용하여 작업을 수행하게 한다.

즉, ThreadPool은 스레드를 매번 생성하지 않고 재사용하여 성능을 향상시키는 스레드 관리 시스템이다.

 

using System;
using System.Collections;
using System.Collections.Generic;
using System.Net.NetworkInformation;
using System.Reflection.Metadata;
namespace BCL_Study
{
    internal class StrStudy
    {
        static void Main(string[] args)
        {
            MyData data = new MyData();

            ThreadPool.QueueUserWorkItem(threadFunc, data);
            ThreadPool.QueueUserWorkItem(threadFunc, data);

            Thread.Sleep(1000);

            Console.WriteLine(data.Number);
        }
        static void threadFunc(object inst)
        {
            MyData data = inst as MyData;

            for(int i = 0; i< 100000; i++)
            {
                lock(data)
                {
                    data.Increment();
                }
            }
        }
    }
    class MyData
    {
        int number = 0;

        public object _numberLock = new object();

        public int Number { get { return number; } }

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

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

ThreadPool의 특징

ThreadPool의 주요 특징 중 하나는 한 번 생성된 스레드를 일정 시간 동안 재사용한다는 점이다.

스레드는 운영체제(OS)의 커널 자원으로 생성된다.
따라서 스레드를 하나 생성하거나 종료하는 과정에는 상당한 CPU 비용이 발생한다.

아래의 과정은 모두 운영체제가 관여하는 비교적 비용이 큰 작업이다.

  • 스레드 생성
  • 스레드 종료
  • 스레드 스케줄링

이 때문에 스레드를 자주 생성하고 종료하는 프로그램에서는 매번 Thread 객체를 생성하는 방식보다 ThreadPool을 통해 기존 스레드를 재사용하는 방식이 더 효율적이다.

ThreadPool은 미리 생성된 스레드들을 관리하다가 작업이 요청되면 유휴 상태의 스레드를 할당하여 작업을 수행하게 하고, 작업이 끝난 스레드는 다시 ThreadPool로 반환되어 재사용된다.

일회성 스레드가 필요한 경우

일회성 작업을 수행하기 위해 스레드를 사용할 때는 다음 두 가지 방법 중 하나를 선택할 수 있다.

  1. Thread 객체를 직접 생성하여 실행
  2. ThreadPool에 작업을 맡겨 실행

일반적인 상황에서는 Thread 객체를 직접 생성해서 사용해도 큰 문제는 없다.
다만 스레드 생성과 관리를 직접 해야 한다는 번거로움이 존재한다.

반면 ThreadPool을 사용하면 스레드를 직접 생성하지 않고 이미 생성되어 있는 스레드를 재사용할 수 있기 때문에 관리가 훨씬 간단해진다.

하지만 모든 경우에 ThreadPool이 더 좋은 선택은 아니다.
어떤 방식이 더 효율적인지는 ThreadPool의 내부 동작 방식을 이해하면 판단하기 쉽다.

 

Thread를 직접 생성하는 경우

  • 스레드를 오랫동안 유지해야 하는 작업
  • 스레드의 동작을 직접 제어해야 하는 경우
  • 백그라운드 서비스나 감시 작업

ThreadPool을 사용하는 경우

  • 짧은 작업
  • 많이 반복되는 작업
  • 일회성 계산 작업

System.Threading.EventWaitHandle

EventWaitHandle은 Monitor와 마찬가지로 스레드 동기화를 위한 도구 중 하나이다.

이 타입은 한 스레드는 특정 이벤트가 발생하기를 기다리고,
다른 스레드는 그 이벤트를 발생시키는 구조를 만들 때 사용한다.

즉, 스레드 간에 신호를 전달하는 방식의 동기화에 적합하다.

EventWaitHandle의 상태

EventWaitHandle 객체는 두 가지 상태만 가진다.

  1. Signaled 상태
  2. Non-Signaled 상태

각 상태의 의미는 다음과 같다.

  • Signaled 상태
    • 대기 중인 스레드가 실행을 계속할 수 있는 상태
  • Non-Signaled 상태
    • 스레드가 대기 상태로 멈춰 있는 상태

상태 변경

EventWaitHandle의 상태는 다음 메서드를 통해 변경할 수 있다.

  • Set()
    → 상태를 Signaled로 변경하여 대기 중인 스레드를 깨운다.
  • Reset()
    → 상태를 Non-Signaled로 변경하여 스레드를 다시 대기 상태로 만든다.

WaitOne 메서드

EventWaitHandle은 WaitOne 메서드를 제공한다.

어떤 스레드가 WaitOne 메서드를 호출했을 때 이벤트 객체의 상태에 따라 동작이 달라진다.

  • 이벤트 객체가 Signaled 상태라면
    → WaitOne 메서드는 즉시 반환되고 스레드는 계속 실행된다.
  • 이벤트 객체가 Non-Signaled 상태라면
    → WaitOne 메서드는 즉시 반환되지 않고 대기 상태에 들어간다.

이 경우 스레드는 이벤트 객체의 상태가 Signaled로 변경될 때까지 실행이 중단된다.

다른 스레드가 Set() 메서드를 호출하여 이벤트 상태를 Signaled로 변경하면,
대기 중이던 스레드는 WaitOne 메서드에서 벗어나 다시 실행을 이어갈 수 있다.

더보기

스레드 A → WaitOne() 호출 → 이벤트 대기
스레드 B → Set() 호출 → 이벤트 발생
스레드 A → 대기 해제 후 실행 재개

static void Main(string[] args)
{
    //Non-Signal 상태의 이벤트 객체 생성
    //생성자의 첫 번째 인자가 false이면 Non-Signal 상태로 시작
    // true이면 Signal 상태로 시작
    EventWaitHandle eventWaitHandle = new EventWaitHandle(false, EventResetMode.ManualReset);

    Thread thread = new Thread(threadFunc);

    thread.IsBackground = true;
    thread.Start(eventWaitHandle);

    //Non-Signal 상태에서 WaitOne을 호출했으므로 Signal 상태로 바뀔 때까지 대기
    eventWaitHandle.WaitOne();
    Console.WriteLine("주 스레드 종료");
}
static void threadFunc(object state)
{
    EventWaitHandle eventWaitHandle = state as EventWaitHandle;

    Console.WriteLine("60초 후에 프로그램 종료");
    Thread.Sleep(1000 * 60);    //60초 동안 실행 중지
    Console.WriteLine("스레드 종료");

    //Non-Signal 상태의 이벤트를 Signal 상태로 전환
    eventWaitHandle.Set();
}

EventWaitHandle 을 사용하면 한 스레드가 다른 스레드의 작업 완료를 기다리도록 만들 수 있다.

더보기

주 스레드 시작

작업 스레드 시작

주 스레드 WaitOne() → 대기

작업 스레드 60초 대기

작업 스레드 종료 메시지 출력

Set() 호출 → 이벤트 Signal

주 스레드 대기 해제

주 스레드 종료

이 내용을 보면 스레드 간에 신호를 전달하는 역할을 담당하는 것을 알 수 있다.

Join 메서드의 역할을 EventWaitHandle 객체로 우회해서 구현할 수 있기 때문에

이를 응용해 ThreadPool의 단점을 보완할 수 있다.

 

아래는 개선된 ThreadPool의 예시 코드이다.

using System;
using System.Collections;
using System.Collections.Generic;
using System.Net.NetworkInformation;
using System.Reflection.Metadata;
namespace BCL_Study
{
    internal class StrStudy
    {
        static void Main(string[] args)
        {
            MyData data = new MyData();
            
            Hashtable hashtable1 = new Hashtable();
            hashtable1["data"] = data;
            hashtable1["evt"] = new EventWaitHandle(false, EventResetMode.ManualReset);
            //데이터와 함께 이벤트 객체를 스레드 풀의 스레드에 전달한다.
            ThreadPool.QueueUserWorkItem(threadFunc, hashtable1);

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

            //2개의 이벤트 객체가 Signal 상태로 바뀔 때까지 대기한다.
            (hashtable1["evt"] as EventWaitHandle).WaitOne();
            (hashtable2["evt"] as EventWaitHandle).WaitOne();

            Console.WriteLine(data.Number);
        }
        static void threadFunc(object inst)
        {
            Hashtable hashTable = inst as Hashtable;

            MyData data = hashTable["data"] as MyData;

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

            //주어진 이벤트 객체를 Signal 상태로 전환한다.
            (hashTable["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++;
            }
        }
    }
}

출력 결과

이벤트 리셋 방식

이벤트는 크게 두 가지 방식으로 나뉜다.

  • 수동 리셋 이벤트 (Manual Reset Event)
  • 자동 리셋 이벤트 (Auto Reset Event)

두 방식의 차이는 EventWaitHandle.Set() 메서드가 호출되어 Signaled 상태가 되었을 때,
이벤트가 자동으로 Non-Signaled 상태로 돌아가는지 여부에 있다.

1. 자동 리셋 이벤트 (Auto Reset Event)

Set()이 호출되어 이벤트가 Signaled 상태가 되면,
대기 중인 하나의 스레드만 실행을 재개하고 이벤트는 자동으로 Non-Signaled 상태로 돌아간다.

즉, 이벤트 상태가 자동으로 초기화된다.

2. 수동 리셋 이벤트 (Manual Reset Event)

Set()이 호출되어 이벤트가 Signaled 상태가 되면,
이벤트는 계속 Signaled 상태를 유지한다.

따라서 이벤트를 다시 Non-Signaled 상태로 바꾸기 위해서는 개발자가 Reset() 메서드를 직접 호출해야 한다.

예제 코드의 경우

위 코드에서는 EventWaitHandle 생성자의 두 번째 인자로 EventResetMode.ManualReset을 전달하여 수동 리셋 이벤트를 생성하고 있다. 따라서 Set()이 한 번 호출되면 이벤트는 계속 Signaled 상태를 유지하게 된다.

언제 사용하는가?

수동 리셋 이벤트는 다음과 같은 상황에 적합하다.

  • 여러 스레드에게 동시에 신호를 전달해야 할 때
  • 특정 작업의 완료 여부를 알리는 용도

반면 자동 리셋 이벤트는 아래와 같은 상황에서 사용된다.

  • 스레드 하나씩 순차적으로 깨워야 하는 경우
  • 작업 큐 처리
이벤트 종류 특징
Auto Reset Event Set() 후 스레드 하나만 깨우고 자동으로 Non-Signaled 상태로 돌아감
Manual Reset Event Set() 후 계속 Signaled 상태 유지, Reset() 호출 필요

자동 리셋과 수동 리셋의 차이점

Set() 호출 후 이벤트 상태가 어떻게 변하는가몇 개의 스레드를 깨우는가에 있다.

자동 리셋 (Auto Reset Event)

특징

  • Set()이 호출되면 대기 중인 스레드 하나만 실행을 재개한다.
  • 그 후 이벤트 상태는 자동으로 Non-Signaled 상태로 돌아간다.
  • 즉, 신호가 한 번 소비되면 바로 초기화된다.

동작 흐름

더보기

Non-Signal

Set()

스레드 1개 실행

자동으로 Non-Signal

수동 리셋 (Manual Reset Event)

특징

  • Set()이 호출되면 대기 중인 모든 스레드가 실행을 재개한다.
  • 이벤트 상태는 Signaled 상태로 계속 유지된다.
  • Reset()을 호출해야만 다시 Non-Signaled 상태로 돌아간다.

동작 흐름

더보기

Non-Signal

Set()

모든 스레드 실행

Signaled 상태 유지

Reset() 호출

Non-Signal

 
구분 Auto Reset Manual Reset
깨우는 스레드 수 1개 모든 스레드
상태 변경 자동으로 Non-Signal Reset() 필요
사용 목적 순차 작업 처리 전체 스레드 동시 시작

AutoResetEvent  → 스레드 하나만 깨우고 자동 초기화( 신호 1회용 )
ManualResetEvent → 모든 스레드 깨우고 상태 유지 ( 신호 유지 )


Reference

시작하세요! C# 12 프로그래밍 기본 문법부터 실전 예제까지

이미지 출처

 

[C#] 비동기 호출

비동기 호출 비동기 호출(asynchronous call)이란 "동기 호출(synchronous call)"과 대비되는 개념이다. 일반적으로 비동기 호출은 입출력(I/O) 장치와 연계되어 설명될때가 많다. 예를 들어, 파일의 데이터

andjjip.tistory.com