'이것이 C#이다 개정판'의 chapter 19를 정리한 글입니다.
프로세스와 스레드 (Process & Thread)
프로세스는 실행 파일이 실행되어 메모리에 적재된 인스턴스입니다.
가령 word.exe가 실행 파일이라면, 이 실행 파일에 담겨 있는 데이터와 코드가 메모리에 적재되어 동작하는 것이 프로세스입니다.
프로세스는 반드시 하나 이상의 스레드로 구성되는데, 스레드는 운영체제가 CPU 시간을 할당하는 기본 단위입니다.

멀티 스레드를 이용할 때의 장점
- 사용자 대화형 프로그램(콘솔 프로그램 및 GUI 프로그램 모두)에서 멀티 스레드를 이용하면 응답성을 높일 수 있습니다.
- 단일 스레드로 프로그램을 만들고, 해당 프로그램에서 파일 복사 작업을 할 때의 작업을 취소하고 싶어도 프로그램은 사용자에게 반응하지 않아서 복사를 취소할 수 없습니다.
- 위의 프로그램에서 사용자와의 대화를 위한 스레드를 하나 더 추가한다면, 파일 복사 작업을 하면서도 사용자로부터 명령을 입력받을 수 있습니다.
- 멀티 프로세스 방식에 비해 멀티 스레드 방식이 자원 공유가 쉽습니다.
- 멀티 프로세스틑 GUI가 없는 웹 서버 같은 서버용 애플리케이션에서 많이 취하는 구조인데, 프로세스끼리 데이터를 교환하려면 소켓이나 공유 메모리 같은 IPC(Inter Process Communication)를 이용해야 합니다.
- 반면에, 멀티 스레드 방식에서는 스레드끼리 코드 내의 변수를 같이 사용하는 것만으로도 데이터 교환을 할 수 있습니다.
- 멀티 프로세스 방식에 비해 멀티 스레드 방식이 경제성 측면에서 좋습니다.
- 프로세스를 띄우기 위해 메모리와 자원을 할당하는 작업은 비용이 비싼 반면, 스레드를 띄울 때는 이미 프로세스에 할당된 메모리와 자원을 그대로 사용하므로 따로 비용을 지불하지 않아도 됩니다.
멀티 스레드를 이용할 때의 단점
- 멀티 스레드 구조의 소프트웨어는 구현하기 매우 까다롭습니다.
- 소프트웨어 안정성을 악화시킬 수 있습니다.
- 멀티 스레드 기반의 소프트웨어는 자식 스레드 중 하나에 문제가 생기면 전체 프로세스에 영향을 줍니다.
- 반면에, 멀티 프로세스 기반의 소프트웨어는 여러 개의 자식 프로세스 중 하나에 문제가 생기면 그 자식 프로세스 하나가 죽는 것 이상으로는 영향이 확산되지 않습니다.
- 스레드를 과용하면 오히려 성능이 저하됩니다.
- 스레드가 CPU를 사용하기 위해서는 작업 간 전환(Context Switching)을 해야 하는데, 이 작업 간 전환은 적잖은 비용을 소모합니다.
스레드 구현
C#에서 스레드를 다루기 위해서는 .NET에서 지원하는 스레드를 제어하는 클래스 System.Threading.Tread가 필요합니다.
해당 클래스를 사용하기 위해서는 다음 순서가 필요합니다.
스레드 구현 순서
① Thread의 인스턴스를 생성합니다. 이때 생성자의 인수로 스레드가 실행할 메서드를 넘깁니다.
② Thread.Start() 메서드를 호출하여 스레드를 시작합니다.
③ Thread.Join() 메서드를 호출하여 스레드가 끝날 때까지 기다립니다.
Thread의 인스턴스를 생성하고, 스레드를 Thread.Start() 메서드를 호출하여 시작할 때, 실제 스레드가 메모리에 적재됩니다.
인스턴스 생성은 준비만 해둘 뿐입니다.
Thread.Start() 메서드를 호출한 뒤, CLR은 스레드를 실제로 생성하여 스레드가 실행할 메서드를 호출합니다.
Thread.Join() 메서드는 블록되어 있다가 스레드가 실행할 메서드의 실행이 끝나면(스레드의 실행이 끝나면) 반환되어 다음 코드를 실행할 수 있께 합니다.
System.Threading.Thread.Start( ) 메서드
- 역할
- 새로 생성된 Thread 객체가 나타내는 스레드의 실행을 시작합니다,
- 작동 방식
- Start( ) 메서드를 호출하면 .NET 런타임이 새 운영 체제 스레드를 생성하고, 이 스레드에서 Thread 객체를 생성할 때 지정했던 델리게이트(메서드)를 실행하기 시작합니다.
- 특징
- Start( ) 메서드는 비동기적입니다. 즉, Start( )를 호출한 스레드는 새 스레드가 실행을 시작하는 것을 기다리지 않고 즉시 다음 코드를 실행합니다.
- 새 스레드는 호출 스레드와 독립적으로 동시에 실행됩니다.
System.Threading.Thread.Join( ) 메서드
- 역할
- Join( ) 메서드를 호출한 스레드를 Join( )을 호출당한 스레드가 실행흘 마칠 때까지 대기하도록 만듭니다.
- 작동 방식
- Join( ) 메서드를 호출하면, 호출한 스레드는 블록(Block) 상태가 되어 대기합니다.
- Join( )을 호출당한 스레드가 실행을 모두 마치거나, 처리되지 않은 예외로 종료될 때 대기 상태가 해제되고 호출한 스레드가 다시 실행을 계속합니다.
- 특징
- Join( ) 메서드는 동기적인 메서드입니다.
- 오버로드
- Join(TimeSpan timeout) / Join(int millisecondsTimeout)
- 대기할 최대 시간을 지정하는 오버로드가 존재합니다.
- 지정된 시간 내에 대상 스레드가 종료되지 않으면 대기 상태가 해제됩니다.
[예제]
using System;
using System.Threading;
namespace BasicThread
{
class MainApp
{
static void DoSomething()
{
for (int i = 0; i < 5; i++)
{
Console.WriteLine($"DoSomething : {i}");
// Sleep() 메서드를 만나면 인수(10)만큼 CPU 사용을 멈춥니다.
// 이때 인수 단위는 밀리초입니다.
Thread.Sleep(10);
}
}
static void Main(string[] args)
{
Thread t1 = new Thread(new ThreadStart(DoSomething));
Console.WriteLine("Starting thread...");
t1.Start();
for (int i = 0; i < 5; i++)
{
Console.WriteLine($"Main : {i}");
Thread.Sleep(10);
}
Console.WriteLine("Wating until thread stops...");
t1.Join();
Console.WriteLine("Finished");
}
}
}

스레드 임의 종료 : Abort( ) 메서드
프로세스를 임의로 죽이기 위해서는 사용자가 작업 관리자 등을 이용하면 됩니다. 하지만, 프로세스 안에서 동작하는 각 스레드는 그런 식으로 죽일 수 없습니다.
살아있는 스레드를 죽이려면 그 스레드를 나타내는 Thread 객체의 Abort() 메서드를 호출해줘야 합니다.
Abort( ) 메서드
Thread.Abort() 메서드는 특정 스레드의 실행을 강제로 죵료하려고 시도하는 메서드입니다.
다만, 이 기능은 현재 Obsolete되어 사용을 강력히 권장하지 않고 있습니다.
[작동 방식]
Thread.Abort() 가 호출되면, .NET 런타임은 대상 스레드에서 System.Threading.TrheadAbortException 예외를 발생시키려고 합니다.
대상 스레드는 이 ThreadAbortException 예외를 받게 되며, 만약 예외 처리가 되어 있지 않다면 스레드는 종료됩니다.
[특징]
- 비동기적 호출
- Abort( )는 대상 스레드의 실행 위치와 상관없이 예외를 삽입하려고 시도하는 비동기적인 메서드입니다.
- 호출 즉시 대상 스레드가 종료되는 것을 보장하지 않습니다.
- finally 블록 실행
- ThreadAbortException 예외는 일반 예외와 마찬가지로 try - catch - finally 구문의 finally 블록을 실행시킵니다.
- 스레드가 강제로 종료되더라도 finally 블록에 있는 정리 코드가 실행될 기회가 존재합니다.
- Thread.ResetAbort( )
- 대상 스레드는 catch (ThreadAbortException ex) 구문에서 이 예외를 잡은 후, Thread.ResetAbort( ) 메서드를 호출하여 Abort( ) 요청을 취소하고 실행을 계속할 수 있었습니다.
[사용하지 말아야 하는 이유]
- 심각한 상태 손상 위험
- Thread.Abort( )는 스레드가 코드 실행의 어떤 지점에 있든 예외를 강제로 주입합니다. 이에 따라 애플리케이션의 전체 상태가 손상될 위험이 있습니다.
- 예측 불가능성
- 예외가 정확히 어느 시점에 발생할지 예측하기 어렵습니다. 특정 시점에만 안전하게 중단될 수 있는 작업의 경우 Abort( )는 적합하지 않습니다.
- 리소스 누수 가능성
- finally 블록이 실행되더라도, 상태가 손상된 상황에서는 finally 블록 내의 정리 코드가 제대로 작동하지 않아 리소스(파일 핸들, 네트워크 소켓, 메모리 등) 누수가 발생할 수 있습니다.
[권장되는 대안]
스레드 또는 비동기 작업의 실행을 중단해야 할 때는 Thread.Abort( )와 같은 강제적인 방법 대신 협력적 취소(Cooperative Cancellation) 패턴을 사용하는 것이 올바른 방법입니다.
취소를 요청하는 쪽에서 CancellationTokenSource.Cancel( )을 호출하고, 취소 대상 스레드/작업에서는 CancellationToken 객체를 전달받아 주기적으로 IsCancellationReqeuested 속성을 확인하거나 ThrowIfCancellationRequested( ) 메서드를 호출하여 스스로 중단 여부를 결정하고 안전하게 종료합니다.
[예제 : 협력적 취소 패턴]
using System;
using System.Threading;
namespace AbortingThread
{
class SideTask
{
int count;
CancellationToken cancellationToken; // 취소 토큰
public SideTask(int count, CancellationToken token) // 취소 토큰을 매개변수로 받음
{
this.count = count;
this.cancellationToken = token; // 취소 토큰 저장
}
public void KeepAlive()
{
try
{
while (count > 0)
{
// 취소 요청 확인
if (cancellationToken.IsCancellationRequested)
{
Console.WriteLine("취소 요청 받음. 스레드 종료 중...");
// cancellationToken.ThrowIfCancellationRequested();
// 예외를 던져서 종료 (선택 사항)
return; // 메서드를 빠져나가 스레드 종료
}
Console.WriteLine($"{count--} left");
Thread.Sleep(10);
}
Console.WriteLine("Count : 0");
}
finally
{
Console.WriteLine("Clearing resource...");
}
}
}
class MainApp
{
static void Main(string[] args)
{
// 취소 토큰 소스 생성
CancellationTokenSource cts = new CancellationTokenSource();
// 취소 토큰 얻기
CancellationToken token = cts.Token;
SideTask task = new SideTask(100, token); // 취소 토큰 전달
Thread t1 = new Thread(new ThreadStart(task.KeepAlive));
t1.IsBackground = false;
Console.WriteLine("Starting thread...");
t1.Start();
Thread.Sleep(100);
Console.WriteLine("Requesting cancellation...");
cts.Cancel(); // 취소 요청
Console.WriteLine("Waiting until thread stops...");
t1.Join();
Console.WriteLine("Finished");
}
}
}

스레드의 일생과 상태 변화
ThreadState 열거형 : 스레드의 상태
| 상태 | 설명 |
| Unstarted | 스레드 객체를 생성한 후 Thread.Start( ) 메서드가 호출되기 전의 상태입니다. |
| Running | 스레드가 시작하여 동작 중인 상태를 나타냅니다. Unstarted 상태의 스레드를 Thread.Start( ) 메서드를 통해 이 상태로 만들 수 있습니다. |
| Suspended | 스레드의 일시 중단 상태를 나타냅니다. 스레드를 Thread.Suspend( ) 메서드를 통해 이 상태로 만들 수 있으며, Suspended 상태인 스레드는 Thread.Resume( ) 메서드를 통해 다시 Running 상태로 만들 수 있습니다. |
| WaitSleepJoin | 스레드가 블록(Block)된 상태를 나타냅니다. 스레드에 대해 Monitor.Enter( ), Thread.Sleep( ) 또는 Thread.Join( ) 메서드를 호출하면 해당 상태가 됩니다. |
| Aborted | 스레드가 취소된 상태를 나타냅니다. Thread.Abort( ) 메서드를 호출하면 이 상태가 됩니다. Aborted 상태가 된 스레드는 다시 Stopped 상태로 전환되어 완전히 중지됩니다. |
| Stopped | 중지된 스레드의 상태를 나타냅니다. Abort( ) 메서드를 호출하거나 스레드가 실행 중인 메서드가 종료되면 이 상태가 됩니다. |
| Background | 스레드가 백그라운드로 동작하고 있음을 나타냅니다. 포어그라운드(Foreground) 스레드는 하나라도 살아 있는 한 프로세스가 죽지 않지만, 백그라운드는 하나가 아니라 열 개가 살아 있어도 프로세스가 죽고 사는 것에는 영향을 미치지 않습니다. 하지만 프로세스가 죽으면 백그라운드 스레드들도 모두 죽습니다. Thread.IsBackgorund 속성에 ture 값을 입력함으로써 스레드를 이 상태로 바꿀 수 있습니다. |
Flags Attribute
ThreadState는 Flags 애트리뷰트를 갖고 있습니다.
Flags 애트리뷰트는 C#에서 열거형(enum)을 비트 필드(Bit field), 즉 비트 플래그(Bit Flags)의 집합으로 취급하도록 지시하는 애트리뷰트입니다.
비트 필드는 Flags 애트리뷰트가 적용된 열거형의 각 멤버가 개별적인 비트를 나타내며, 이러한 비트들을 조합하여 여러 속성이나 상태를 동시에 표현할 수 있게 합니다.
[ThreadState가 Flags 애트리뷰트는 갖는 이유]
스레드는 동시에 두 가지 이상의 상태일 수 있습니다.
두 가지 이상의 상태를 동시에 표현하기 위해 ThreadState가 Flags 애트리뷰트를 갖습니다.
[사용 예제]
// Non Flags, Common Enumeration
enum MyEnum
{
Apple = 1 << 0, // 1 (0001)
Orange = 1 << 1, // 2 (0010)
Kiwi = 1 << 2, // 4 (0100)
Mango = 1 << 3 // 8 (1000)
};
Console.WriteLine( (MyEnum)1 ); // Apple
Console.WriteLine( (MyEnum)2 ); // Orange
Console.WriteLine( (MyEnum)4 ); // Kiwi
Console.WriteLine( (MyEnum)8 ); // Mango
Console.WriteLine( (MyEnum)(1 | 4) ); // 5
Console.WriteLine( (MyEnum)(1 | 8) ); // 9
// 열거 요소에 대응하지 못하는 값은 형식 변환을 시도해도 원래의 값으로 표현됩니다.
// Enumeration using Flags Attribute
[Flags]
enum MyEnum
{
Apple = 1 << 0, // 1 (0001)
Orange = 1 << 1, // 2 (0010)
Kiwi = 1 << 2, // 4 (0100)
Mango = 1 << 3 // 8 (1000)
};
Console.WriteLine( (MyEnum)1 ); // Apple
Console.WriteLine( (MyEnum)2 ); // Orange
Console.WriteLine( (MyEnum)4 ); // Kiwi
Console.WriteLine( (MyEnum)8 ); // Mango
Console.WriteLine( (MyEnum)(1 | 4) ); // Apple, Kiwi
Console.WriteLine( (MyEnum)(1 | 8) ); // Apple, Mango
// Flag 애트리뷰트는 열거형의 요소들의 집합으로 구성되는 값들도 표현할 수 있습니다.
[예제]
using System;
using System.Threading;
namespace UsingThreadState
{
class MainApp
{
private static void PrintThreadState(ThreadState state)
{
Console.WriteLine("{0, -16} : {1}", state, (int)state);
}
static void Main(string[] args)
{
PrintThreadState(ThreadState.Running); // 000000000
PrintThreadState(ThreadState.StopRequested); // 000000001
PrintThreadState(ThreadState.SuspendRequested); // 000000010
PrintThreadState(ThreadState.Background); // 000000100
PrintThreadState(ThreadState.Unstarted); // 000001000
PrintThreadState(ThreadState.Stopped); // 000010000
PrintThreadState(ThreadState.WaitSleepJoin); // 000100000
PrintThreadState(ThreadState.Suspended); // 001000000
PrintThreadState(ThreadState.AbortRequested); // 010000000
PrintThreadState(ThreadState.Aborted); // 100000000
PrintThreadState(ThreadState.Aborted | ThreadState.Stopped); // 100010000
}
}
}

스레드 임의 종료 : Interrupt( ) 메서드
안정성의 측면에서 강력하게 권장되지 않는 Thread.Abort( ) 메서드 대신, Thread.Interrupt( ) 메서드는 스레드가 한참 동작 중인 상태(Running 상태)를 피해서 WaitJoinSleep 상태에 들어갔을 때 ThreadInterruptedException 예외를 던져 스레드를 중지시킵니다.
Thread.Interrpupt( ) 메서드는 스레드가 이미 WaitSleepJoin 상태에 있을 때는 즉시 중단시키지만, 다른 상태일 때는 스레드를 지켜보고 있다가 WaitSleepJoin 상태가 되면 그제서야 스레드를 중단시킵니다.
이런 특징 덕분에 개발자는 최소한 코드가 절대로 중단되면 안되는 작업을 하고 있을 때는 중단되지 않는다는 보장을 받을 수 있습니다.
[예제]
using System;
using System.Threading;
namespace InterruptingThread
{
class SideTask
{
int count;
public SideTask(int count)
{
this.count = count;
}
public void KeepAlive()
{
try
{
Console.WriteLine("Running thread isn't gonna be interrupted");
Thread.SpinWait(1000000000);
while (count > 0)
{
Console.WriteLine($"{count--} left");
Console.WriteLine("Entering into WaitJoinSleep State...");
Thread.Sleep(10);
}
Console.WriteLine("Count : 0");
}
catch (ThreadInterruptedException e)
{
Console.WriteLine(e);
}
finally
{
Console.WriteLine("Clearing resource...");
}
}
}
class MainApp
{
static void Main(string[] args)
{
SideTask task = new SideTask(100);
Thread t1 = new Thread(new ThreadStart(task.KeepAlive));
t1.IsBackground = false;
Console.WriteLine("Starting thread...");
t1.Start();
Thread.Sleep(100);
Console.WriteLine("Interrupting thread...");
t1.Interrupt();
Console.WriteLine("Wating until thread stops...");
t1.Join();
Console.WriteLine("Finished");
}
}
}

스레드 동기화 (Thread Synchronization)
스레드 동기화는 여러 스레드가 공유 자원에 안전하게 접근하고, 특정 코드 영역을 동시에 실행하지 않도록 제어하여 프로그램의 정확성과 안정성을 보장하는 메커니즘입니다.
스레드는 본래 서로 협력하지 않고 제멋대로 행동하기 때문에, 정연하게 자원을 사용할 수 있게 하는 것이 중요하고, 스레드들이 순서를 갖춰 자원을 사용하게 하는 것을 일컬어 동기화라고 합니다.
스레드 동기화의 역할
- 자원에 대한 단독 접근 보장
- 공유 자원(메모리, 파일, 네트워크 연결 등)에 접근하는 코드 영역을 한 번에 하나의 스레드만 실행하도록 보장하여, 여러 스레드가 자원을 수정하거나 사용하는 것을 방지합니다.
- 데이터 일관성 유지, 경쟁 조건 방지, 교착 상태 회피에 핵심적인 역할을 합니다.
- 데이터 일관성 유지
- 여러 스레드가 동시에 공유 데이터에 접근하여 수정할 때, 데이터가 손상되거나 예상치 못한 값으로 변경되는 것을 방지합니다.
- 경쟁 조건 방지
- 여러 스레드가 동시에 공유 자원에 접근하려고 경쟁하는 상황에서, 실행 순서에 따라 결과가 달라지는 문제를 방지합니다.
- 교착 상태 회피
- 여러 스레드가 서로의 자원 획득을 기다리면서 무한정 멈춰있는 상황을 방지합니다.
- 자원 접근 순서를 일관되게 하거나, 타임아웃을 설정하는 등의 동기화 기법을 통해 교착 상태를 예방할 수 있습니다.
- 정확한 실행 순서 보장
- 특정 스레드가 다른 스레드의 작업 완료 후에 실행되도록 실행 순서를 제어합니다.
- 스레드 간의 의존성을 관리하고, 전체 작업 흐름을 올바르게 유지합니다.
스레드 동기화 : lock 키워드
C#에서는 lock 키워드로 코드 영역을 감싸주어 해당 영역을 크리티컬 섹션(Critical Section)으로 바꿀 수 있습니다.
코드 영역을 lock 키워드와 중괄호로 둘러싸 크리티컬 섹션으로 만들면, 한 스레드가 이 코드를 실행하다가 lock 블록이 끝나는 괄호를 만나기 전까지 다른 스레드는 절대 해당 코드 영역을 실행할 수 없습니다.
※ 크리티컬 섹션 (Critical Section)
크리티컬 섹션은 한 번에 한 스레드만 사용할 수 있는 코드 영역을 말합니다.
lock 키워드의 성능 저하
lock 키워드는 사용하기 간편하고 스레드 안전성을 보장하지만, 다음과 같은 이유로 성능 저하를 유발할 수 있습니다.
- 스레드 블로킹
- 가장 큰 성능 저하 요인입니다. lock을 획득하려는 스레드가 이미 다른 스레드에 의해 잠금이 걸려 있다면, 해당 스레드는 블록 상태로 전환됩니다.
- 블록된 스레드는 CPU를 사용하지 않고 대기하며, 운영 체제는 다른 실행 가능한 스레드에게 CPU를 할당합니다.
- 잠금이 해제되면, 운영 체제는 대기 중인 스레드 중 하나를 선택하여 다시 실행 가능한 상태로 만들고, 해당 스레드가 잠금을 획득하도록 합니다.
- 이러한 스레드 컨텍스트 스위칭 과정은 상당안 오버헤드를 발생시킵니다.
스레드 상태 저장, 스케줄링, 캐시 무효화 등 많은 작업이 필요하며, CPU 시간을 낭비하고 전체 프로그램의 성능을 저하시킵니다.
- 경쟁
- 여러 스레드가 빈번하게 동일한 lock을 획득하려고 시도하는 경우, 경쟁이 발생합니다.
- 경쟁이 심할수록 스레드가 블록될 확률이 높아지고, 컨텍스트 스위칭이 더 자주 발생하여 성능 저하가 심화됩니다.
- 교착 상태
- 드물지만, lock을 잘못 사용하면 여러 스레드가 서로의 잠금 해제를 기다리면서 영원히 멈춰있는 교착 상태가 발생할 수 있습니다.
- 교착 상태는 프로그램의 응답성을 완전히 멈추게 하므로, 심각한 문제를 발생시킬 수 있습니다.
- 힙 메모리 할당
- lock 키워드는 참조 타입 객체에 대해서만 사용할 수 있습니다. 값 타입 변수를 lock에 사용하려면 박싱이 발생하여 힙에 객체가 할당됩니다.
- 박싱은 가비지 컬렉션의 부담을 증가시키고 성능을 저하시킬 수 있습니다.
lock 키워드 사용시 주의할 점
- 잠금 범위 및 획득 빈도 최소화
- lock으로 보호하는 코드 영역(크리티컬 섹션)을 최대한 작게 유지합니다. lock 블록 안에서 불필요한 연산이나 I/O 작업을 수행하지 않도록 합니다.
- lock을 획득하는 빈도를 줄입니다. 여러 작업을 한 번의 lock으로 처리할 수 있다면, 여러 번 lock을 사용하는 것보다 효율적입니다.
- 경쟁 없는 상황에서는 lock 피하기
- 공유 자원에 대한 접근이 경쟁적이지 않은 상황에서는 lock을 사용하지 않는 것이 좋습니다.
- 코드의 여러 부분에서 쉽게 접근하거나 공유될 수 있는 특정 객체들 사용하지 않기
- 해당 객체들을 사용했을 때, 예상치 못한 스레드 간의 경쟁이나 교착 상태를 유발할 가능성이 높아집니다.
- this 키워드 사용하지 않기
- 클래스의 인스턴스는 클래스 내부뿐만 아니라 외부에서도 거의 항상 사용됩니다. 외부 코드에서도 접근할 수 있는 lock (this)는 사용하지 않는 것을 권장합니다.
- Type 형식 사용하지 않기
- typeof 연산자나 object 클래스로부터 물려받은 GetType() 메서드는 Type 형식의 인스턴스를 반환합니다. 즉, 코드의 어느 곳에서나 특정 형식에 대한 Type 객체를 얻을 수 있습니다.
- 따라서 lock(typeof(SomeClass))나 lock(obj.GetType())은 사용하지 않는 것을 권장합니다.
- string 형식 사용하지 않기
- .NET 런타임은 문자열 리터럴을 메모리 내의 intern 풀이라는 특별한 영역에 저장합니다. intern 풀에는 각 문자열 리터럴에 대한 단 하나의 객체만 존재합니다. 즉, 동일한 문자열 리터럴을 여러 번 사용하더라도, 실제로는 모두 동일한 string 객체를 참조합니다.
- lock("스트링객체")와 같이 문자열 리터럴을 잠금 객체로 사용하면, 프로그램 네의 다른 코드에서도 동일한 문자열 리터럴을 사용하여 lock을 획득할 수 있습니다.
- 서로 관련 없는 코드 영역이 동일한 잠금 객체를 공유하게 되어 스레드 경쟁이 발생할 가능성이 매우 높아집니다.
[예제]
using System;
using System.Threading;
namespace Synchronize
{
class Counter
{
const int LOOP_COUNT = 1000;
readonly object thisLock;
private int count;
public int Count
{
get { return count; }
}
public Counter()
{
thisLock = new object();
count = 0;
}
public void Increase()
{
int loopCount = LOOP_COUNT;
while(loopCount-- > 0)
{
lock (thisLock)
{
count++;
}
Thread.Sleep(1); // decThread에게 실행 기회를 줍니다.
}
}
public void Decrease()
{
int loopCount = LOOP_COUNT;
while (loopCount-- > 0)
{
lock (thisLock)
{
count--;
}
Thread.Sleep(1); // incThread에게 실행 기회를 줍니다.
}
}
}
class MainApp
{
static void Main(string[] args)
{
Counter counter = new Counter();
Thread incThread = new Thread(new ThreadStart(counter.Increase));
Thread decThread = new Thread(new ThreadStart(counter.Decrease));
incThread.Start();
decThread.Start();
incThread.Join();
decThread.Join();
Console.WriteLine(counter.Count);
}
}
}

스레드 동기화 : Monitor 클래스
Monitor 클래스는 스레드 동기화에서 사용하는 몇 가지 정적 메서드를 제공합니다.
그 중 Monitor.Enter( )와 Monitor.Exit( ) 메서드는 lock 키워드와 동일한 기능을 합니다.
Monitor.Enter( ) 메서드는 크리티컬 섹션을 만들며 Monitor.Exit( ) 메서드는 크리티컬 섹션을 제거합니다.
※ lock 키워드는 Monitor 클래스의 Enter( )와 Exit( ) 메서드를 바탕으로 구현되어 있습니다.
그러니 사실 Monitor.Enter( )와 Monitor.Exit( )로 동기화를 할 것 같으면 차라리 간편하게 lock 키워드를 사용하는 편이 낫습니다.
[예제]
using System;
using System.Threading;
namespace UsingMonitor
{
class Counter
{
const int LOOP_COUNT = 1000;
readonly object thisLock;
private int count;
public int Count
{
get { return count; }
}
public Counter()
{
thisLock = new object();
count = 0;
}
public void Increase()
{
int loopCount = LOOP_COUNT;
while(loopCount-- > 0)
{
Monitor.Enter(thisLock);
try
{
count++;
}
finally
{
Monitor.Exit(thisLock);
}
Thread.Sleep(1);
}
}
public void Decrease()
{
int loopCount = LOOP_COUNT;
while (loopCount-- > 0)
{
Monitor.Enter(thisLock);
try
{
count--;
}
finally
{
Monitor.Exit(thisLock);
}
Thread.Sleep(1);
}
}
}
class MainApp
{
static void Main(string[] args)
{
Counter counter = new Counter();
Thread incThread = new Thread(new ThreadStart(counter.Increase));
Thread decThread = new Thread(new ThreadStart(counter.Decrease));
incThread.Start();
decThread.Start();
incThread.Join();
decThread.Join();
Console.WriteLine(counter.Count);
}
}
}

저수준 동기화 : Monitor.Wait( ) & Monitor.Pulse( )
lock 키워드 대신 Monitor 클래스를 사용해야 한다면 그건 Enter( )와 Exit( ) 메서드 때문이 아니라 Wait( )와 Pulse( ) 메서드 때문일 것입니다.
Monitor.Wait( ) 메서드와 Monitor.Pulse( ) 메서드는 단순히 lock 키워드만 사용할 때보다 더 섬세하게 멀티 스레드 간의 동기화를 가능하게 해줍니다.
Monitor.Wait()와 Monitor.Pulse()는 스레드 간의 신호를 기반으로 하는 기본적인 동기화 메커니즘입니다.
이들은 스레드를 대기시키고 깨우는 기능을 제공하지만, 직접적인 스레드 실행 제어나 자원 관리를 제공하지 않기 때문에 저수준으로 분류됩니다.
Monitor.Wiat( )
- 역할
- Monitor.Wait() 메서드는 현재 스레드를 대기 상태로 전환하고, 다른 스레드가 신호를 보낼 때까지 기다리도록 합니다.
- 작동 방식
- 1. 잠금 해제 : Wait( 메서드를 호출하면, 현재 스레드는 자동으로 지정된 객체에 대한 잠금(Lock)을 해제합니다.
이는 다른 스레드가 해당 객체에 대한 잠금을 획득하고, 공유 자원을 수정하거나 특정 작업을 수행할 수 있도록 하기 위함입니다. - 2. 대기 상태 전환 : 스레드는 대기 큐(Wait Queue)에 들어가 블록(Block) 상태가 됩니다.
이 스레드는 CPU 시간을 소비하지 않고, 운영 체제가 다른 스레드를 실행하도록 스케줄링합니다. - 3. 신호 대기 : 스레드는 다른 스레드가 Pulse()또는 PulseAll() 메서드를 호출하여 신호를 보낼 때까지 대기 큐에서 기다립니다.
- 4. 잠금 재획득 : 신호를 받으면, 대기 중인 스레드는 자동으로 잠금 객체에 대한 잠금을 다시 획득하려고 시도합니다.
만약 다른 스레드가 이미 잠금을 획득한 상태라면, 신호를 받은 스레드는 잠금을 획득할 수 있을 때까지 다시 대기합니다. - 5. 실행 재개 : 잠금을 다시 획득하면, 스레드는 Wait() 메서드 호출 지점부터 다시 실행을 시작합니다.
- 1. 잠금 해제 : Wait( 메서드를 호출하면, 현재 스레드는 자동으로 지정된 객체에 대한 잠금(Lock)을 해제합니다.
- 필수 조건
- Wait() 메서드를 호출하기 전에 반드시 해당 객체에 대한 잠금을 획득하고 있어야 합니다.
- 즉, lock 블록 안에서 Wait()를 호출해야 합니다.
- lock을 걸어 놓지 않은 상태에서 호출할 경우, CLR이 SynchronizationLockException 예외를 던집니다.
- 오버로드
- Wait(object obj, int millsecondsTimeout) / Wait(object obj, TimeSpan timeout)
- 대기할 최대 시간을 지정하는 오버로드가 존재합니다. 지정된 시간 내에 신호를 받지 못하면 대기 상태가 해제되고 flase를 반환합니다.
Monitor.Pulse( )
- 역할
- Pulse() 및 PulseAll() 메서드는 대기 큐에서 대기 중인 스레드에게 신호를 보내는 역할을 합니다.
- 작동 방식
- Pulse(object obj)
- 대기 큐(Waiting Queue)에서 첫 번째 위치에 있는 스레드 하나에게 신호를 보내어, CLR은 해당 스레드를 실행 가능한 상태로 변경하고, 운영 체제의 스레드 스케줄러가 관리하는 Ready Queue에 배치합니다.
- 신호를 받은 스레드는 대기 상태에서 깨어나 실행 가능한 상태가 되며, 잠금 객체에 대한 잠금을 획득하기 위해 다른 스레드와 경쟁합니다. 잠금을 획득한 스레드는 운영 체제의 스레드 스케줄러에 의해 선택되어 CPU를 할당받고 실행(Running) 상태로 전환됩니다.
- PulseAll(object obj)
- 대기 큐(Waiting Queue)에서 대기하고 있는 모든 스레드에게 신호를 보내어, CLR은 해당 스레드를 실행 가능한 상태로 변경하고, 운영 체제의 스레드 스케줄러가 관리하는 Ready Queue에 배치합니다.
- 각 스레드는 신호를 받으면 대기 상태에서 깨어나 실행 가능한 상태가 되며, 잠금 객체에 대한 잠금을 획득하기 위해 다른 스레드와 경쟁합니다. 스레드들은 운영 체제의 스레드 스케줄러에 의해 우선 순위, 대기 시간 등에 따라 선택되어 잠금을 획득하고, CPU를 할당받아 실행(Running) 상태로 전환됩니다.
- Pulse(object obj)
- 필수 조건
- Wait() 메서드와 마찬가지로, Pulse() 또는 PulseAll() 메서드를 호출하기 전에 반드시 해당 객체에 대한 잠금을 획득하고 있어야 합니다.
- 즉, lock 블록 안에서 호출해야 합니다.
- lock을 걸어 놓지 않은 상태에서 호출할 경우, CLR이 SynchronizationLockException 예외를 던집니다.
- 주의할 점
- Thread.Sleep() 메서드도 스레드를 WaitSleepJoin 상태로 만들기는 하지만, Waiting Queue에 들어가지도 않을 뿐더러, Monitor.Pulse() 메서드에 의해 깨어날 수는 없습니다.
- Thread.Sleep() 메서드로 WiatSleepJoin 상태가 된 스레드를 다시 Running 상태로 돌아오게 하려면, 매개변수에 입력된 시간이 경가되거나 인터럽트 예외(Interrupt() 메서드 호출에 의해)를 받아야 다시 Running 상태가 될 수 있습니다.
Monitor.Wiat( )와 Monitor.Pulse( )의 사용 패턴
1. 클래스 안에 다음과 같이 동기화 객체 필드를 선언합니다.
readonly object thisLock = new object();
2. 스레드를 WaitSleepJoin 상태로 바꿔 블록시킬 조건(즉, Wiat()를 호출할 조건)을 결정할 필드를 선언합니다.
bool lockedCount = false;
3. 스레드를 블록시키고 싶은 곳에서 다음과 같이 lock 블록 안에서, '2번 과정'에서 선언한 필드를 검사하여 Monitor.Wait()를 호출합니다.
lock (thisLock)
{
while (count > 0 || lockedCount == true)
Monitor.Wait(thisLock);
// ...
}
4. 깨어난 스레드는 먼저 lockedCount를 true로 설정하여 다른 스레드의 진입을 막고, count 변수를 안전하게 조작한 후 lockedCount를 false로 되돌립니다. 마지막으로 Monitor.Pulse()를 호출하여 대기 중인 다른 스레드에게 실행 기회를 알립니다.
lock (thisLock)
{
while (count > 0 || lockedCount == true)
Monitor.Wait(thisLock);
lockedCount = true;
count++;
lockedCount = false;
Monitor.Pulse(thisLock);
}
[예제]
using System;
using System.Threading;
namespace WaitPulse
{
class Counter
{
const int LOOP_COUNT = 1000;
readonly object thisLock;
// lockedCount와 count는 스레드가 블록될 조건을 검사하기 위해 사용됩니다.
// lockedCount는 count 변수를 다른 스레드가 사용하고 있는지를 판별하기 위해,
// count는 각 스레드가 너무 오랫동안 count 변수를 혼자 사용하는 것을 막기 위해 사용되었습니다.
bool lockedCount = false;
private int count;
public int Count
{
get { return count; }
}
public Counter()
{
thisLock = new object();
count = 0;
}
public void Increase()
{
int loopCount = LOOP_COUNT;
while(loopCount-- > 0)
{
lock (thisLock)
{
// count가 0보다 크거나 lockedCount가 다른 스레드 에 의해
// true로 바뀌어 있으면 현재 스레드를 블록시킵니다.
// 다른 스레드가 Pulse() 메서드를 호출해줄 때까지는 WaitSleepJoin 상태로 남습니다.
while (count > 0 || lockedCount == true)
Monitor.Wait(thisLock);
lockedCount = true;
count++;
lockedCount = false;
// lockedCount를 false로 만든 뒤에 다른 스레드를 깨웁니다.
// 깨어난 스레드들은 Decrease() 안의 while 문의 조건 검사를 통해
// 다시 Wait()를 호출할지, 그 다음 코드를 실행할지를 결정합니다.
Monitor.Pulse(thisLock);
}
}
}
public void Decrease()
{
int loopCount = LOOP_COUNT;
while (loopCount-- > 0)
{
lock (thisLock)
{
while (count < 0 || lockedCount == true)
Monitor.Wait(thisLock);
lockedCount = true;
count--;
lockedCount = false;
Monitor.Pulse(thisLock);
}
}
}
}
class MainApp
{
static void Main(string[] args)
{
Counter counter = new Counter();
Thread incThread = new Thread(new ThreadStart(counter.Increase));
Thread decThread = new Thread(new ThreadStart(counter.Decrease));
incThread.Start();
decThread.Start();
incThread.Join();
decThread.Join();
Console.WriteLine(counter.Count);
}
}
}

'C#' 카테고리의 다른 글
| [C#] 태스크 (Task) (3) | 2025.05.26 |
|---|---|
| [C#] 파일과 디렉터리 다루기, 스트림, 객체 직렬화 (0) | 2025.05.23 |
| [C#] dynamic (0) | 2025.05.16 |
| [C#] 리플렉션과 애트리뷰트 (0) | 2025.05.13 |
| [C#] LINQ (0) | 2025.05.12 |