「처리가 느려서 스레드를 만들어 병렬로 했더니 가끔 집계 결과가 어긋난다」, 「백그라운드 처리를 추가했더니 한 달에 한 번 앱이 멈추게 됐다」, 「디버그 실행에서는 재현되지 않는다고 해도 고객사 현장에서는 분명히 일어나고 있다」── 멀티스레드 프로그래밍의 무서운 점은, 작성한 직후에는 올바르게 동작하는 것처럼 보인다는 것입니다. 경합 버그는 타이밍에 의존하기 때문에 테스트를 빠져나가 프로덕션에서만 얼굴을 내밉니다.
한편 멀티코어가 당연해진 지금은 업무 앱에서도 「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 - 「병렬로 하면 빠르다」는 보장이 아닙니다. 1회당 작업이 작은 루프는 병렬화 오버헤드 때문에 오히려 느려집니다. 반드시 측정한 다음 채택합니다.3
2. 멀티스레드는 왜 어려운가 ── 경합 상태와 데드락
멀티스레드가 끌어들이는 문제는 근본적으로 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));
// 포인트는 2가지: 호출 측의 토큰을 ParallelOptions에 연결할 것
// (이를 잊으면 본체의 ct는 항상 None이 된다)과, 그 ct를 본체에도 전달할 것(버리지 말 것)
주의할 점이 하나 있습니다. Task.WhenAll(items.Select(...))는 열거한 시점에 모든 요소의 처리를 일제히 시작합니다. 수 건에서 수십 건 정도의 정해진 작업이라면 문제없지만, 건수가 많은 컬렉션에 사용하면 소켓・DB 연결・메모리를 한 번에 다 써버립니다. 건수를 가늠할 수 없는 처리는 위의 Parallel.ForEachAsync처럼 동시 실행 수에 상한을 두거나, 뒤에서 설명할 bounded 채널로 흐름을 제어합니다.
직접 스레드를 만드는 것이 정당화되는 경우는 「전용 메시지 루프를 가진다」, 「스레드의 아파트먼트(STA)를 지정해야 한다」, 「앱이 살아있는 동안 계속 돈다」와 같이, 스레드 자체의 성질이 요구 사항이 되는 경우로 거의 한정됩니다.
3.2. 데이터 병렬은 Parallel.For / ForEach
「컬렉션의 각 요소에 같은 처리를 해서 전체를 빠르게 하고 싶다」는 데이터 병렬에는 루프를 직접 스레드에 나누는 것이 아니라 Parallel.For / Parallel.ForEach를 사용합니다. 데이터 소스의 분할(파티셔닝)과 부하 재분배는 TPL이 수행하며, 기본적인 루프라면 락도 필요 없습니다.11
다만 공식 문서가 명시하는 2가지 함정이 있습니다.3
- 병렬이 항상 빠르다고 생각하지 말 것. 반복 횟수가 적거나 1회 처리가 가벼운 루프는 병렬화 오버헤드가 본체를 웃돌아 느려집니다. 성능은 요인이 많으므로 반드시 측정해서 판단합니다.
- 반복끼리 서로 기다리게 하지 말 것.
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: 「스레드를 만들기」 전의 분기. 대부분의 업무 처리는 위 3가지 출구 중 하나로 떨어지며, new Thread에 도달하는 것은 예외적인 경우뿐이다
async/await의 실무 판단은 「C# async/await 실무 판단표」에서, 그 바탕에서 스레드 풀과 비동기 I/O가 어떻게 연결되는지는 「IOCP와 .NET 스레드 풀」에서 자세히 다루고 있습니다.
4. 원칙 2: 공유 가변 상태를 최소화한다
경합은 「여러 스레드」와 「공유된 가변 데이터」가 갖춰졌을 때만 일어납니다. 스레드 수는 요구 사항으로 정해지므로, 설계로 줄일 수 있는 것은 공유 쪽입니다. 수단은 3가지입니다.
4.1. 분할한다 ── 각 스레드가 자신의 데이터만 다룬다
가장 단순하면서도 강력한 방법은 데이터를 스레드별로 나눠 버리는 것입니다. 병렬 루프의 집계라면 공유 합계 변수에 매번 쓰는 대신, Parallel.For의 스레드 로컬 상태를 받는 오버로드를 사용해 각 스레드가 자신의 손안에서 소계를 만들고, 마지막에 딱 한 번만 합칩니다. 공유에 대한 쓰기가 「반복할 때마다」에서 「스레드마다 1회」로 줄어들어, 동기화 비용도 경합의 창(window)도 훨씬 작아집니다.3
long total = 0;
Parallel.For(0, items.Length,
() => 0L, // 스레드 로컬 초기값
(i, state, local) => local + Weigh(items[i]), // 각 반복은 자신의 local에만 더한다
local => Interlocked.Add(ref total, local)); // 합류는 스레드마다 1회
flowchart TB
SRC["데이터 배열 - 처리 대상"] --> T1["스레드 1<br/>담당분을 처리하고<br/>손안의 소계에만 더한다"]
SRC --> T2["스레드 2<br/>담당분을 처리하고<br/>손안의 소계에만 더한다"]
SRC --> T3["스레드 3<br/>담당분을 처리하고<br/>손안의 소계에만 더한다"]
T1 --> M["합류: Interlocked.Add로<br/>스레드마다 1회만 합계에 반영"]
T2 --> M
T3 --> M
그림 4: 스레드 로컬 집계. 처리 중에는 각 스레드가 자신의 데이터만 다루기 때문에 경합의 여지가 없고, 공유에 대한 쓰기는 합류 시 스레드마다 1회로 끝난다
4.2. 불변으로 만든다 ── 고쳐 쓰지 않는 것은 공유해도 된다
읽기만 이루어지는 데이터는 몇 개의 스레드가 동시에 읽어도 안전합니다. 설정값・마스터 데이터・계산 입력 등은 생성 후 고쳐 쓰지 않음(불변으로 만듦)으로써 동기화 없이 자유롭게 공유할 수 있습니다. 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 -.->|"가득 차면 쓰기를 기다리게 함 - 배압"| P1
CH -.->|"비어 있으면 읽기를 기다리게 함"| C1
그림 5: 채널을 사이에 둔 프로듀서/컨슈머 구성. 양쪽 모두 공유 변수를 직접 만지지 않고, 대기 동기화도 용량 제어도 채널에 맡긴다
실무에서 중요한 것은 용량에 상한이 있는(bounded) 채널을 선택하는 것입니다. 상한에 도달했을 때의 기본 동작은 「쓰기 측이 여유가 생길 때까지 기다린다」이며, 이것이 자연스러운 배압(백프레셔)이 됩니다. 생산이 소비보다 빠른 구성에서 무제한 큐를 사용하면, 동작은 하지만 메모리가 계속 늘어나는 시한폭탄이 됩니다.5
동기적인 세계에서 bounded 채널과 같은 역할을 하는 것은 용량을 지정한 BlockingCollection<T>입니다. 용량 제한으로 생산 측이 소비 측을 너무 앞지르는 것을 막고, 비어 있을 때는 소비 측을 블록해서 기다리게 하는 블로킹과 용량 제어를 갖추고 있습니다.12 한편 ConcurrentQueue<T> / ConcurrentStack<T>는 락을 사용하지 않고 Interlocked 연산만으로 스레드 안전성을 구현한 고속 컬렉션이지만6, 용량 제한도 「비면 기다린다」는 구조도 갖고 있지 않은, 순수한 스레드 안전 큐입니다. 작업 전달의 주역이 아니라 부품으로 생각하십시오. 또한 BlockingCollection<T>는 비동기 접근을 상정한 설계가 아니므로, async/await와 조합한다면 Channel<T>를 선택합니다.12
또한 「딕셔너리를 ConcurrentDictionary로 바꿨으니 스레드 안전」이라는 생각에는 주의가 필요합니다. 개별 연산이 스레드 안전하더라도, 「존재 확인 후 추가」와 같은 복합 연산은 여전히 경합합니다(GetOrAdd 등 복합 연산용 메서드를 사용합니다). 그 GetOrAdd에도 단서가 있어서, 저장되는 값은 하나로 결정되는 반면 값을 만드는 팩토리 함수는 경합 시 여러 번 호출될 수 있습니다. 팩토리에 부작용(연결을 열거나 파일을 만드는 등)을 넣으면 중복 실행으로 누락이 발생하므로, 부작용이 없는 함수로 만들거나 확실히 1회만 실행하고 싶은 초기화는 Lazy<T>를 값으로 저장하는 형태로 만듭니다. 컬렉션의 형식을 바꾸는 것은 공유 가변 상태를 줄이는 것을 대신하지 못합니다.
5. 원칙 3: 락에는 규율을 부여한다
공유 가변 상태를 줄이더라도 완전히 없애지는 못하는 경우가 많습니다. 남은 공유에는 배타 제어(락)를 사용하는데, 락은 「일단 수상한 곳을 lock으로 감싼다」는 도구가 아닙니다. 규율은 4가지입니다.
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. 여러 개의 락은 항상 같은 순서로
락이 2개 이상 있을 때, 획득 순서가 스레드마다 뒤바뀌는 것이 데드락의 고전적인 패턴입니다. 대책은 단순한데, 모든 스레드가 같은 순서로 락을 잡는다는 것을 규칙화하는 것입니다. 순서를 보장할 수 없는 곳에는 Monitor.TryEnter의 타임아웃이 있는 오버로드를 사용해, 잡지 못하면 놓아주고 다시 시도하거나(혹은 이상을 기록하거나) 함으로써 영원한 행(hang)을 감지 가능한 실패로 바꿀 수 있습니다.4
5.4. 단순한 갱신은 Interlocked, 읽기가 많다면 ReaderWriterLockSlim
카운터의 증감이나 플래그 교체와 같은 단일 변수의 원자적 갱신은 lock보다 Interlocked 클래스(Increment / Add / CompareExchange)가 더 빠릅니다. 경합이 없으면 단일 CPU 명령 접두사만으로 끝납니다.4 반대로 Interlocked로 할 수 있는 것은 거기까지이며, 여러 변수를 한꺼번에 정합성 있게 맞추는 용도로는 사용할 수 없습니다. volatile과 조합한 자체 락 프리(lock-free) 구조는 메모리 모델에 대한 깊은 이해가 필요한 상급자용 도구이며, 업무 앱에서 작성해야 할 것은 아닙니다.
「읽기는 빈번하지만 쓰기는 드물다」는 공유 데이터에는, 쓰기만 배타적으로 처리하고 읽기는 동시에 허용하는 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)는 「동시에 하나」를 스스로 지키게 하는 것이 기본입니다. 여기에 더해 3가지가 있습니다. 첫째, 정지 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 함수를 1회 호출"| 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의 컨트롤은 스레드 안전하지 않으며, 여러 스레드에서 조작하면 컨트롤이 불일치 상태로 몰려 경합・데드락・멈춤(freeze)의 원인이 됩니다. 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. 검증과 디버깅 ── 「재현되지 않는다」는 전제로 대비한다
멀티스레드 버그는 테스트로 발견될 것이라 기대할 수 없습니다. 일반적인 단위 테스트는 「우연히 경합이 일어나지 않은」 실행을 성공으로 세어 버리기 때문입니다. 대비는 3개의 층으로 생각합니다.
첫 번째 방어선은 여기까지의 설계 원칙 그 자체입니다. 공유 가변 상태가 5개인 앱과 50개인 앱은 의심해야 할 곳의 수가 10배 다릅니다. 리뷰에서는 「공유하고 있는 가변 데이터는 무엇인가」, 「각각 어떤 락이 지키는가」, 「락의 획득 순서는 유일한가」, 「정지 경로는 어디인가」를 표로 확인합니다. 이 표를 작성할 수 없는 설계는 동작하고 있더라도 아직 완성된 것이 아닙니다.
둘째, 이상을 숨기지 않고 관측 가능하게 만듭니다. Monitor.TryEnter의 타임아웃으로 락 대기 이상을 감지해 로그에 남기고4, 스레드 풀에 던진 처리의 미관측 예외를 뭉개지 않고 기록하며, 행(hang) 발생 시에는 풀 덤프를 채취해 모든 스레드의 스택을 확인할 수 있도록 해 둡니다 ── 「가끔씩만 일어나는」 버그와의 싸움은 일어난 그 한 번에서 얼마나 많은 정보를 얻을 수 있느냐로 결정됩니다. 덤프와 로그 정비는 「Windows 앱 크래시 시 로그와 덤프를 남기는 설계」에서 다루고 있습니다.
셋째, 부하를 걸어 흔듭니다. 코어 수보다 많은 병렬도로 장시간 돌리기, 처리 순서를 무작위화하기, 지연을 인위적으로 삽입하기 등, 개발 머신에서 인터리빙의 당첨을 뽑기 쉽게 만드는 스트레스 테스트는 출시 전에 경합을 드러내는 현실적인 수단입니다. 디버그 실행에서는 사라지는 버그도 릴리스 빌드와 고부하 조합이라면 재현되는 경우가 흔합니다.
10. 정리 ── 스레드를 늘리기 전의 체크리스트
멀티스레드 프로그래밍의 모범 사례는 근본적으로 말하면 「동기화를 올바르게 작성하는 기술」이 아니라 「동기화를 작성하지 않아도 되는 설계」입니다. 착수하기 전에 다음 8가지 질문에 답할 수 있다면 큰 사고는 거의 막을 수 있습니다.
- 그 처리는 CPU 바운드인가, I/O 바운드인가(후자라면 스레드가 아니라 async/await가 답이다)
new Thread를 작성하려 하고 있지는 않은가(Task・Parallel・스레드 풀로 표현할 수 없는가)- 스레드 간에 공유되는 가변 데이터는 무엇인가, 열거할 수 있는가
- 그 공유는 「분할」・「불변화」・「큐를 통한 전달」로 없앨 수 없는가
- 남은 공유 데이터 각각에 대응하는 락이 하나로 정해져 있는가
- 락의 획득 순서는 모든 스레드에서 유일한가, 락을 잡은 동안 외부 호출을 하고 있지 않은가
CancellationToken은 모든 장시간 처리에 전달되고 있는가, 정지 경로를 설명할 수 있는가- UI를 건드리는 코드는 UI 스레드에 집약되어 있는가
멀티스레드 버그는 작성한 날에는 나타나지 않고, 잊어버릴 즈음 고객사 현장에서 이빨을 드러냅니다. 반대로 말하면, 설계 단계에서 이 체크리스트를 통과시켜 두면 「가끔 죽는다」, 「한 달에 한 번 멈춘다」라는 가장 비용이 큰 종류의 장애를, 코드를 작성하기 전에 뽑아 낼 수 있다는 뜻입니다.
관련 글
- 멀티스레드 실무 베스트 프랙티스 C++ 편 ── RAII와 jthread로 사고를 구조적으로 없애기
- 멀티스레드 실무 베스트 프랙티스 C 언어 편 ── Win32 API의 방식으로 안전하게 작성하기
- 멀티스레드 실무 베스트 프랙티스 Java 편 ── 가상 스레드 시대의 정석
- C# async/await의 베스트 프랙티스 - Task.Run과 ConfigureAwait의 판단표
- WPF / WinForms의 async/await와 UI 스레드를 한 장으로 정리 - await 후의 돌아갈 곳, Dispatcher, ConfigureAwait, .Result / .Wait()의 막힘 포인트
- Windows I/O의 심층(제3회) ── I/O 완료 포트(IOCP)와 .NET 스레드 풀: async/await의 지하실
- COM STA/MTA 기초 - 스레드 모델과 행(hang)을 피하는 사고방식
- Windows에서 타이머 대기보다 이벤트 대기를 우선하는 이유 - 약 15.6ms 입자의 폴링을 피한다
- 공유 메모리를 사용할 때의 함정과 베스트 프랙티스 - 동기, 가시성, 수명, ABI, 보안을 먼저 정리
관련 상담 영역
합동회사 코무라소프트에서는 멀티스레드화를 포함한 업무 앱의 설계 리뷰, 「가끔 죽는다・멈춘다」와 같은 재현성이 낮은 결함의 원인 조사(덤프 분석・경합 지점 특정), 기존 앱의 병렬화・비동기화에 대한 기술 상담을 다루고 있습니다. 「이 설계로 경합이 일어나지 않는지 봐줬으면 한다」와 같은 단계부터도 괜찮습니다.
참고 링크
-
Microsoft Learn, Task Parallel Library (TPL). TPL이 .NET Framework 4 이후 멀티스레드・병렬 코드의 권장 수단이라는 것, 병렬도를 사용 가능한 프로세서에 맞춰 동적으로 조정한다는 것, 작업 분할・스레드 풀로의 스케줄링・취소 대응・상태 관리를 떠맡는다는 것, 1회 반복의 작업이 작은 루프에서는 병렬화 오버헤드로 느려질 수 있다는 것, 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에 의한 협조적 취소 모델의 절차, 취소가 강제가 아니라 협조이며 멈추는 방식은 리스너 측이 정한다는 것, 폴링・콜백 등록・대기 핸들이라는 3가지 감시 수단, ThrowIfCancellationRequested에 의한 OperationCanceledException 송출을 Task가 취소 완료로 취급한다는 것, 링크 토큰에 의한 여러 토큰의 합성, 라이브러리는 CancellationToken을 받는 공개 메서드를 제공해야 한다는 것에 대해. ↩ ↩2 ↩3
-
Microsoft Learn, Using threads and threading. 스레드 정지에는 CancellationToken을 사용해야 한다는 것, .NET Core 및 .NET 5 이후에는 Thread.Abort가 PlatformNotSupportedException을 던지며 .NET 5 이후에는 컴파일 시에도 사용 중단(obsolete) 경고(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 채널이 대안이 된다는 것에 대해. ↩
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
멀티스레드 실무 베스트 프랙티스 C 언어 편 ── Win32 API의 방식으로 안전하게 작성하기
C 언어 × Win32의 멀티스레드는 _beginthreadex를 이용한 스레드 생성, SRW 잠금과 조건 변수, Interlocked, 정지 이벤트+WaitForMultipleObjects의 정지 설계가 정석입니다. TerminateThread...
WMI/CIM을 C#・PowerShell에서 사용하기 ── 하드웨어 정보 취득・프로세스 감시・원격 조회 실무 가이드
PC 시리얼 번호 취득, 디스크 여유 공간 감시, 프로세스 시작 감지의 정석이 WMI/CIM입니다. Get-CimInstance 등 CIM 계열 명령어의 사용법과 구형 Get-WmiObject로부터의 이전, C#의 System.Management...
Windows의 세션 분리를 어떻게 이해할까 ── Session 0・RDP・다중 사용자 동시 실행
Windows 앱 개발자가 혼란스러워하기 쉬운 '세션' 개념을 정리합니다. 서비스가 UI를 표시할 수 없는 Session 0 분리의 이유, RDP 접속 시 세션의 동작, 명명된 개체의 세션 분리, 공유 PC・RDS 환경에서 흔히 발생하는 설계 실...
Windows의 프로세스 간 통신을 어떻게 선택할까 ── 네임드 파이프 / TCP / gRPC / 공유 메모리 / COM 판단표
Windows 앱 사이의 연동 수단을 어떻게 선택할지 정리합니다. 네임드 파이프, 로컬 TCP, gRPC, 공유 메모리, 파일 연동, COM 각각의 강점과 함정을 판단표로 정리하고, UI+서비스 분리・32bit/64bit 브리지・권한 경계 같은 ...
Windows 앱 데이터 저장 위치를 고르는 방법 ── SQLite / JSON / 레지스트리 / Access 판단표
Windows 데스크톱 앱의 데이터를 어디에·무엇으로 저장할지 정리합니다. AppData/ProgramData 구분, SQLite・JSON 파일・레지스트리・Access(.accdb) 각각의 강점과 함정을 판단표로 정리하고, 손상 대책과 비트 수 ...
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
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으로 표현하는 것이 표준입니다.
- 가끔씩만 일어나는 버그가 멀티스레드에서 비롯됐는지는 어떻게 판별하나요?
- 의심해야 할 징후는 「같은 조작이라도 재현되거나 안 되거나 한다」, 「디버거를 붙이거나 로그를 추가하면 재현되지 않게 된다」, 「부하가 높을 때나 시작 직후에만 발생한다」의 3가지입니다. 타이밍에 의존하는 버그는 실행할 때마다 결과가 달라지는 것이 특징이며, 이것이 바로 경합 상태(race condition)의 정의 그 자체입니다. 원인 분리 방법으로는 먼저 공유하고 있는 가변 데이터를 모두 찾아내고, 각각 「어떤 락이 지키고 있는지」를 표로 정리합니다. 지켜지지 않는 접근이 한 곳이라도 있으면 용의자입니다. 행(hang)의 경우에는 모든 스레드의 스택을 확보해 서로 락 대기로 순환하고 있지 않은지 확인합니다. Visual Studio 디버거로 일시 정지해서 「병렬 스택」을 보거나, 프로덕션 환경이라면 덤프를 채취해 분석합니다.