C#LAB
C# 4.2 · 4부. 고급 문법

멀티스레딩 기초

일을 여러 개로 나누어 함께 돌립니다.

예상 학습 시간 22분 난이도 고급
개념 설명

여럿이 함께 계산합니다

4.1의 비동기는 기다리는 동안 스레드를 놓아 주는 것이었습니다. 놓아 줄 것이 없는 일, 그러니까 바깥을 기다리지 않고 계산만 하는 일은 그 방법으로 빨라지지 않습니다.

스레드는 코드를 실행하는 흐름입니다. 프로그램은 스레드 하나로 시작하고, 더 만들면 그만큼의 흐름이 동시에 진행됩니다. 코어가 여럿인 장비에서는 실제로 같은 시각에 함께 돕니다.

// 이 장비에서 함께 돌 수 있는 수
Console.WriteLine(Environment.ProcessorCount);   // 16

빨라지는 대신 새로운 종류의 오류가 생깁니다. 두 흐름이 같은 값을 함께 고치면 결과가 어긋나고, 그 어긋남은 실행할 때마다 달라집니다. 이 단원은 대부분 그것을 막는 이야기입니다.

최소 예제

둘이 함께 세면 어긋납니다

같은 일을 세 가지 방법으로 합니다. 그냥 늘리는 것과 lock, Interlocked 입니다.

Program.cs
// 두 스레드가 같은 변수를 100만 번씩 늘립니다. 답은 200만이어야 합니다.
static void Count(Action add) {
    var a = new Thread(() => { for (var i = 0; i < 1_000_000; i++) add(); });
    var b = new Thread(() => { for (var i = 0; i < 1_000_000; i++) add(); });

    a.Start(); b.Start();
    a.Join(); b.Join();   // Join 은 그 스레드가 끝날 때까지 기다립니다
}

int bare = 0, locked = 0, atomic = 0;
var gate = new object();

Count(() => bare++);
Count(() => { lock (gate) locked++; });
Count(() => Interlocked.Increment(ref atomic));

Console.WriteLine($"그냥        : {bare}");
Console.WriteLine($"lock        : {locked}");
Console.WriteLine($"Interlocked : {atomic}");
출력
그냥 : 1373922 lock : 2000000 Interlocked : 2000000

이 예제는 브라우저에서 실행할 수 없습니다. 위 결과는 16코어 장비에서 실행한 것입니다. 브라우저의 .NET 은 스레드를 하나만 사용하므로 두 스레드가 부딪히는 상황을 만들 수 없고, Thread 를 시작하면 PlatformNotSupportedException 이 발생합니다.

첫 줄의 값은 실행할 때마다 다릅니다. 같은 코드를 다섯 번 돌린 결과가 1088891 · 1247772 · 1518411 · 1421122 · 1373922 였습니다. 한 번도 200만이 나오지 않았고, 같은 값이 두 번 나오지도 않았습니다.

아래 두 줄은 언제나 정확히 2000000 입니다. 무엇이 이 차이를 만드는지가 이 단원의 알맹이입니다.

상세 사용법

왜 어긋나는가

count++ 는 한 줄이지만 하는 일은 셋입니다. 읽고, 1을 더하고, 다시 담습니다. 두 스레드가 이 셋을 겹쳐 실행하면 한쪽이 더한 값이 없어집니다.

// count 가 100 일 때
// 스레드 A          스레드 B
// 100 을 읽는다
//                   100 을 읽는다      A 가 아직 담지 않았습니다
// 101 을 담는다
//                   101 을 담는다      두 번 늘렸는데 101 입니다

이것을 경쟁 상태라고 합니다. 겹치는 순간은 아주 짧지만 100만 번 가운데 몇십만 번은 겹칩니다. 위 예제에서 60만 번 넘게 사라진 것이 그것입니다.

lock

lock한 번에 한 스레드만 들어가게 합니다. 나머지는 앞사람이 나올 때까지 기다립니다.

private readonly object _gate = new();

public void Add() {
    lock (_gate) {
        _count++;
    }
}

_gate잠글 대상이 아니라 잠금의 표식입니다. 안에 값을 담지 않고, 지금 누가 들어가 있는지를 기록하는 자리로만 존재합니다. 참조 형식은 모두 이 표시를 담을 자리를 하나씩 가지고 있어서 아무 객체나 표식이 될 수 있습니다.

그래서 _gate_count 를 모릅니다. 둘을 이어 주는 것은 컴파일러가 아니라 이 값을 건드리려면 저것을 먼저 잡는다는 약속입니다. 어겨도 컴파일 오류가 나지 않습니다.

// 두 스레드가 50만 번씩 늘립니다. 답은 1000000 입니다.

lock (g1) shared++;   // 한쪽은 g1 을
lock (g2) shared++;   // 다른 쪽은 g2 를 잠그면  →  812932

lock (same) shared++;  // 둘 다 같은 것을 잠그면   →  1000000

잠그는 자리가 서로 다르면 잠갔는데도 아무것도 막히지 않습니다. 아래 규칙은 모두 여기서 따라 나옵니다.

규칙사유
전용 객체를 잠급니다private readonly 로 두어 바깥에서 같은 것을 잠글 수 없게 합니다
readonly 로 둡니다중간에 새 객체를 담으면 그 뒤로는 서로 다른 것을 잠급니다. 예외도 경고도 없습니다
this 를 잠그지 않습니다바깥에서도 그 인스턴스를 잠글 수 있어, 서로 모르는 코드가 같은 것을 잠급니다
typeof·문자열을 잠그지 않습니다프로그램 전체가 하나를 나누어 가지므로 상관없는 코드끼리 막힙니다
안을 짧게 둡니다잠근 동안 다른 스레드는 서 있습니다. 여기가 길면 스레드를 늘린 뜻이 사라집니다
안에서 await 하지 않습니다문법으로 막혀 있습니다. 돌아올 때 다른 스레드일 수 있어 잠금을 풀 수 없습니다

Interlocked

늘리고 줄이고 바꾸는 정도라면 lock 없이도 됩니다. Interlocked읽고 고치고 담는 셋을 하나로 처리합니다. 기다리는 것이 없어 더 빠릅니다.

Interlocked.Increment(ref count);       // count++
Interlocked.Add(ref total, 10);          // total += 10
Interlocked.Exchange(ref name, "새 값");  // 담고 옛 값을 돌려줍니다

다룰 것이 값 하나를 넘어가면 lock 입니다. 두 값을 짝으로 맞추어 고쳐야 한다면 각각을 따로 처리해서는 중간 상태가 보입니다.

System.Threading.Lock

.NET 9부터 잠금 전용 형식이 생겼습니다. object 대신 이것을 사용하면 잠그라고 만든 것임이 이름에 드러나고, 더 빠릅니다. lock 문은 그대로 사용합니다.

private readonly Lock _gate = new();   // using System.Threading;

lock (_gate) { _count++; }

다만 object 에 담으면 조용히 옛 방식으로 돌아갑니다. 컴파일러가 알려 줍니다.

object boxed = new Lock();
lock (boxed) { }

// warning CS9216: 다른 형식으로 변환된 'System.Threading.Lock' 형식의 값은
// 'lock' 문에서 의도하지 않은 모니터 기반 잠금을 사용합니다.

서로 기다리다 멈추는 것

두 곳을 잠그는 차례가 어긋나면 둘 다 영원히 기다립니다. 아래는 실제로 멈춥니다.

var t1 = new Thread(() => { lock (A) { Thread.Sleep(50); lock (B) { } } });
var t2 = new Thread(() => { lock (B) { Thread.Sleep(50); lock (A) { } } });

// t1 은 A 를 쥔 채 B 를 기다리고, t2 는 B 를 쥔 채 A 를 기다립니다.
// t1.Join(2000) 도 t2.Join(2000) 도 false 를 돌려줍니다.

이것을 데드락이라고 합니다. 예외도 없고 오류 메시지도 없습니다. 그냥 서 있습니다. 여러 곳을 잠가야 한다면 어디서든 같은 차례로 잠그세요. 위 예제도 둘 다 A 부터 잠그면 멈추지 않습니다.

두 스레드가 배경 스레드가 아니면 프로그램 자체가 끝나지 않습니다. 멈춘 스레드가 남아 있는 한 종료를 기다리기 때문입니다.

더 안전한 것은 애초에 한 번에 한 곳만 잠그는 것입니다. 잠근 채로 다른 메서드를 부르지 않으면 이 문제는 생기지 않습니다.

Thread 와 Task.Run

new Thread스레드를 하나 새로 만듭니다. 만드는 데 비용이 들고, 끝나면 사라집니다. Task.Run미리 만들어 둔 스레드 풀에서 빌려 옵니다.

Console.WriteLine(Environment.CurrentManagedThreadId);   // 2  (메인)

await Task.Run(() => {
    Console.WriteLine(Environment.CurrentManagedThreadId);        // 10
    Console.WriteLine(Thread.CurrentThread.IsThreadPoolThread);  // True
});

var t = new Thread(() => {
    Console.WriteLine(Environment.CurrentManagedThreadId);        // 13
    Console.WriteLine(Thread.CurrentThread.IsThreadPoolThread);  // False
});
t.Start(); t.Join();
언제무엇을
짧은 계산을 나눌 때Task.Run
여러 개를 함께Parallel.For · PLINQ
프로그램이 도는 내내 하나new Thread
바깥의 응답을 기다릴 때스레드가 아니라 async·await 입니다(4.1)

new Thread 는 기본이 전경 스레드라서, 그것이 끝나지 않으면 프로그램도 끝나지 않습니다. IsBackground = true 로 두면 프로그램이 끝날 때 함께 정리됩니다.

풀 스레드를 막지 마세요

풀에 있는 스레드 수는 정해져 있습니다. Thread.Sleep 처럼 붙잡고 서 있는 코드를 풀 스레드에서 실행하면 그동안 다른 일이 자리를 얻지 못합니다.

// 같은 500밀리초를 100번 기다립니다. 16코어 장비에서 잰 값입니다.

await Task.WhenAll(Enumerable.Range(0, 100)
    .Select(_ => Task.Run(() => Thread.Sleep(500))));   // 3526ms

await Task.WhenAll(Enumerable.Range(0, 100)
    .Select(_ => Task.Delay(500)));                    //  515ms

같은 500밀리초인데 7배 가까이 차이납니다. 위쪽은 풀 스레드를 붙잡고 서 있어 나머지가 자리를 기다렸고, 아래쪽은 아무 스레드도 붙잡지 않아 100개가 한꺼번에 기다렸습니다. 기다리는 일에는 Task.Delay 입니다.

여러 개를 한꺼번에

목록의 항목마다 같은 계산을 해야 한다면 스레드를 손으로 만들 것 없이 Parallel 이나 PLINQ 를 사용합니다.

int sum = 0;
Parallel.For(0, 1000, i => Interlocked.Add(ref sum, i));
Console.WriteLine(sum);   // 499500

// PLINQ. 안에서 합치는 것까지 알아서 처리하므로 Interlocked 가 필요 없습니다.
var total = Enumerable.Range(1, 1000).AsParallel().Select(x => x * 2).Sum();
Console.WriteLine(total);   // 1001000

순서는 지켜지지 않습니다. 아래는 다섯 개를 순서대로 넘겼는데도 출력이 뒤섞입니다.

Parallel.ForEach(new[] { 1, 2, 3, 4, 5 }, x => Console.Write(x + " "));

// 1 3 2 4 5

한 항목의 계산이 아주 짧으면 나누는 비용이 더 큽니다. 항목이 몇 개 안 되거나 계산이 가벼우면 그냥 foreach 가 빠릅니다.

반복 변수를 그대로 넘기지 마세요

for 의 변수는 반복마다 새로 만들어지지 않습니다. 그것을 그대로 붙잡으면 다섯 스레드가 같은 하나를 봅니다.

그대로 붙잡으면
for (var i = 0; i < 5; i++)
    list.Add(new Thread(
        () => Console.Write(i)));

// 5 5 5 5 5
사본을 두면
for (var i = 0; i < 5; i++) {
    var copy = i;
    list.Add(new Thread(
        () => Console.Write(copy)));
}

// 0 1 2 3 4

왼쪽이 전부 5인 것은 스레드가 시작될 무렵 i 가 이미 반복을 마쳐 5가 되어 있기 때문입니다. foreach 는 C# 5.0부터 반복마다 새 변수를 만들어 이 문제가 없습니다.

함께 사용하는 컬렉션

3.2의 컬렉션은 여럿이 함께 고치도록 만들어지지 않았습니다. 깨지면 값이 틀리는 정도가 아니라 예외가 발생하거나 영영 멈춥니다.

// 잠그거나
lock (_gate) { _dict[key] = value; }

// 함께 사용하도록 만들어진 것을 사용합니다(System.Collections.Concurrent)
var map = new ConcurrentDictionary<string, int>();
map.AddOrUpdate(key, 1, (_, old) => old + 1);

var bag = new ConcurrentBag<int>();
var queue = new ConcurrentQueue<int>();

읽기만 한다면 잠글 것이 없습니다. 만든 뒤 아무도 고치지 않는 컬렉션은 여럿이 함께 읽어도 됩니다.

브라우저에는 스레드가 없습니다

이 화면이 도는 곳이기도 합니다. 브라우저의 .NET(WASM)은 스레드가 하나뿐이고, 그 하나가 화면을 그리는 스레드입니다.

무엇을브라우저에서
Environment.ProcessorCount1
new Thread(...)만들어집니다
t.Start()PlatformNotSupportedException
Task.Run메인 스레드에서 실행됩니다. 풀 스레드가 아닙니다
Parallel.For돌기는 하지만 전부 같은 스레드입니다
lock · Interlocked그대로 동작합니다
Thread.Sleep돌아오지 않습니다. 화면이 그대로 멈춥니다

마지막 줄이 특히 조심할 곳입니다. 예외도 발생하지 않고 그냥 멈춥니다. 재우려는 스레드가 브라우저를 돌리는 그 스레드라, 깨워 줄 것이 남지 않기 때문입니다. 브라우저에서 기다릴 때는 await Task.Delay 입니다.

4.1에서 비동기와 병렬이 다르다고 한 것이 여기서 드러납니다. 같은 환경에서 비동기는 그대로 동작하고 병렬만 동작하지 않습니다.

버전 배지

이 문법, 몇 버전부터 사용할 수 있나요

1.0
3.0
5.0
6.0
7.0
8.0
9.0
11.0
13.0
  • 1.0Thread · lock · Interlocked · ThreadPool
  • 5.0Task · Parallel · PLINQ · 동시성 컬렉션
  • 13.0System.Threading.Lock (.NET 9)
C# 1.0+

스레드와 lockC#이 나온 날부터 있었습니다. 바뀐 것은 문법이 아니라 손으로 하던 일이 줄어든 것입니다. 예전에는 스레드를 직접 만들고 개수를 세고 결과를 모으는 것을 모두 작성해야 했는데, TaskParallel 이 그 자리를 대신했습니다. .NET 9의 Lock아무 객체나 잠그던 것을 잠금 전용 형식으로 좁힌 것입니다.

실습 문제

직접 해보기

1. 어긋나는 셈 고치기 난이도 하

아래 클래스를 여러 스레드가 함께 사용하면 Total 이 맞지 않습니다. 두 가지 방법으로 고쳐보세요.

class Basket {
    private int _total;
    public int Total => _total;

    public void Add(int price) => _total += price;
}
_total += price 는 읽고 더하고 담는 셋입니다. 그 셋을 한 스레드만 하게 하거나, 셋을 하나로 처리하는 것을 사용합니다.
// 방법 1 — lock class Basket { private readonly Lock _gate = new(); // .NET 8 이하면 new object() private int _total; public int Total { get { lock (_gate) return _total; } } public void Add(int price) { lock (_gate) _total += price; } } // 방법 2 — Interlocked class Basket { private int _total; public int Total => Volatile.Read(ref _total); public void Add(int price) => Interlocked.Add(ref _total, price); } // 읽는 쪽도 함께 손봅니다. 담는 것만 맞추어 두면 읽는 순간의 값이 // 중간 상태일 수 있습니다.
2. 멈추는 코드 찾기 난이도 중

아래 코드는 가끔 멈춥니다. 무엇 때문인지 설명하고 고쳐보세요. 두 메서드는 서로 다른 스레드에서 불립니다.

private readonly object _users = new();
private readonly object _orders = new();

public void Transfer() {
    lock (_users) {
        lock (_orders) { /* 옮깁니다 */ }
    }
}

public void Report() {
    lock (_orders) {
        lock (_users) { /* 셈합니다 */ }
    }
}
두 메서드가 잠그는 차례를 나란히 놓고 비교해보세요. 한쪽이 첫 번째를 쥔 채 두 번째를 기다리는 그 순간에 다른 쪽은 무엇을 쥐고 있습니까.
// 데드락입니다. Transfer 가 _users 를 쥐고 _orders 를 기다리는 사이 // Report 가 _orders 를 쥐고 _users 를 기다리면 둘 다 나오지 못합니다. // 예외도 오류도 없이 그냥 서 있습니다. // 방법 1 — 어디서든 같은 차례로 잠급니다 public void Report() { lock (_users) { lock (_orders) { /* 셈합니다 */ } } } // 방법 2 — 애초에 하나만 잠급니다 private readonly Lock _gate = new(); public void Transfer() { lock (_gate) { /* 옮깁니다 */ } } public void Report() { lock (_gate) { /* 셈합니다 */ } } // 방법 2는 둘이 동시에 돌지 못해 느려집니다. 대신 차례를 틀릴 자리가 // 없습니다. 잠글 곳이 늘어날수록 이쪽이 안전합니다.
요약
  • 스레드를 늘리면 계산이 함께 진행됩니다. 바깥을 기다리는 일은 스레드가 아니라 비동기입니다.
  • 둘이 같은 값을 고치면 실행할 때마다 다른 답이 나옵니다. count++ 는 셋으로 나뉩니다.
  • 값 하나를 늘리고 줄이는 정도는 Interlocked, 그보다 넓으면 lock 입니다.
  • lock전용 객체를 잠급니다. this·typeof·문자열은 잠그지 않습니다.
  • 여러 곳을 잠글 때는 어디서든 같은 차례로 잠급니다. 어긋나면 예외 없이 멈춥니다.
  • 풀 스레드를 Thread.Sleep 으로 붙잡지 마세요. 기다리는 일은 Task.Delay 입니다.
  • 브라우저에는 스레드가 하나뿐입니다. Start() 는 예외가 발생하고 Thread.Sleep 은 화면을 멈춥니다.

C# 버전별 변경 이력 에서 각 버전이 무엇을 더했는지 볼 수 있습니다.