동시성 제어에서 Semaphore와 Mutex는 목적이 다릅니다. 올바른 선택은 성능과 안정성을 좌우합니다.
1. 핵심 요약
Mutex는 단 하나의 소유자만 임계 구역에 들어오게 하는 상호 배제 장치입니다. Semaphore는 허용 가능한 동시 진입 수를 카운트로 제어합니다. 프로세스 간 동기화가 필요하면 커널 기반 객체(Mutex, Semaphore), 프로세스 내부와 비동기 코드에는 SemaphoreSlim을 우선 고려합니다.
2. 개념 정리: Mutex vs Semaphore vs SemaphoreSlim
Mutex는 오너십이 있습니다. 획득한 스레드만 해제할 수 있으며(.NET에서 소유하지 않은 스레드가 ReleaseMutex를 호출하면 예외), 재귀적 잠금을 지원합니다. 프로세스 간 동기화가 가능하며, 이전 소유자가 해제하지 못하고 종료되면 AbandonedMutexException으로 감지됩니다.
Semaphore는 카운터 기반입니다. 특정 개수만큼 동시에 임계 구역에 들어갈 수 있습니다. 오너십 개념이 없어 어떤 스레드든 Release로 반환할 수 있습니다. 이름을 부여해 프로세스 간에도 사용할 수 있습니다.
SemaphoreSlim은 경량, 프로세스 내부 전용입니다. 비동기 WaitAsync를 지원해 async/await와 궁합이 좋고, 성능 이점이 큽니다.
3. 언제 무엇을 선택할까?
단일 리소스에 대한 배타적 접근이 필요하고 프로세스 내부라면 lock(Monitor)을 최우선으로 씁니다. abandonment 감지나 프로세스 간 동기화가 필요할 때만 Mutex를 사용합니다.
동시 실행 개수를 제한하려면 프로세스 내부에서는 SemaphoreSlim을 사용합니다. 비동기 코드에서는 반드시 WaitAsync/Release 패턴을 사용하세요. 프로세스 간 동시성 제한이 필요할 때만 커널 기반 Semaphore를 씁니다.
ASP.NET, UI, 백그라운드 서비스 등 비동기 중심 코드는 SemaphoreSlim이 기본값입니다. 동기 블로킹은 피하는 것이 좋습니다.
4. C# 코드 예제 1: Mutex로 단일 인스턴스 보호(프로세스 간)
using System;
using System.Threading;
class Program
{
static void Main()
{
// Windows에서 전역 범위를 원하면 "Global\\MyApp_Job_Mutex"처럼 사용합니다.
using var mutex = new Mutex(false, "Global\\MyApp_Job_Mutex");
bool hasHandle = false;
try
{
hasHandle = mutex.WaitOne(TimeSpan.FromSeconds(5), false);
if (!hasHandle)
{
Console.WriteLine("다른 프로세스가 작업 중입니다.");
return;
}
Console.WriteLine("임계 구역 진입");
// 보호가 필요한 작업 수행
Thread.Sleep(1000);
}
catch (AbandonedMutexException)
{
// 이전 소유자가 비정상 종료. 상태 점검 후 계속할지 결정합니다.
Console.WriteLine("이전에 소유한 프로세스가 종료되었습니다. 안전성 검토가 필요합니다.");
hasHandle = true; // 현재 스레드가 소유권을 얻게 됩니다.
}
finally
{
if (hasHandle)
{
mutex.ReleaseMutex();
}
}
}
}
5. C# 코드 예제 2: SemaphoreSlim으로 동시 실행 제한(비동기)
using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
class Program
{
static readonly SemaphoreSlim Gate = new SemaphoreSlim(3); // 최대 3개 동시 실행
static readonly HttpClient Http = new HttpClient();
static async Task Main()
{
var tasks = new Task[10];
for (int i = 0; i < tasks.Length; i++)
{
tasks[i] = FetchAsync(i, CancellationToken.None);
}
await Task.WhenAll(tasks);
}
static async Task FetchAsync(int id, CancellationToken ct)
{
await Gate.WaitAsync(ct);
try
{
var resp = await Http.GetAsync("https://example.com", ct);
Console.WriteLine($"{id}: {resp.StatusCode}");
}
finally
{
Gate.Release();
}
}
}
6. 흔한 실수와 주의사항
비동기 코드에서 lock이나 Mutex.WaitOne 같은 동기 블로킹을 사용하면 스레드 풀 고갈과 교착 가능성이 커집니다. SemaphoreSlim.WaitAsync를 사용하세요.
Mutex는 소유한 스레드만 해제할 수 있으므로 쓰레드 전환이 있는 설계에서는 주의가 필요합니다. 반면 Semaphore는 오너십이 없어 다른 스레드가 Release해도 됩니다.
예외나 취소 발생 시 finally에서 항상 Release 또는 ReleaseMutex를 호출해 누수를 방지합니다. IDisposable을 구현하므로 using으로 수명 관리를 권장합니다.
7. 성능과 플랫폼 메모
커널 객체(Mutex, Semaphore)는 컨텍스트 전환 비용이 커 느릴 수 있습니다. 프로세스 내부에서는 Monitor(lock) 또는 SemaphoreSlim이 일반적으로 더 빠릅니다.
이름 있는 동기화 객체는 Windows와 Linux 모두 지원되지만, Windows의 Global\, Local\ 접두사는 Windows 전용입니다. 크로스 플랫폼 이름 규칙을 미리 정하고 테스트하세요.
8. 마무리 체크리스트
프로세스 내부 단일 접근은 lock, abandonment 감지나 프로세스 간이면 Mutex를 사용합니다. 동시 개수 제한은 비동기라면 SemaphoreSlim, 프로세스 간이면 Semaphore를 사용합니다. 항상 finally에서 해제하고, 가능하면 비동기 대기를 사용해 교착과 병목을 줄입니다.
'C#' 카테고리의 다른 글
| C# Marshal 클래스 활용한 메모리 변환 기법 (1) | 2026.07.29 |
|---|---|
| C# GCHandle로 관리 객체 고정(Pinning)하기 (0) | 2026.07.29 |
| C# PeriodicTimer로 주기적 작업 구현하기 (0) | 2026.07.28 |
| C# Random.Shared와 안전한 난수 생성 전략 (0) | 2026.07.27 |
| C# UTF8String 리터럴과 UTF-8 문자열 처리 최적화 (0) | 2026.07.27 |