멀티스레딩 기초
일을 여러 개로 나누어 함께 돌립니다.
여럿이 함께 계산합니다
4.1의 비동기는 기다리는 동안 스레드를 놓아 주는 것이었습니다. 놓아 줄 것이 없는 일, 그러니까 바깥을 기다리지 않고 계산만 하는 일은 그 방법으로 빨라지지 않습니다.
스레드는 코드를 실행하는 흐름입니다. 프로그램은 스레드 하나로 시작하고, 더 만들면 그만큼의 흐름이 동시에 진행됩니다. 코어가 여럿인 장비에서는 실제로 같은 시각에 함께 돕니다.
// 이 장비에서 함께 돌 수 있는 수 Console.WriteLine(Environment.ProcessorCount); // 16
빨라지는 대신 새로운 종류의 오류가 생깁니다. 두 흐름이 같은 값을 함께 고치면 결과가 어긋나고, 그 어긋남은 실행할 때마다 달라집니다. 이 단원은 대부분 그것을 막는 이야기입니다.
둘이 함께 세면 어긋납니다
같은 일을 세 가지 방법으로 합니다. 그냥 늘리는 것과 lock, Interlocked 입니다.
// 두 스레드가 같은 변수를 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}");
이 예제는 브라우저에서 실행할 수 없습니다. 위 결과는 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.ProcessorCount | 1 |
| new Thread(...) | 만들어집니다 |
| t.Start() | PlatformNotSupportedException |
| Task.Run | 메인 스레드에서 실행됩니다. 풀 스레드가 아닙니다 |
| Parallel.For | 돌기는 하지만 전부 같은 스레드입니다 |
| lock · Interlocked | 그대로 동작합니다 |
| Thread.Sleep | 돌아오지 않습니다. 화면이 그대로 멈춥니다 |
마지막 줄이 특히 조심할 곳입니다. 예외도 발생하지 않고 그냥 멈춥니다.
재우려는 스레드가 브라우저를 돌리는 그 스레드라, 깨워 줄 것이 남지 않기 때문입니다.
브라우저에서 기다릴 때는 await Task.Delay 입니다.
4.1에서 비동기와 병렬이 다르다고 한 것이 여기서 드러납니다. 같은 환경에서 비동기는 그대로 동작하고 병렬만 동작하지 않습니다.
이 문법, 몇 버전부터 사용할 수 있나요
- 1.0Thread · lock · Interlocked · ThreadPool
- 5.0Task · Parallel · PLINQ · 동시성 컬렉션
- 13.0System.Threading.Lock (.NET 9)
스레드와 lock 은 C#이 나온 날부터
있었습니다. 바뀐 것은 문법이 아니라 손으로 하던 일이 줄어든
것입니다. 예전에는 스레드를 직접 만들고 개수를 세고 결과를 모으는 것을
모두 작성해야 했는데, Task 와
Parallel 이 그 자리를 대신했습니다.
.NET 9의 Lock 은 아무 객체나 잠그던
것을 잠금 전용 형식으로 좁힌 것입니다.
직접 해보기
아래 클래스를 여러 스레드가 함께 사용하면 Total 이
맞지 않습니다. 두 가지 방법으로 고쳐보세요.
class Basket { private int _total; public int Total => _total; public void Add(int price) => _total += price; }
아래 코드는 가끔 멈춥니다. 무엇 때문인지 설명하고 고쳐보세요. 두 메서드는 서로 다른 스레드에서 불립니다.
private readonly object _users = new(); private readonly object _orders = new(); public void Transfer() { lock (_users) { lock (_orders) { /* 옮깁니다 */ } } } public void Report() { lock (_orders) { lock (_users) { /* 셈합니다 */ } } }
- 스레드를 늘리면 계산이 함께 진행됩니다. 바깥을 기다리는 일은 스레드가 아니라 비동기입니다.
- 둘이 같은 값을 고치면 실행할 때마다 다른 답이 나옵니다.
count++는 셋으로 나뉩니다. - 값 하나를 늘리고 줄이는 정도는
Interlocked, 그보다 넓으면lock입니다. lock은 전용 객체를 잠급니다.this·typeof·문자열은 잠그지 않습니다.- 여러 곳을 잠글 때는 어디서든 같은 차례로 잠급니다. 어긋나면 예외 없이 멈춥니다.
- 풀 스레드를
Thread.Sleep으로 붙잡지 마세요. 기다리는 일은Task.Delay입니다. - 브라우저에는 스레드가 하나뿐입니다.
Start()는 예외가 발생하고Thread.Sleep은 화면을 멈춥니다.
C# 버전별 변경 이력 에서 각 버전이 무엇을 더했는지 볼 수 있습니다.