.NET의 System.Threading을 기준으로 스레딩에 대한 기본 지식을 정리해보자.
스레드는 명령어를 실행하기 위한 스케줄링 단위, 즉 프로그램 내에서 실행되는 독립적인 실행 흐름의 최소 단위이다.
스레드는 CPU의 명령어 실행과 관련된 정보를 보관하고 있는데, 이를 스레드 문맥(Thread Context)이라고 한다.
스레드는 운영체제에 의해 스케줄링되며 언제든 실행 도중에 일시중지 될 수 있다.
운영체제에 의해 스레드가 교체될 때 스레드 문맥이 중요하다.
일시중지가 되는 시점에 스레드 문맥을 갱신하고 이후 다시 실행될 때 스레드 문맥을 통해 작업을 복원한다.
프로세스 하나에 여러개의 스레드를 지원하는 것을 멀티 스레딩(Multi-Threading)이라고 한다.
운영체제가 지원해야 하지만 요즘 운영체제는 기본적으로 멀티 스레딩을 지원한다.
프로그램을 시작할 때 Main 메서드가 호출되는 스레드를 프로그램의 메인 스레드(Main Thread)라고 하고 사용자가 의도적으로 호출하는 스레드를 서브 스레드(Sub Thread)라고 한다.
운영체제마다 다르지만 Windows 기준 서브 스레드는 기본적으로 1MB 만큼의 스택 메모리가 할당된다.
그렇기 때문에 메모리만 충분하다면 스레드를 생성하는데 제한은 없다.
하지만 스레드의 작업을 수행할 물리적인 CPU 코어는 한정적이다.
그렇기 때문에 운영체제가 스케줄링을 통해 스레드의 실행과 교체를 반복하여 사용자로 하여금 여러 작업이 동시에 진행되는 것처럼 느끼게 해준다.
(물론 CPU의 코어 개수만큼 물리적인 동시 작업이 이루어진다.)
프로세스 내의 스레드들은 각각이 보유한 스택 메모리를 제외한 다른 메모리는 공유한다.
이를 공유 자원(Shared Resource)이라고 한다.
멀티 스레드 환경에서는 특히 공유 자원을 취급하는데 주의해야 되는데, 이는 스레드가 운영체제에 의해 스케줄링되어 사용자가 직접적으로 제어할 수 없어 결과를 예측할 수 없기 때문이다.
아래 예시는 스레드의 대표적인 특징을 보여준다.
class Program
{
static int number = 0;
static void Main(string[] args)
{
Thread t1 = new Thread(threadFunc);
Thread t2 = new Thread(threadFunc);
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine(number); // 200000이 출력되지 않고 매번 결과가 다르다.
}
static void threadFunc()
{
for (int i = 0; i < 100000; i++)
{
number = number + 1;
}
}
}
number라는 공유 자원에 두 스레드가 접근하면서 생기는 동기화 이슈다.
number의 값을 1만큼 올리는 연산은 아래와 같이 3단계로 이루어진다.
하지만 스레드는 언제든지 스케줄링되어 교체될 수 있다.
만약 1을 더한 결과를 다시 number의 주소에 저장하기 전에 다른 스레드로 교체되고 다른 스레드가 같은 연산을 수행한다면 사실상 1회의 시행은 무시된거나 다름 없다.
이렇듯 1을 더하는 연산은 더 작은 연산으로 쪼개질 수 있다.
하지만 "n = 1"과 같은 연산은 더이상 쪼갤 수 없다.
이러한 연산을 원자적 연산(Atomic Operation)라고 한다.
아래의 Monitor, lock, Interlocked는 논리적으로 원자적 연산을 지원하는 클래스, 예약어들이다.
참조 형식의 공유 자원을 하나의 스레드가 단독으로 점유할 수 있게 한다.
사용자가 직접 운영체제의 스케줄링에 개입할 수 없기 때문에 한 번에 하나의 스레드만 공유 자원에 접근할 수 있게 하여 동기화를 수행한다.
class Program
{
int number = 0;
static void Main(string[] args)
{
Program pg = new Program();
Thread t1 = new Thread(threadFunc);
Thread t2 = new Thread(threadFunc);
t1.Start(pg);
t2.Start(pg);
t1.Join();
t2.Join();
Console.WriteLine(pg.number); // 200000이 정상적으로 출력된다.
}
static void threadFunc(object ins)
{
Program pg = ins as Program;
for (int i = 0; i < 100000; i++)
{
Monitor.Enter(pg); // 반드시 참조 형식이어야 한다.
try
{
pg.number = pg.number + 1;
}
finally
{
Monitor.Exit(pg); // 반드시 참조 형식이어야 한다.
}
}
}
}
위의 Monitor + try/finally 구문을 하나의 예약어로 지원하는 것이 lock이다.
class Program
{
int number = 0;
static void Main(string[] args)
{
Program pg = new Program();
Thread t1 = new Thread(threadFunc);
Thread t2 = new Thread(threadFunc);
t1.Start(pg);
t2.Start(pg);
t1.Join();
t2.Join();
Console.WriteLine(pg.number);
}
static void threadFunc(object ins)
{
Program pg = ins as Program;
for (int i = 0; i < 100000; i++)
{
lock (pg)
{
pg.number = pg.number + 1;
}
}
}
}
실제로 컴파일러는 컴파일 타임에 lock 예약어를 Monitor + try/finally 구문으로 바꾼다.
간단한 연산에 대해 공유 자원의 동기화를 지원하는 다양한 메서드를 제공하는 정적 클래스이다.
lock과 구분되는 장점은 값 형식의 공유 자원에 대한 연산을 간단하게 해준다는 것이다.
class Program
{
static int number = 0;
static void Main(string[] args)
{
Thread t1 = new Thread(threadFunc);
Thread t2 = new Thread(threadFunc);
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine(number); // 200000이 정상적으로 출력된다.
}
static void threadFunc()
{
for (int i = 0; i < 100000; i++)
{
Interlocked.Increment(ref number);
}
}
}
스레드의 생성과 재사용을 지원하는 타입이다.
일회성의 간단한 스레드가 짧은 주기로 자주 생성되는 프로그램이라면 스레드 풀을 통해 성능 개선을 노려볼 수 있다.
class MyData
{
int number = 0;
public int Number { get { return number; } }
public void Increment()
{
Interlocked.Increment(ref number);
}
}
class Program
{
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();
}
}
}
}
스레드 풀이 알아서 스레드를 생성하고 실행 시켜주는 것을 확인할 수 있다.
스레드 풀이 갖는 특징은 다음과 같다.
- 생성된 스레드는 일정 시간 동안 재사용된다.
- 운영체제의 커널 자원을 사용한다. 그렇기 때문에 스레드의 생성/종료 비용이 크다.
커널 자원을 사용하기 때문에 특히나 공유 자원 관리에 신경써야 한다.
(실제로 위 코드에서 lock을 지우고 실행하면 백신 프로그램이 가동된다...)
이벤트를 발생시켜 다른 스레드에 신호(signal)를 전달할 수 있는 타입이다.
EventWaitHandle 객체는 Signal과 Non-Signal 두가지의 상태를 갖는다.
Non-Signal 상태에서 Signal 상태로 변하면 이벤트가 발생하고, 이벤트는 WaitOne 메서드로 전달된다.
WaitOne 메서드는 EventWaitHandle 객체가 Signal 상태가 될 때까지 해당 스레드의 제어권을 가진다.
쉽게 생각해서 WaitOne 메서드는 thread.Join 메서드와 비슷한 기능을 한다고 볼 수 있다.
(이러한 제어권을 블락(block)이라고도 한다.)
class MyData
{
int number = 0;
public int Number { get { return number; } }
public void Increment()
{
Interlocked.Increment(ref number);
}
}
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? inst)
{
Hashtable? ht = inst as Hashtable;
MyData? data = ht?["data"] as MyData;
for (int i = 0; i < 100000; i++)
{
data?.Increment();
}
(ht?["evt"] as EventWaitHandle)?.Set();
}
}
EventWaitHandle 생성자의 두번째 인자를 보면 리셋 모드를 설정할 수 있다.
리셋 모드는 수동 리셋(Manual Reset)과 자동 리셋(Auto Reset)이 있다.
수동 리셋은 객체를 재사용할 경우 직접 Reset 메서드를 호출해줘야 한다.
자동 리셋은 Set으로 이벤트가 발생하자마자 바로 리셋된다.
위 코드는 각 스레드가 서로 다른 EventWaitHandle 객체를 가졌기 때문에 크게 상관이 없다.
하지만 만약 두 스레드가 동일한 객체를 가졌다면 리셋하는 타이밍에 따라 해당 부분에서 결과가 달라질 수 있다.
(ht1?["evt"] as EventWaitHandle)?.WaitOne();
(ht2?["evt"] as EventWaitHandle)?.WaitOne();
자동 리셋 모드를 적용할 경우 하나의 객체라도 한 번의 Set에 하나의 WaitOne만 처리된다.
하지만 수동 리셋은 한 번의 Set에 WaitOne이 전부 처리될 수 있다.
BCL에서 제공하는 메서드는 대부분 동기 호출 방식이다.
하지만 "Begin~", "End~"로 시작하는 일부 메서드는 비동기 호출 방식을 지원한다.
비동기 호출시 스레드 풀에서 스레드를 풀링하여 제어권을 넘긴다.
대표적인 비동기 호출로 델리게이트가 있다. (플랫폼에 따라 안되는 경우가 있다.)
public class Calc
{
public static long Cumsum(int start, int end)
{
long sum = 0;
for (int i = start; i <= end; i++)
{
sum += i;
}
return sum;
}
}
class Program
{
public delegate long CalcMethod(int start, int end);
static void Main(string[] args)
{
CalcMethod calc = new CalcMethod(Calc.Cumsum);
calc.BeginInvoke(1, 100, calcCompleted, calc);
Console.ReadLine();
}
static void calcCompleted(IAsyncResult ar)
{
CalcMethod calc = ar.AsyncState as CalcMethod;
long result = calc.EndInvoke(ar);
Console.WriteLine(result);
}
}
반환 타입인 IAsyncResult의 AsyncWaitHandle 속성의 타입이 EventWaitHandle이기 때문에 WaitOne 메서드로 이벤트를 대기할 수도 있다.
public class Calc
{
public static long Cumsum(int start, int end)
{
long sum = 0;
for (int i = start; i <= end; i++)
{
sum += i;
}
return sum;
}
}
class Program
{
public delegate long CalcMethod(int start, int end);
static void Main(string[] args)
{
CalcMethod calc = new CalcMethod(Calc.Cumsum);
IAsyncResult ar = calc.BeginInvoke(1, 100, null, null);
ar.AsyncWaitHandle.WaitOne();
long result = calc.EndInvoke(ar);
Console.WriteLine(result);
}
}
참고 자료
시작하세요! C# 10 프로그래밍 - 정성태
같이 알아두면 좋은 용어: volatile, Memory barrier, SpinLock, AutoResetEvent, ManualResetEvent, Mutex, ReaderWriterLock