수정 이력(4건, 최종 수정 2026년 08월 25일)
이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.
- 부자연스러운 구어·비유를, 의미를 바꾸지 않고 기술 문서로서 자연스러운 일본어로 고쳤습니다. 수정 전 버전 보기 (DOI: 10.5281/zenodo.22054230)
- 지식 맵의 관계를 전면 점검해, 본문 설명과 어긋나 있던 관계(방향 반전·과도한 일반화·술어 혼동)를 고쳤습니다. 본문 설명은 바꾸지 않았습니다.
- 지식 맵의 관계를 전면 점검해, 본문 설명과 어긋나 있던 관계(방향 반전·과도한 일반화·술어 혼동)를 고쳤습니다. 본문 설명은 바꾸지 않았습니다.
- 지식 맵의 「전용 락 객체는 데드락을 방지한다」는 관계를 「완화한다」로 고쳤습니다. 여러 락의 획득 순서가 얽히는 데드락은 남기 때문입니다. 본문 설명은 바꾸지 않았습니다.
- 최초 공개
이 글을 인용하기(DOI(등록된 아카이브): 10.5281/zenodo.22175832)
아래 DOI는 이전에 등록된 아카이브를 가리키며 현재 본문과 다를 수 있습니다. 현재 본문을 참조할 때는 이 페이지의 URL을 사용하세요.
Go Komura (2026). 「멀티스레드 실무 베스트 프랙티스 .NET 편 ── 스레드를 늘리기 전에 정해 둘 것」. 합동회사 코무라소프트. https://comcomponent.com/ko/blog/multithreading-best-practices-dotnet/
- DOI(등록된 아카이브)
- 10.5281/zenodo.22175832
- DOI(마지막 등록 버전)
- 10.5281/zenodo.22175833
「처리가 느려서 스레드를 만들어 병렬로 했더니, 가끔 집계 결과가 어긋난다」「백그라운드 처리를 넣었더니, 한 달에 한 번 앱이 멈추게 됐다」「디버그 실행에서는 재현되지 않는다고 해도, 고객사에서는 분명히 난다」── 멀티스레드 프로그래밍의 무서움은, 쓴 직후에는 올바르게 돌아가는 것처럼 보인다는 점입니다. 경합 버그는 타이밍에 의존하므로, 테스트를 빠져나가 프로덕션에서만 모습을 드러냅니다.
한편 멀티코어가 당연한 지금은, 업무 앱에서도 「UI를 멈추지 않고 무거운 처리를 돌린다」「여러 장치나 파일을 병행 처리한다」와 같은 요구 때문에 멀티스레드를 피할 수 없는 장면이 분명히 있습니다. 중요한 것은 스레드를 늘리기 전에 설계 원칙을 정해 두는 것입니다. 멀티스레드 버그는 디버그로 잡는 것이 아니라, 설계로 들어올 여지를 없애는 것이기 때문입니다.
이 글은 멀티스레드 실무 시리즈의 .NET 편입니다. Windows에서 업무 앱을 개발하다 멀티스레드화가 필요해진 개발자를 대상으로, 언어나 OS를 가리지 않고 통하는 설계 원칙과 C#/.NET의 구체적 도구를, 2026년 8월 시점의 1차 정보를 바탕으로 정리합니다. 원칙 자체는 Linux에서도 C++에서도 달라지지 않습니다. 네이티브 코드로 쓸 때는 같은 원칙을 각 언어의 도구로 옮긴 「C++ 편」「C 언어 편」을, Java로 쓸 때는 「Java 편」을 참조하십시오.
1. 먼저 결론
- 스레드를 직접 만들지 않는 것이 첫 번째 베스트 프랙티스입니다.
new Thread가 아니라 Task나 스레드 풀, Parallel 같은 상위 API를 쓰고, 스레드 수 관리는 런타임에 맡깁니다.12 - 병렬화에서 먼저 줄일 것은 「공유된 가변 상태」입니다. 여러 스레드가 같은 변수에 쓰는 지점이 경합의 발생원이며, 락으로 지키기에 앞서 데이터 분할·불변화·전달로 공유 자체를 줄입니다.3
- 락에는 규율을 둡니다. 「어느 데이터를 어느 락이 지키는지」를 1대1로 정하고, 락 대상은 외부에 보이지 않는 전용 객체로 합니다.
lock(this)나lock(typeof(X))는 금지입니다. .NET 9 이후에는 전용System.Threading.Lock형식을 씁니다.4 - 스레드 사이 데이터 전달은 큐로 모읍니다.
System.Threading.Channels나 동시성 컬렉션을 쓴 프로듀서/컨슈머 구성은, 여기저기에 락을 두르는 것보다 설계가 단순하고 경계도 분명해집니다.56 - 멈추는 방법은 처음에 설계합니다. 정지는
CancellationToken에 의한 협력적 취소가 유일한 정답이며,Thread.Abort는 .NET(Core 계열)에서는 실행 시 예외가 됩니다.78 - UI는 UI 스레드만의 것입니다. WinForms 컨트롤도 WPF 요소도, 만든 스레드 이외에서 만지면 안 됩니다. 다른 스레드에서는
Control.Invoke/Dispatcher를 통해 요청합니다.910 - 「병렬로 하면 빠르다」고는 할 수 없습니다. 한 번당 일이 작은 루프는 병렬화 오버헤드로 오히려 느려집니다. 반드시 측정한 뒤에 채택합니다.3
그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 28건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle
2. 멀티스레드는 왜 어려운가 ── 경합 상태와 데드락
멀티스레드가 끌어들이는 문제는, 따져 보면 두 종류입니다.4
경합 상태(race condition)는, 여러 스레드가 어떤 순서로 특정 코드에 도달하느냐에 따라 결과가 바뀌어 버리는 버그입니다. 대표적인 예가 공유 카운터의 증가로, count++라는 한 줄은 실제로는 「읽기 → 더하기 → 다시 쓰기」의 3단계로 나뉩니다. 두 스레드가 동시에 이 3단계를 실행하면, 한쪽의 증가가 다른 쪽의 다시 쓰기로 덮여 증가가 사라집니다. 실행할 때마다 결과가 바뀌고, 어떤 실행 결과가 될지는 예측할 수 없습니다.4
sequenceDiagram
participant A as 스레드 A
participant M as 공유 변수 count
participant B as 스레드 B
Note over M: count = 10
A->>M: 읽기(10)
B->>M: 읽기(10)
A->>A: 자기 쪽에서 더하기(11)
B->>B: 자기 쪽에서 더하기(11)
A->>M: 다시 쓰기(11)
B->>M: 다시 쓰기(11)
Note over M: 2번 더했는데 count = 11<br/>스레드 A의 증가가 사라졌다
그림 1: 공유 카운터에서 증가가 사라지는 전형적인 경합 상태. count++의 3단계 사이에 다른 스레드가 끼어들면, 나중에 다시 쓴 쪽이 덮어씁니다
데드락은, 두 스레드가 서로 상대가 가진 락을 기다리며, 어느 쪽도 앞으로 나아가지 못하는 상태입니다. 스레드 A가 락 1을 들고 락 2를 기다리고, 스레드 B가 락 2를 들고 락 1을 기다린다 ── 이것만으로 둘은 영원히 멈춥니다.4
flowchart LR
A["스레드 A<br/>락 1을 보유 중"] -->|"락 2의 해제 대기"| B["스레드 B<br/>락 2를 보유 중"]
B -->|"락 1의 해제 대기"| A
그림 2: 데드락의 순환 대기. 대기 화살표가 고리를 만든 순간, 고리 안의 모든 스레드가 영원히 정지합니다
까다로운 점은, 둘 다 타이밍에 의존한다는 것입니다. 개발 머신에서는 수만 번에 한 번만 맞는 인터리브(실행 순서 조합)가, 코어 수도 타이밍도 다른 고객사 머신에서는 매일 일어나는 일이 흔합니다. 「디버거를 붙이면 재현되지 않는다」「로그를 넣으니 사라졌다」는 것도, 관측이 타이밍을 바꿔 버리기 때문이며, 경합 버그의 전형적인 행동입니다.
그래서 이후의 원칙은 모두 한 방향을 향합니다. 「올바르게 동기화하기」보다 먼저 「동기화가 필요한 장소를 줄이기」 ── 이것이 멀티스레드 설계의 기본 원칙입니다.
3. 원칙 1: 스레드를 직접 만들지 않는다
3.1. Task와 스레드 풀에 맡긴다
new Thread(...)로 스레드를 직접 만드는 방식은, 지금의 .NET에서는 예외적인 최후 수단입니다. .NET Framework 4 이후, 멀티스레드·병렬 코드의 권장 수단은 TPL(Task Parallel Library), 즉 Task를 중심으로 한 API 군입니다. TPL은 병렬도를 이용 가능한 프로세서에 맞춰 동적으로 조정하고, 일의 분할, 스레드 풀로의 스케줄링, 취소 대응, 상태 관리 같은 저수준 수고를 모두 떠맡습니다.1
스레드 풀은 .NET 자신이 Task 실행, 비동기 I/O 완료, 타이머 콜백 등 폭넓게 쓰는 기반이며, 짧은 일을 던지는 한 개발자가 스레드의 수명 주기를 관리할 필요는 없습니다.2
// CPU를 쓰는 무거운 처리를 백그라운드에서
var result = await Task.Run(() => HeavyCalculation(input));
// 독립된 여러 처리를 병행으로 돌리고 전부 기다린다(건수가 적을 때)
// ※ 이 형태는 ProcessAsync가 I/O가 주체인 비동기 메서드일 때의 쓰기입니다.
// WhenAll은 「이미 돌아가고 있는 Task를 기다릴」 뿐이므로, CPU 계산을 병행하고
// 싶을 때는 각 처리를 Task.Run(() => Calc(x))로 감싸 스레드 풀에 올립니다
var results = await Task.WhenAll(items.Select(x => ProcessAsync(x)));
// 건수가 많으면, 동시 실행 수에 상한을 두고 흘립니다
await Parallel.ForEachAsync(items,
new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = callerCt },
async (x, ct) => await ProcessAsync(x, ct));
// 포인트는 두 가지입니다. 호출 측 토큰을 ParallelOptions에 연결할 것
// (이것을 잊으면 본문의 ct는 항상 None)과, 그 ct를 본문에도 통과시킬 것(버리지 말 것)
주의점이 하나 있습니다. Task.WhenAll(items.Select(...))는 열거한 시점에 모든 요소의 처리를 한꺼번에 시작합니다. 몇 건~수십 건의 정해진 일이면 문제없지만, 건수가 많은 컬렉션에 쓰면 소켓·DB 연결·메모리를 한 번에 다 씁니다. 건수를 가늠할 수 없는 처리는, 위의 Parallel.ForEachAsync처럼 동시 실행 수에 상한을 두거나, 뒤에서 다루는 bounded 채널로 유량을 제어합니다.
자체 스레드가 정당화되는 것은, 「전용 메시지 루프를 가진다」「스레드의 아파트먼트(STA)를 지정해야 한다」「앱의 수명 동안 계속 돈다」처럼, 스레드 자체의 성질이 요건이 된 경우에 거의 한정됩니다.
3.2. 데이터 병렬은 Parallel.For / ForEach
「컬렉션의 각 요소에 같은 처리를 해서, 전체를 빠르게 하고 싶다」는 데이터 병렬에는, 루프를 직접 스레드에 나누지 말고 Parallel.For / Parallel.ForEach를 씁니다. 데이터 소스의 분할(파티셔닝)과 부하의 재배분은 TPL이 하고, 기본적인 루프라면 락도 필요 없습니다.11
다만 공식 문서가 명기하는 함정이 두 가지 있습니다.3
- 병렬이 언제나 빠르다고 생각하지 말 것. 반복 횟수가 적거나, 한 번의 처리가 가벼운 루프는 병렬화 오버헤드가 본체를 웃돌아 느려집니다. 성능은 요인이 많으므로, 반드시 측정해서 판단합니다.
- 반복끼리 서로 기다리지 말 것.
Parallel.For의 각 반복이 실제로 병렬 실행된다는 보장은 없습니다. 어떤 반복이 다른 반복의 이벤트 설정을 기다리는 코드는, 스케줄링에 따라 데드락합니다.
3.3. 「기다리는」 처리는 스레드가 아니라 비동기 I/O로
파일·네트워크·DB처럼 I/O 대기가 주체인 처리는, 스레드를 늘릴 대상이 아닙니다. 기다리는 동안 스레드를 한 개 점유하는 것은 낭비일 뿐이고, async/await에 의한 비동기 I/O라면 대기 중에 스레드를 소비하지 않습니다. 이 구분(CPU 바운드는 병렬화, I/O 바운드는 비동기화)은 멀티스레드 설계의 입구에서 먼저 그어야 할 선입니다.
flowchart TB
S["병행하고 싶은 처리가 있다"] --> Q1{"처리의 주체는?"}
Q1 -->|"I/O 대기가 주체<br/>파일·네트워크·DB"| ASYNC["async/await의 비동기 I/O<br/>스레드는 늘리지 않는다"]
Q1 -->|"CPU를 쓰는 계산"| Q2{"일의 형태는?"}
Q2 -->|"컬렉션의 모든 요소에<br/>같은 처리를 적용"| PAR["Parallel.For / ForEach"]
Q2 -->|"독립된 한 덩어리의<br/>백그라운드 처리"| TASK["Task.Run / Task.WhenAll"]
Q2 -->|"메시지 루프나 STA 지정 등<br/>스레드 자체의 성질이 요건"| TH["new Thread<br/>(예외적인 최후 수단)"]
그림 3: 「스레드를 만들기」 전의 분기. 대부분의 업무 처리는 위 세 출구 중 하나에 떨어지고, new Thread에 도달하는 것은 예외적인 경우뿐입니다
async/await의 실무 판단은 「C# async/await 실무 판단표」에서, 그 바닥에서 스레드 풀과 비동기 I/O가 어떻게 이어지는지는 「IOCP와 .NET 스레드 풀」에서 자세히 다룹니다.
4. 원칙 2: 공유 가변 상태를 최소화한다
경합은 「여러 스레드」와 「공유된 가변 데이터」가 갖춰졌을 때만 일어납니다. 스레드 수는 요건으로 정해지므로, 설계에서 깎을 수 있는 것은 공유 쪽입니다. 수단은 세 가지입니다.
4.1. 분할한다 ── 각 스레드가 자기 데이터만 만진다
가장 단순하고 강력한 것은, 데이터를 스레드마다 나눠 버리는 것입니다. 병렬 루프의 집계라면, 공유 합계 변수에 매번 쓰지 말고, Parallel.For의 스레드 로컬 상태를 받는 오버로드를 써서 각 스레드가 자기 쪽에서 소계를 만들고, 마지막에 한 번만 합류시킵니다. 공유로의 쓰기가 「반복마다」에서 「스레드마다 한 번」까지 줄어, 동기화 비용도 경합의 창도 자릿수가 달라질 만큼 작아집니다.3
long total = 0;
Parallel.For(0, items.Length,
() => 0L, // 스레드 로컬 초깃값
(i, state, local) => local + Weigh(items[i]), // 각 반복은 자기 local에만 더한다
local => Interlocked.Add(ref total, local)); // 합류는 스레드마다 한 번
flowchart TB
SRC["데이터 배열(처리 대상)"] --> T1["스레드 1<br/>담당분을 처리해<br/>자기 쪽 소계에만 더한다"]
SRC --> T2["스레드 2<br/>담당분을 처리해<br/>자기 쪽 소계에만 더한다"]
SRC --> T3["스레드 3<br/>담당분을 처리해<br/>자기 쪽 소계에만 더한다"]
T1 --> M["합류: Interlocked.Add로<br/>스레드마다 한 번만 합계에 반영"]
T2 --> M
T3 --> M
그림 4: 스레드 로컬 집계. 처리 중에는 각 스레드가 자기 데이터만 만지므로 경합의 여지가 없고, 공유로의 쓰기는 합류 시 스레드마다 한 번뿐입니다
4.2. 불변으로 만든다 ── 바꾸지 않는 것은 공유해도 된다
읽기만 하는 데이터는, 몇 스레드에서 동시에 읽어도 안전합니다. 설정값·마스터 데이터·계산 입력 등은, 구축 뒤에 바꾸지 않게(immutable로 만들게) 하면 동기화 없이 자유롭게 공유할 수 있습니다. C#이라면 record 형식이나 init 속성이 이 설계를 뒷받침합니다. 「변경이 필요해지면, 고치는 것이 아니라 새 인스턴스를 만들어 교체한다」고 정하는 것만으로, 지켜야 할 가변 상태가 하나 줄어듭니다.
다만 「읽기 전용으로 보이는 것」과 「불변인 것」은 다릅니다. IReadOnlyList<T> 같은 읽기 전용 인터페이스는 「그 인터페이스를 통해서는 고칠 수 없다」일 뿐이고, 뒤에 있는 List<T>를 다른 참조에서 고치는 것은 막지 못합니다. record / init의 보장도 얕아서, 속성이 참조하는 대상 객체까지는 지키지 않습니다. 스레드 사이에서 정말로 안전하게 공유하고 싶은 데이터는, ImmutableArray<T>처럼 System.Collections.Immutable의 불변 컬렉션을 쓰거나, 공유하는 시점에 복사본을 넘겨 고치는 경로 자체를 끊습니다. 이때 요소 형식 T 자체도 불변일 것이 조건입니다. 불변 컬렉션이 지키는 것은 「나열」뿐이고, 가변 요소 객체로의 참조는 그대로 공유되므로, 다른 경로에서 요소 속을 고치면 경합은 남습니다. 객체 그래프의 끝까지 불변으로 만들거나, 깊은 복사본을 넘기십시오.
4.3. 전달한다 ── 공유하는 대신 큐로 보낸다
그래도 스레드 사이에서 데이터를 움직여야 할 필요는 있습니다. 그때는 「공유 변수를 양쪽에서 만지는」 것이 아니라, 한쪽이 쓰고, 한쪽이 읽는 큐를 사이에 두는 프로듀서/컨슈머 구성으로 합니다.
.NET에서의 첫째 후보는 System.Threading.Channels입니다. 프로듀서가 비동기로 데이터를 쓰고, 컨슈머가 비동기로 읽어 내는 FIFO이며, 동기화의 수고는 모두 채널이 관리합니다.5
var channel = Channel.CreateBounded<WorkItem>(100); // 용량 100으로 배압을 건다
// 프로듀서 측
await channel.Writer.WriteAsync(item, ct); // 가득이면 빈자리가 날 때까지 기다린다
// …모든 프로듀서가 쓰기를 마치면:
channel.Writer.Complete(); // 「더 오지 않는다」를 선언. 이것이 없으면 읽는 쪽 루프가 끝나지 않는다
// 컨슈머 측
await foreach (var item in channel.Reader.ReadAllAsync(ct))
{
Process(item);
}
flowchart LR
P1["프로듀서 1<br/>WriteAsync"] --> CH["bounded 채널(용량 100)<br/>FIFO 큐<br/>동기화는 채널이 관리"]
P2["프로듀서 2<br/>WriteAsync"] --> CH
CH --> C1["컨슈머 1<br/>ReadAllAsync"]
CH --> C2["컨슈머 2<br/>ReadAllAsync"]
CH -.->|"가득이면 쓰기를 기다리게 한다<br/>(배압)"| P1
CH -.->|"비면 읽기를 기다리게 한다"| C1
그림 5: 채널을 끼운 프로듀서/컨슈머 구성. 양쪽은 공유 변수를 직접 만지지 않고, 대기와 용량 제어도 채널에 맡깁니다
실무에서 중요한 것은 용량에 상한이 있는(bounded) 채널을 고르는 것입니다. 상한에 도달했을 때의 기본 동작은 「쓰는 쪽이 빈자리를 기다린다」이며, 이것이 자연스러운 배압(backpressure)이 됩니다. 생산이 소비보다 빠른 구성에서 무제한 큐를 쓰면, 돌아가기는 하지만 메모리가 계속 늘어나는 시한폭탄이 됩니다.5
동기 세계에서 bounded 채널과 같은 역할을 하는 것은, 용량을 지정한 BlockingCollection<T>입니다. 용량 제한으로 생산 측이 소비 측을 너무 앞지르지 못하게 하고, 비었을 때는 소비 측을 블록해 기다리게 하는, 블로킹과 용량 제어를 갖춥니다.12 한편 ConcurrentQueue<T> / ConcurrentStack<T>는 락을 쓰지 않고 Interlocked 연산만으로 스레드 세이프를 실현한 고속 컬렉션이지만6, 용량 제한도 「비면 기다린다」는 장치도 없는, 그대로의 스레드 세이프 큐입니다. 일 전달의 주역이 아니라 부품으로 생각하십시오. 참고로 BlockingCollection<T>는 비동기 접근을 상정한 설계가 아니므로, async/await와 조합한다면 Channel<T>를 고릅니다.12
아울러 「사전을 ConcurrentDictionary로 바꿨으니 스레드 세이프」라는 착각에는 주의가 필요합니다. 개별 조작이 스레드 세이프여도, 「존재 확인 후 추가」 같은 복합 조작은 여전히 경합합니다(GetOrAdd처럼 복합 조작용 메서드를 씁니다). 그 GetOrAdd에도 함정이 있어, 저장되는 값은 하나로 정해지는 한편, 값을 만드는 팩토리 함수는 경합 시 여러 번 호출될 수 있습니다. 팩토리에 부작용(연결을 연다, 파일을 만든다 등)을 넣으면 중복 실행으로 누수가 생기므로, 부작용 없는 함수로 하거나, 반드시 한 번으로 하고 싶은 초기화는 Lazy<T>를 값으로 넣는 형태로 합니다. 컬렉션 형식을 바꾸는 것은, 공유 가변 상태를 줄이는 일의 대체가 되지 않습니다.
5. 원칙 3: 락에는 규율을 둔다
공유 가변 상태를 줄여도, 영이 되지 않는 경우가 많습니다. 남은 공유에는 배타 제어(락)를 쓰지만, 락은 「일단 수상한 곳을 lock으로 감싸는」 도구가 아닙니다. 규율은 네 가지입니다.
5.1. 「무엇을 지킬지」를 정하고, 전용 객체로 락한다
락의 단위는 「코드 구간」이 아니라 「데이터」로 생각합니다. 지키고 싶은 가변 데이터 집합마다 락 객체를 하나 대응시키고, 그 데이터를 만지는 모든 곳에서 같은 락을 잡는다 ── 이 대응표가 무너진 것이 경합 버그의 실태입니다.
락 대상 객체는, 외부에 공개하지 않는 전용 인스턴스로 합니다. lock(this)는 자기 인스턴스를 참조할 수 있는 외부 코드와, lock(typeof(X))는 애플리케이션 도메인 전체와, 각각 락을 공유해 버려 데드락의 온상이 됩니다. .NET 9 / C# 13 이후에는 전용 형식 System.Threading.Lock의 인스턴스를 락 객체로 쓰는 것이 권장입니다.4
public class OrderBook
{
private readonly Lock _gate = new(); // .NET 9+(그 이전은 readonly object)
private readonly List<Order> _orders = []; // _gate가 지키는 데이터
public void Add(Order order)
{
lock (_gate) { _orders.Add(order); }
}
}
C#의 lock 문은, 예외가 나도 락을 확실히 해제하는 것을 보장합니다. 펼쳐지는 방식은 락 객체 형식에 따라 달라, 보통 객체라면 finally에서 Monitor.Exit를 호출하는 형태가 되고, Lock 형식이라면 EnterScope()와 그 폐기를 쓰는 형태가 됩니다.413 즉 Lock 형식 필드는 Monitor와는 다른 장치이므로, 일부 코드만 Monitor.Enter(_gate)를 손으로 쓰면 lock (_gate)와의 상호 배타가 성립하지 않습니다. 어느 형식이든, Monitor.Enter / Exit의 손 쓰기는 그만두고 항상 lock 구문으로 통일하는 것이 안전합니다.4
5.2. 락 중에 「시간이 걸리는 일」「외부의 일」을 하지 않는다
락을 들고 있는 시간은 짧을수록 좋고, 락 중에 해도 되는 것은 지키고 있는 데이터의 읽기·쓰기뿐입니다. 락을 유지한 채 I/O를 하거나, 이벤트나 콜백으로 외부 코드를 호출하는 방식은, 유지 시간을 늘릴 뿐 아니라, 호출된 쪽이 다른 락을 잡으려다 데드락하는 경로를 만듭니다. 락 밖에서 준비하고, 락 안에서는 교체만, 이 기본형입니다.
참고로 lock 안에서 await할 수는 없습니다(컴파일 오류가 됩니다). 이것은 제한이 아니라 보호이며, Monitor는 락을 잡은 스레드가 해제해야 하는 스레드 친화성을 가지므로, await 전후로 스레드가 바뀔 수 있는 비동기 코드와는 양립하지 않습니다. 비동기 코드에서의 배타에는 SemaphoreSlim을 초기 카운트 1로 씁니다.14
private readonly SemaphoreSlim _asyncGate = new(1, 1);
public async Task SaveAsync(Data data, CancellationToken ct)
{
await _asyncGate.WaitAsync(ct);
try { await WriteToFileAsync(data, ct); }
finally { _asyncGate.Release(); }
}
5.3. 여러 락은 항상 같은 순서로
락이 둘 이상일 때, 획득 순서가 스레드마다 바뀌는 것이 데드락의 고전적 패턴입니다. 대책은 단순해서, 모든 스레드가 같은 순서로 락을 잡는다는 것을 규칙으로 둡니다. 순서를 보장할 수 없는 곳에는 Monitor.TryEnter의 타임아웃 있는 오버로드를 쓰고, 잡지 못하면 놓고 다시 하거나(또는 이상을 기록하거나) 해서, 영원한 행을 검출 가능한 실패로 바꿉니다.4
5.4. 단순한 갱신은 Interlocked, 읽기가 많으면 ReaderWriterLockSlim
카운터 증감이나 플래그 교체처럼 단일 변수의 원자적 갱신은, lock보다 Interlocked 클래스(Increment / Add / CompareExchange)가 빠릅니다. 경합이 없으면 단일 CPU 명령 접두사로 끝납니다.4 반대로, Interlocked로 할 수 있는 것은 거기까지이며, 여러 변수를 한데 맞춰 일관되게 유지하는 용도에는 쓸 수 없습니다. volatile과 조합한 자체 락 프리 구조는, 메모리 모델의 깊은 이해를 필요로 하는 상급자 도구이며, 업무 앱에서 쓸 것이 아닙니다.
「읽기는 잦지만 쓰기는 드물다」는 공유 데이터에는, 쓰기만 배타하고 읽기는 동시에 통과시키는 ReaderWriterLockSlim이라는 선택도 있습니다.13
6. 원칙 4: 멈추는 방법을 처음에 설계한다
멀티스레드 설계 리뷰에서 먼저 물어야 할 질문은 「이것은 어떻게 멈춥니까」입니다. 움직이기 시작하는 코드는 쓸 수 있어도, 안전하게 멈추는 코드는 설계하지 않으면 생기지 않습니다.
6.1. 협력적 취소(CancellationToken)가 유일한 정답
.NET의 정지 모델은 협력적 취소로 통일되어 있습니다. 멈추는 쪽이 CancellationTokenSource를 만들고, 그 Token을 각 처리에 넘긴다. 멈추고 싶어지면 Cancel()을 호출한다. 처리 측은 토큰을 감시해, 자기 쪽의 끊기 좋은 지점에서 뒷정리를 하고 끝낸다 ── 강제가 아니라 협력이므로, 처리 측은 일관된 상태를 유지한 채 종료할 수 있습니다.7
private CancellationTokenSource? _cts;
private Task? _worker;
public void Start()
{
if (_worker is { IsCompleted: false }) // 돌아가는 동안의 이중 Start를 거부한다
throw new InvalidOperationException("워커는 이미 실행 중입니다.");
if (_worker is { IsFaulted: true }) // 이전 실패를 삼킨 채로 다시 만들지 않는다
throw new InvalidOperationException("이전 워커가 실패했습니다.", _worker.Exception);
_cts = new CancellationTokenSource();
var token = _cts.Token; // 정지 후 재Start와 경합하지 않도록, 먼저 로컬에 붙잡는다
_worker = Task.Run(() => WorkLoop(token), token);
}
private void WorkLoop(CancellationToken ct)
{
while (!ct.IsCancellationRequested) // 폴링으로 감시
{
ProcessNextItem(ct); // 블록하는 호출에는 ct를 넘겨 즉시 중단
}
}
public async Task StopAsync()
{
var cts = _cts; // 기다리는 동안 필드가 바뀌어도,
var worker = _worker; // 멈출 대상을 잘못 잡지 않도록 로컬에 고정한다
if (cts is null || worker is null) return;
Exception? cancelFailure = null;
try { cts.Cancel(); } // 토큰에 등록된 콜백이 예외를 던질 수 있다
catch (Exception ex) { cancelFailure = ex; } // 잡아 두고, 합류를 마친 뒤에 보고한다
try
{
try { await worker; } // Cancel 성패와 관계없이 합류는 반드시 하고, 중간 실패도 관측한다
catch (OperationCanceledException ex) when (ex.CancellationToken == cts.Token)
{ } // 이쪽이 요구한 정지만이 「정상」이 되게 한다
catch (Exception ex) when (cancelFailure is not null)
{
throw new AggregateException(cancelFailure, ex); // 양쪽 실패를 어느 쪽도 잃지 않는다
}
}
finally
{
cts.Dispose(); // 합류가 끝난 소스는 폐기한다(WaitHandle 등 OS 자원 해제).
if (ReferenceEquals(_cts, cts))
{
_cts = null; // 폐기된 소스를 이후 StopAsync에 쓰게 하지 않는다
_worker = null;
}
}
if (cancelFailure is not null)
throw new AggregateException(cancelFailure);
}
참고로, 이 Start / StopAsync는 같은 스레드(UI 스레드 등)에서 차례로 호출된다는 전제의 최소 구성입니다. 여러 스레드가 동시에 수명 주기를 조작할 수 있다면, Start / StopAsync 자체를 SemaphoreSlim 등으로 직렬화하십시오 ── 워커를 지키기 전에, 워커의 관리 조작 자체가 경합해서는 본말이 전도됩니다.
이 작은 샘플에도, 실무에서 효과가 있는 장치를 넣어 두었습니다. 먼저 Start는 실행 중의 이중 호출을 거부합니다. 무조건 _cts와 _worker를 덮어쓰면, 앞 워커로의 참조가 사라지고, 멈추지도 합류하지도 못하는 「미아 스레드」가 병행합니다. 수명 주기 API(Start/Stop)는 「동시에 하나」를 스스로 지키게 하는 것이 기본입니다. 여기에 세 가지를 더합니다. 첫째, 정지 API는 완료를 기다립니다. Cancel()은 취소를 「요구」할 뿐이고, 돌아온 순간에는 워커가 아직 ProcessNextItem 중간일 수 있습니다. 요구만 하고 돌아오는 Stop()으로 하면, 호출 측이 뒷정리를 시작했을 때 워커가 아직 돌아가는, 새로운 경합을 만듭니다. 둘째, Task를 버리지 않고 유지합니다. _ = Task.Run(...)처럼 던져 버리면, 워커가 예외로 죽어도 아무도 모릅니다. 셋째, 토큰은 _cts.Token을 람다 안에서 참조하는 것이 아니라 로컬 변수에 붙잡은 뒤 넘깁니다. 람다 안에서 참조하면 평가되는 것은 실행 시이며, 정지 직후 재Start된 경우 옛 워커가 새 토큰을 잡는 혼선이 납니다. 아울러 그 토큰을 Task.Run의 두 번째 인수에도 넘겨 두면, 처리 측이 ThrowIfCancellationRequested나 취소 대응 API의 OperationCanceledException으로 끝난 경우 Task가 「실패(Faulted)」가 아니라 「취소(Canceled)」로 분류됩니다(이 예처럼 루프 조건으로 평범하게 빠진 경우는 정상 완료로 취급합니다). 하나 더, StopAsync의 catch는 when 필터로 자기 토큰에서 온 취소만 삼킵니다. OperationCanceledException을 무조건 삼키면, 처리 내부의 다른 토큰(요소마다 타임아웃 등)이 던진 진짜 실패까지 「정지했으니 정상」으로 보이게 되기 때문입니다. 참고로, 이 토큰 일치로 식별하는 방식은 WorkLoop 내부에서 링크 토큰(6.1의 링크 합성)을 쓰면 무너집니다. 링크 쪽 토큰을 실은 예외가 날아오기 때문입니다. 그 구성에서는, WorkLoop 출구에서 ct.ThrowIfCancellationRequested()를 호출해 바깥 토큰으로 「번역」한 뒤 빠지거나, 필터를 when (cts.IsCancellationRequested)로 느슨하게 해 「정지 요구 중의 취소는 정상」으로 받아들이거나, 어느 쪽을 설계로서 명시적으로 고르십시오.
flowchart TB
OWNER["멈추는 쪽"] -->|"Cancel()을 한 번 호출"| CTS["CancellationTokenSource"]
CTS -->|"Token을 넘긴다"| W1["워커 처리 1"]
CTS -->|"Token을 넘긴다"| W2["워커 처리 2"]
CTS -->|"Token을 넘긴다"| W3["라이브러리의<br/>취소 대응 API"]
W1 -->|"IsCancellationRequested를 확인<br/>뒷정리하고 스스로 끝난다"| E1["정상 종료"]
W2 -->|"ThrowIfCancellationRequested"| E2["OperationCanceledException<br/>= 취소 완료로 취급된다"]
W3 -->|"대기 중이어도 즉시 중단"| E3["취소 완료"]
그림 6: 협력적 취소의 구조. 멈추는 쪽은 Cancel()을 호출할 뿐이고, 「언제·어떻게 끝날지」는 각 처리가 스스로 정합니다. 그래서 일관된 상태를 유지한 채 멈출 수 있습니다
라이브러리 측 관례도 정해져 있습니다. 취소 가능한 조작은 CancellationToken을 받는 공개 메서드를 제공하고, 계산 루프에서는 주기적으로 IsCancellationRequested를 확인하거나 ThrowIfCancellationRequested()를 호출합니다. 후자는 OperationCanceledException을 보내고, Task는 이것을 「실패」가 아니라 「취소 완료」로 취급합니다. 외부에서 받은 토큰과 내부 사정(타임아웃 등) 둘 다로 멈추고 싶을 때는, 링크 토큰으로 합성합니다.7
6.2. Thread.Abort는 없는 것으로 생각한다
「말을 듣지 않는 스레드를 밖에서 죽인다」는 Thread.Abort는, .NET Core / .NET 5 이후에는 PlatformNotSupportedException을 던지기만 하고, 더 이상 쓸 수 없습니다. 상대가 어디를 실행 중인지 모르는 채로 스레드에 예외를 던져 넣는 행위는, 자원 해제의 중단이나 상태 파괴를 부르기 때문입니다. 협력적 취소에 응하지 않는(응하도록 쓸 수 없는) 서드파티 코드를 강제 종료해야 한다면, 별도 프로세스에서 실행하고 Process.Kill로 멈추는 것이 공식 지침입니다.8
6.3. 기다릴 때는 폴링이 아니라 대기 핸들로
「플래그가 설 때까지 Sleep(100) 루프로 기다린다」는 방식은, CPU와 응답성 둘 다를 낭비합니다. 스레드 사이 신호에는 ManualResetEventSlim이나 SemaphoreSlim 같은 동기화 프리미티브가 있어, 시그널 상태가 될 때까지 스레드를 올바르게 쉬게 할 수 있습니다.13 Windows에서 타이머 정밀도와 이벤트 대기의 구분은 「Windows에서 Sleep(1)보다 이벤트 대기를 우선해야 하는 이유」에서 자세히 다룹니다.
7. UI 스레드라는 특수 사정 ── Windows 데스크톱 앱의 규칙
Windows 데스크톱 앱에는, 범용 원칙에 더해 또 하나 강한 제약이 있습니다. UI는 그것을 만든 스레드(UI 스레드)만 만질 수 있다는 규칙입니다.
WinForms 컨트롤은 스레드 세이프가 아니며, 여러 스레드에서 조작하면 컨트롤이 불일치 상태로 몰려 경합·데드락·프리즈의 원인이 됩니다. Windows는 앱에 대해, 시스템 메시지를 받는 전용 스레드를 하나 마련할 것을 요구하며, UI의 작성과 조작은 그 스레드에 모아져 있어야 합니다.9 WPF도 완전히 같은 구조로, UI 요소를 바꿀 수 있는 것은 UI 스레드뿐입니다.10
다른 스레드에서 UI를 갱신하고 싶을 때는, 직접 만지지 말고 「UI 스레드로의 요청」으로 바꿉니다.
flowchart LR
OS["Windows<br/>마우스·키보드·다시 그리기"] --> Q["UI 스레드의<br/>메시지 큐"]
BG["백그라운드 스레드<br/>(무거운 처리·통신)"] -->|"Control.Invoke /<br/>Dispatcher.InvokeAsync로 요청"| Q
Q --> UI["UI 스레드<br/>컨트롤을 만질 수 있는 유일한 스레드"]
BG -.->|"컨트롤을 직접 만진다"| NG["금지<br/>경합·데드락·프리즈의 원인"]
그림 7: UI 갱신은 「요청」으로 바꿉니다. 백그라운드 스레드의 일은 메시지 큐에 실어 달라고 하는 것까지이고, 컨트롤을 만지는 것은 항상 UI 스레드 자신입니다
| 프레임워크 | 요청 수단 |
|---|---|
| WinForms | Control.Invoke(동기)/ Control.BeginInvoke(비동기)/ .NET 9 이후는 Control.InvokeAsync9 |
| WPF | Dispatcher.Invoke(동기)/ Dispatcher.InvokeAsync / Dispatcher.BeginInvoke(비동기)10 |
이 중 동기형(Control.Invoke / Dispatcher.Invoke)에는 주의가 필요합니다. UI 스레드가 그 워커의 완료를 동기적으로 기다리는 상태에서 워커가 Invoke를 호출하면, 서로를 기다리는 데드락이 됩니다(2장의 순환 대기 그 자체입니다). 백그라운드에서의 알림·진행 보고는 비동기형(BeginInvoke / InvokeAsync)을 기본으로 하고, 동기형은 UI 스레드가 자기를 기다리지 않는다고 단언할 수 있는 장면에 한정하십시오.
실무에서는 한 단계 더 나은 답이 있습니다. UI 스레드에서 시작한 처리를 async/await로 쓰면, await가 UI 스레드의 SynchronizationContext를 포착하고, 후속 처리를 자동으로 UI 스레드에서 재개해 주므로, Invoke를 손으로 쓰는 장면 자체가 크게 줄어듭니다. 다만 이것은 무조건의 성질이 아닙니다. 백그라운드 콜백에서 들어온 코드나, ConfigureAwait(false)를 끼운 뒤의 계속 처리는 UI 스레드로 돌아가지 않으므로, 그 경로에서 UI를 만진다면 명시적 디스패치가 계속 필요합니다. 「무거운 처리는 Task.Run 또는 비동기 I/O로, 결과의 화면 반영은 await의 계속에서」라는 형태로 맞추는 것이 지금의 Windows 앱의 기본형입니다. UI 스레드와 async/await의 관계는 「WPF/WinForms의 async와 UI 스레드를 한 장으로 정리」에 한 장 그림으로 정리해 두었습니다.
또한 Office 연동이나 레거시 컴포넌트처럼 COM이 얽히는 경우에는, COM 고유의 스레드 모델(STA/MTA)이라는 다른 층이 더해집니다. 「UI 스레드에서 COM 객체를 만들었는데 다른 스레드에서 호출해 굳었다」는 사고는 이 층의 이야기이며, 「COM STA/MTA의 기초 지식」에서 설명합니다.
8. 네이티브(C++/C)로 쓸 때는
여기까지의 원칙 ── 스레드를 직접 만들지 않는다, 공유 가변 상태를 줄인다, 락의 규율, 멈추는 방법의 설계 ── 는, 네이티브 코드에서도 그대로 통합니다. 바뀌는 것은 도구입니다. C++라면 RAII와 std::jthread / std::mutex / std::atomic, C라면 Win32 API의 _beginthreadex·SRW 락·조건 변수·정지 이벤트 패턴이 각각 대응하는 도구가 됩니다. 각각 이 시리즈의 「C++ 편」「C 언어 편」에서, 언어 고유의 함정(std::thread의 소멸자, TerminateThread의 위험성, DllMain과 로더 락 등)까지 포함해 다룹니다.
9. 검증과 디버그 ── 「재현되지 않는다」는 전제로 대비한다
멀티스레드 버그는 테스트에서 발견되기를 기대할 수 없습니다. 보통의 단위 테스트는 「우연히 경합하지 않은」 실행을 성공으로 세기 때문입니다. 대비는 세 층으로 생각합니다.
첫 번째 방어선은, 여기까지의 설계 원칙 그 자체입니다. 공유 가변 상태가 5개인 앱과 50개인 앱에서는, 의심할 장소의 수가 10배 다릅니다. 리뷰에서는 「공유 중인 가변 데이터는 무엇인가」「각각 어느 락이 지키는가」「락 획득 순서는 유일한가」「정지 경로는 어디인가」를 표로 확인합니다. 이 표를 쓸 수 없는 설계는, 돌아가고 있어도 아직 완성되지 않은 것입니다.
둘째, 이상을 숨기지 않고 관측 가능하게 합니다. Monitor.TryEnter의 타임아웃으로 락 대기 이상을 검출해 로그에 남기고4, 스레드 풀에 던진 처리의 미관측 예외를 삼키지 않고 기록하며, 행 시에는 풀 덤프를 채취해 모든 스레드의 스택을 확인할 수 있게 해 둔다 ── 「가끔만 나는」 버그와의 싸움은, 난 한 번에서 얼마나 정보를 가져오느냐로 정해집니다. 덤프와 로그 정비는 「Windows 앱 크래시 시 로그와 덤프를 남기는 설계」에서 다룹니다.
셋째, 부하를 걸어 흔듭니다. 코어 수보다 많은 병렬도로 장시간 돌리고, 처리 순서를 무작위화하고, 지연을 인공적으로 넣는 등, 개발 머신에서 인터리브의 당첨을 쉽게 뽑게 하는 스트레스 테스트는, 출하 전에 경합을 드러내는 현실적인 수단입니다. 디버그 실행에서 사라지는 버그도, 릴리스 빌드+고부하라면 재현되는 일이 흔합니다.
10. 정리 ── 스레드를 늘리기 전의 체크리스트
멀티스레드 프로그래밍의 베스트 프랙티스는, 따져 보면 「동기화를 올바르게 쓰는 기술」이 아니라 「동기화를 쓰지 않아도 되는 설계」입니다. 착수 전에 다음 8문에 답할 수 있으면, 큰 사고는 거의 막을 수 있습니다.
- 그 처리는 CPU 바운드인가, I/O 바운드인가(후자라면 스레드가 아니라 async/await가 답)
new Thread를 쓰려 하지 않는가(Task·Parallel·스레드 풀로 표현할 수 없는가)- 스레드 사이에서 공유되는 가변 데이터는 무엇인가, 열거할 수 있는가
- 그 공유는 「분할」「불변화」「큐로 전달」로 지울 수 없는가
- 남은 공유 데이터 각각에, 대응하는 락이 하나 정해져 있는가
- 락 획득 순서는 모든 스레드에서 유일한가, 락 중에 외부 호출을 하지 않는가
CancellationToken은 모든 장시간 처리에 넘어가 있는가, 정지 경로를 설명할 수 있는가- UI를 만지는 코드는 UI 스레드에 모아져 있는가
멀티스레드 버그는, 쓴 날에는 나타나지 않고, 잊을 무렵 고객사에서 갑자기 터집니다. 거꾸로 말하면, 설계 단계에서 이 체크리스트를 통과해 두면, 「가끔 죽는다」「한 달에 한 번 멈춘다」는 가장 값이 비싼 종류의 장애를, 코드를 쓰기 전에 없앨 수 있다는 뜻입니다.
관련 글
- 멀티스레드 실무 베스트 프랙티스 C++ 편
- 멀티스레드 실무 베스트 프랙티스 C 언어 편
- 멀티스레드 실무 베스트 프랙티스 Java 편
- C# async/await 실무 판단표 - Task.Run과 ConfigureAwait
- WPF/WinForms의 async와 UI 스레드를 한 장으로 정리
- Windows I/O의 심층(제3회) ── I/O 완료 포트(IOCP)와 .NET 스레드 풀
- COM STA/MTA의 기초 지식 - 스레드 모델과 행(hang)을 피하는 사고방식
- Windows에서 Sleep(1)보다 이벤트 대기를 우선해야 하는 이유
- 공유 메모리의 함정과 실무 베스트 프랙티스
관련 상담 영역
합동회사 코무라소프트에서는, 멀티스레드화를 포함한 업무 앱 설계 리뷰, 「가끔 죽거나 멈춘다」처럼 재현성이 낮은 결함의 원인 조사(덤프 분석·경합 지점 특정), 기존 앱의 병렬화·비동기화 기술 상담을 다룹니다. 「이 설계에서 경합이 나지 않는지 봐 달라」는 단계부터여도 됩니다.
참고 링크
-
Microsoft Learn, Task Parallel Library (TPL). TPL이 .NET Framework 4 이후의 멀티스레드·병렬 코드의 권장 수단이라는 것, 병렬도를 이용 가능한 프로세서에 맞춰 동적으로 조정한다는 것, 일의 분할·스레드 풀로의 스케줄링·취소 대응·상태 관리를 떠맡는다는 것, 한 반복의 일이 작은 루프에서는 병렬화 오버헤드로 느려질 수 있다는 것, TPL을 쓰는 데도 락·데드락·경합 상태의 기본 이해가 권장된다는 것에 대해. ↩ ↩2
-
Microsoft Learn, The managed thread pool. ThreadPool 클래스가 시스템 관리 워커 스레드의 풀을 제공해, 개발자가 스레드 관리가 아니라 앱의 작업에 집중할 수 있다는 것, .NET이 TPL 조작·비동기 I/O 완료·타이머 콜백·등록된 대기·소켓 연결 등 폭넓게 스레드 풀을 쓴다는 것에 대해. ↩ ↩2
-
Microsoft Learn, Potential Pitfalls in Data and Task Parallelism. 병렬 루프가 순차보다 느려지는 경우가 있어 반드시 측정해야 한다는 것, 병렬 루프 안의 공유 메모리 쓰기를 피해야 하며 스레드 로컬 상태를 쓰는 오버로드가 권장된다는 것, For/ForEach의 각 반복이 병렬 실행된다는 보장은 없고 반복 사이에서 기다리는 코드가 데드락할 수 있다는 것에 대해. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, Managed threading best practices. 경합 상태(카운터 증가가 읽기·더하기·다시 쓰기로 분해되어 덮어쓰기로 사라지는 예)와 데드락의 정의, Thread.Abort를 쓰지 않고 협력적 취소를 써야 한다는 것, 형식이나 this를 락 대상으로 삼아서는 안 되며 .NET 9/C# 13 이후에는 전용 System.Threading.Lock 인스턴스를 쓴다는 것, C#의 lock 문이 finally에서 Monitor.Exit를 보장한다는 것, Monitor.TryEnter의 타임아웃에 의한 데드락 검출, 단순한 상태 변경에는 Interlocked 클래스가 빠르다는 것, 정적 데이터는 기본으로 스레드 세이프하게·인스턴스 데이터는 기본으로 스레드 세이프하지 않게라는 설계 지침에 대해. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10
-
Microsoft Learn, System.Threading.Channels library. 채널이 프로듀서/컨슈머 모델의 FIFO로 동기화를 내부 관리한다는 것, CreateBounded로 용량 상한 있는 채널을 만들 수 있다는 것, 상한 도달 시 기본 동작이 쓰는 쪽의 대기(Wait)이며 DropOldest 등 FullMode도 고를 수 있다는 것, 쓰기가 읽기보다 빠를 때 배압이 걸린다는 것에 대해. ↩ ↩2 ↩3
-
Microsoft Learn, Thread-safe collections. System.Collections.Concurrent 아래 컬렉션이 세분화된 락이나 락 프리 기구로 스레드 세이프를 실현한다는 것, ConcurrentQueue와 ConcurrentStack은 락을 쓰지 않고 Interlocked 연산으로 구현되어 여러 스레드에서의 고빈도 추가·삭제에 견딘다는 것에 대해. ↩ ↩2
-
Microsoft Learn, Cancellation in Managed Threads. CancellationTokenSource와 CancellationToken에 의한 협력적 취소 모델의 절차, 취소가 강제가 아니라 협력이며 정지 방법은 리스너 측이 정한다는 것, 폴링·콜백 등록·대기 핸들의 세 감시 수단, ThrowIfCancellationRequested에 의한 OperationCanceledException 발생을 Task가 취소 완료로 취급한다는 것, 링크 토큰에 의한 여러 토큰의 합성, 라이브러리는 CancellationToken을 받는 공개 메서드를 제공해야 한다는 것에 대해. ↩ ↩2 ↩3
-
Microsoft Learn, Using threads and threading. 스레드 정지에는 CancellationToken을 써야 한다는 것, .NET Core 및 .NET 5 이후에는 Thread.Abort가 PlatformNotSupportedException을 던지고, .NET 5 이후는 컴파일 시에도 사용 중단 경고(SYSLIB0006)가 된다는 것, 협력적 취소에 응하지 않는 서드파티 코드를 강제 종료하려면 별도 프로세스에서 실행해 Process.Kill을 써야 한다는 것에 대해. ↩ ↩2
-
Microsoft Learn, How to handle cross-thread operations with controls. WinForms 컨트롤 접근이 스레드 세이프가 아니며, 여러 스레드에서의 조작이 불일치 상태·경합·데드락·프리즈를 부른다는 것, 모든 컨트롤은 동일 스레드에서 작성·접근되어야 하고, Windows가 시스템 메시지를 배송하는 전용 UI 스레드를 요구한다는 것, 다른 스레드에서는 Control.Invoke, .NET 9 이후의 Control.InvokeAsync, 또는 BackgroundWorker로 안전하게 호출한다는 것에 대해. ↩ ↩2 ↩3
-
Microsoft Learn, Threading model (WPF). WPF에서는 UI를 바꿀 수 있는 것이 하나의 스레드로 한정되며, 백그라운드 스레드는 UI 스레드의 Dispatcher에 작업 항목을 등록해 요청한다는 것, Dispatcher.Invoke가 동기·InvokeAsync와 BeginInvoke가 비동기라는 것, Dispatcher가 우선순위 있는 큐로 작업을 처리한다는 것에 대해. ↩ ↩2 ↩3
-
Microsoft Learn, Data Parallelism (Task Parallel Library). Parallel.For / Parallel.ForEach가 for 루프와 거의 같은 쓰기로 데이터 병렬을 제공한다는 것, 스레드 작성이나 워크 아이템 큐잉이 필요 없고 기본적인 루프에서는 락도 필요 없다는 것, TPL이 데이터 소스를 분할해 여러 스레드에서 처리하고 부하가 치우치면 재배분한다는 것에 대해. ↩
-
Microsoft Learn, BlockingCollection<T> Class. BlockingCollection이 블로킹과 용량 제한을 갖춘 프로듀서/컨슈머 구현이라는 것, 용량 제한으로 생산 측이 소비 측을 너무 앞지르지 못하게 한다는 것, 비동기 접근을 상정한 설계가 아니어서 비동기 프로듀서/컨슈머에는 Channel<T> 사용을 검토해야 한다고 되어 있다는 것에 대해. ↩ ↩2
-
Microsoft Learn, Overview of synchronization primitives. Monitor가 락 대상 객체를 통한 상호 배타를 제공하고 스레드 친화성을 가진다는 것, C#에서는 Monitor 직접이 아니라 lock 문을 써야 한다는 것, ReaderWriterLockSlim이 쓰기는 배타·읽기는 동시 접근 가능으로 한다는 것, SemaphoreSlim이 프로세스 내 전용 경량 세마포이며 Semaphore는 이름 있는 형태로 프로세스 간 동기화에 쓸 수 있다는 것에 대해. ↩ ↩2 ↩3
-
Microsoft Learn, Async semaphores, locks, and reader/writer coordination. C#의 lock 문과 Lock 형식이 스레드 친화성을 가지므로 await를 건너 쓸 수 없다는 것(await 전후로 계속을 실행하는 스레드가 바뀔 수 있으므로), 비동기 코드에서의 상호 배타에는 카운트 1의 SemaphoreSlim을 WaitAsync와 try/finally의 Release로 쓴다는 것, 스로틀링 용도에서는 bounded한 Channel이 대안이 된다는 것에 대해. ↩
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
멀티스레드 실무 베스트 프랙티스 Java 편 ── 가상 스레드 시대의 정석
Java 멀티스레드는 스레드를 직접 만들지 않고 ExecutorService와 가상 스레드에 맡기는 것이 정석입니다. synchronized와 ReentrantLock의 구분, interrupt를 통한 협력적 중단, ConcurrentHashMa...
멀티스레드 실무 베스트 프랙티스 C 언어 편 ── Win32 API의 방식으로 안전하게 작성하기
C 언어 × Win32의 멀티스레드는 _beginthreadex로 스레드를 만들고, SRW 락과 조건 변수, Interlocked, 정지 이벤트+WaitForMultipleObjects로 정지를 설계하는 것이 정석입니다. TerminateThre...
멀티스레드 실무 베스트 프랙티스 C++ 편 ── RAII와 jthread로 사고를 구조적으로 없애기
C++ 멀티스레드는 data race가 곧 undefined behavior가 되는 세계입니다. std::thread 소멸자의 함정, jthread와 stop_token으로 멈추는 설계, scoped_lock의 deadlock 회피, atomic...
WMI/CIM을 C#·PowerShell에서 쓰기 ── 하드웨어 정보 가져오기·프로세스 모니터링·원격 조회의 실무 가이드
PC 시리얼 번호 조회, 디스크 여유 공간 모니터링, 프로세스 시작 감지의 흔한 답이 WMI/CIM입니다. Get-CimInstance 등 CIM cmdlet 사용법과 구 Get-WmiObject에서의 이전, C#의 System.Managemen...
Windows 세션 분리를 어떻게 이해할 것인가 ── Session 0·RDP·다중 사용자 동시 실행
Windows 앱 개발자가 혼동하기 쉬운 「세션」 개념을 정리합니다. 서비스가 UI를 띄울 수 없는 Session 0 분리의 이유, RDP 연결 시 세션의 동작, named object의 세션 분리, 공유 PC·RDS 환경에서 자주 나오는 설계 ...
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
상주 처리, 장비 연동, 운영 로그, 유지 보수 가능한 구조가 필요한 Windows 데스크톱 애플리케이션을 지원합니다.
기술 상담 & 설계 리뷰
설계 방향, 아키텍처 경계, 수명 관리, 기존 Windows 자산 처리 방법을 정리하는 데 도움을 드립니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- lock(this)나 lock(typeof(MyClass))는 왜 피해야 하나요?
- 락 대상 객체가 자기 코드 밖에서도 보이기 때문입니다. this는 자기 인스턴스 그 자체이므로, 그 인스턴스를 참조할 수 있는 외부 코드가 같은 객체를 락할 수 있어 의도하지 않은 경합이나 데드락의 원인이 됩니다. typeof(MyClass)는 더 위험합니다. Type 객체는 애플리케이션 도메인 안에 하나뿐이므로, 전혀 무관한 코드와 락을 공유하게 됩니다. 락 대상에는 외부에 공개하지 않는 전용 객체를 쓰십시오. .NET 9 / C# 13 이후에는 전용 System.Threading.Lock 형식의 인스턴스를 락 객체로 쓰는 것이 권장됩니다.
- 스레드는 몇 개까지 만들어도 되나요? 최적의 스레드 수는?
- 「스스로 스레드 수를 정하지 않는다」가 지금의 답입니다. Task나 Parallel 클래스를 쓰면 스레드 풀이 CPU 코어 수와 부하에 맞춰 병렬도를 자동 조정합니다. 직접 new Thread를 반복하는 설계는 코어 수가 다른 고객사 머신에서 과다·과소가 되기 쉽습니다. 기준으로 의식할 것은 개수가 아니라 일의 종류입니다. CPU를 다 쓰는 계산은 코어 수 이상으로 병렬화해도 빨라지지 않고, I/O 대기가 주체인 처리는 애초에 스레드를 늘릴 일이 아니라 async/await의 비동기 I/O로 하는 것이 정답입니다.
- volatile을 붙이면 스레드 안전해지나요?
- 되지 않습니다. volatile이 보장하는 것은 그 필드 접근이 앞뒤 메모리 연산과 재정렬되지 않는다는 순서 부여(획득/해제 시맨틱스)이지, 「읽고, 계산하고, 다시 쓴다」는 복합 연산의 원자성이 아닙니다. 예를 들어 volatile int 카운터에 여러 스레드에서 ++를 해도 증분은 사라집니다. 카운터 증감이나 비교 후 교체에는 Interlocked 클래스를, 여러 변수를 한데 지켜야 하면 lock을 쓰십시오. volatile이 검토 대상이 되는 것은 정지 플래그처럼 「한 스레드가 쓰고, 다른 스레드는 읽기만 하는」 단순한 경우에 거의 한정되며, 그 플래그도 지금은 CancellationToken으로 나타내는 것이 표준입니다.
- 가끔만 일어나는 결함이 멀티스레드 원인인지 어떻게 가려냅니까?
- 의심할 징후는 「같은 조작이라도 재현되기도 하고 아니기도 한다」, 「디버거를 붙이거나 로그를 넣으면 재현되지 않는다」, 「부하가 높을 때나 시작 직후에만 난다」의 세 가지입니다. 타이밍 의존 버그는 실행마다 결과가 바뀌는 것이 특징이고, 이것이 경합 상태의 정의 그 자체입니다. 원인 분리는 먼저 공유 중인 가변 데이터를 모두 찾아, 각각 「어느 락이 지키는지」를 표로 만듭니다. 지켜지지 않는 접근이 한 곳이라도 있으면 용의자입니다. 행(hang)이면 모든 스레드의 스택을 얻어, 서로의 락 대기로 순환하지 않는지 확인합니다. Visual Studio 디버거에서 일시 정지해 「병렬 스택」을 보거나, 프로덕션이면 덤프를 채취해 분석합니다.