CodeGym /행동 /C# SELF /스레드-세이프 패턴

스레드-세이프 패턴

C# SELF
레벨 58 , 레슨 4
사용 가능

1. «락 없이» 패턴 (Lock-Free / Wait-Free)

이미 Concurrent 컬렉션이 스레드-세이프하다는 걸 알고 있을 거예요. 그런데 어떻게 여러분이 코드 곳곳에 lock을 걸지 않아도 되는 걸까요? 답은 고급 알고리즘에 있어요 — lock-free(락 없음)와 wait-free(대기 없음).

lock-free 와 wait-free 알고리즘 개념 간단 설명

Lock-Free (락 없음)

핵심: 다른 스레드들이 지연되거나 인터럽트돼도 적어도 한 스레드는 항상 전진할 수 있다는 보장입니다.

lock과의 차이: lock을 쓰면 경쟁하는 스레드들이 락 해제될 때까지 대기합니다. lock-free 알고리즘에서는 스레드들이 고전적 의미의 대기로 서로를 막지 않습니다: 충돌이 생기면 단순히 재시도합니다.

예시: 계산대의 줄을 생각해보세요: lock에서는 줄 서서 기다리는 느낌입니다. lock-free에서는 가서 보니 비어있지 않으면 잠깐 뒤에 다시 와보는 식이죠 — 공용 줄에서 ‘서성이는’ 일이 줄어듭니다.

Wait-Free (대기 없음)

핵심: 더 강한 보장으로, 각 스레드는 다른 스레드와 상관없이 내에서 반드시 진행합니다. 누구도 무한히 재시도하지 않습니다.

차이: lock-free에서는 충돌 때문에 어떤 스레드가 무한히 재시도할 가능성이 있지만, wait-free에서는 그런 일이 발생하지 않습니다.

실무: wait-free를 구현하는 건 훨씬 복잡해서 실제로는 lock-free나 하이브리드 방식이 더 자주 쓰입니다.

2. Concurrent 컬렉션이 스레드-세이프를 달성하는 방법

lock-free 알고리즘의 기본 빌딩 블록은 프로세서 수준의 원자적 연산입니다. .NET에서는 System.Threading.Interlocked 클래스를 통해 이런 연산을 사용합니다.

Interlocked 연산

원시 타입(int, long)에 대한 빠른 원자적 연산들: 예를 들어 Interlocked.Increment, Interlocked.Decrement, Interlocked.CompareExchange.

예시: Interlocked.Increment(ref value) — 원자적으로 증가; Interlocked.CompareExchange(ref location, value, comparand) — 원자적으로 비교 후 교체.

CAS (Compare-And-Swap) — 비교-후-교환

CAS 연산은 .NET에서 Interlocked.CompareExchange로 구현됩니다. 일반적 로직은:

  1. 변수의 현재 값을 읽는다.
  2. 읽은 값을 기반으로 새 값을 계산한다.
  3. 변수가 아직도 원래 값과 같다면 새 값으로 기록하려 시도한다. 아니라면 재시도한다.

예: Interlocked로 구현한 간단한 lock-free 카운터

using System.Threading;
using System.Threading.Tasks;

class CounterExample
{
    static int regularCounter = 0;
    static int interlockedCounter = 0;

    static void IncrementRegular(int iterations)
    {
        for (int i = 0; i < iterations; i++)
        {
            regularCounter++; // 스레드-세이프하지 않음!
        }
    }

    static void IncrementInterlocked(int iterations)
    {
        for (int i = 0; i < iterations; i++)
        {
            Interlocked.Increment(ref interlockedCounter); // 원자적!
        }
    }
}

//Main에서:
Task t1 = Task.Run(() => IncrementRegular(500_000));
Task t2 = Task.Run(() => IncrementRegular(500_000));
Task.WaitAll(t1, t2);
Console.WriteLine($"일반 카운터: {regularCounter}"); // 거의 항상 1_000_000보다 작음

regularCounter = 0; // 다음 테스트를 위해 리셋
t1 = Task.Run(() => IncrementInterlocked(500_000));
t2 = Task.Run(() => IncrementInterlocked(500_000));
Task.WaitAll(t1, t2);
Console.WriteLine($"Interlocked 카운터: {interlockedCounter}"); // 정확히 1_000_000

Interlocked.Increment는 증감 연산의 원자성을 보장하므로 여러 스레드가 동시에 접근해도 데이터가 유실되지 않습니다.

3. 왜 이게 스케일링과 성능에서 중요한가

오버헤드 감소: 전통적 락(lock)은 컨텍스트 스위치와 커널 레벨 대기를 유발할 수 있습니다. Lock-free는 이런 비용을 최소화합니다.

데드락 없음: 스레드들이 서로 기다리지 않으므로 deadlock이 발생할 가능성이 줄어듭니다.

더 나은 확장성: 멀티코어 환경에서 스레드들이 서로 덜 간섭하므로 단일 공용 락의 '병목'이 사라집니다.

응답성 향상: 긴 대기로 인해 누구도 '멈춰버리지' 않습니다.

ConcurrentQueue<T> 내부를 아주 간단히 들여다보기

단순화하면: 큐는 연결된 세그먼트들로 구성됩니다. Enqueue 시 스레드는 CompareExchange를 통해 'tail'을 원자적으로 전진시키고, TryDequeue 시에는 'head'를 원자적으로 이동시킵니다(변경되지 않았을 경우에만). 실제 구현은 ABA 문제 해결, 가비지 수집 고려 등으로 더 복잡하지만 핵심은 '무거운' 락 대신 원자적 연산을 사용한다는 점입니다.

4. Concurrent 컬렉션의 성능

lock 기반 일반 컬렉션과의 성능 비교

경쟁이 적을 때는 차이가 크지 않으며, 때론 단순한 lock이 비슷한 성능을 낼 수도 있습니다. 하지만 경쟁이 심해지면 Concurrent 컬렉션은 일반적으로 훨씬 빠릅니다 — 공용 락에 대한 대기가 없기 때문입니다.

예: 비교 아이디어(실행하지 않음)

using System.Collections.Generic;
using System.Collections.Concurrent;
using System.Diagnostics; // Stopwatch 용
using System.Threading.Tasks;

class PerformanceTest
{
    static List<int> regularList = new List<int>();
    static ConcurrentQueue<int> concurrentQueue = new ConcurrentQueue<int>();
    static object lockObject = new object();

    const int Iterations = 1_000_000;
    const int NumTasks = 4; // 병렬 작업 수

    public static void RunTests()
    {
        Console.WriteLine("성능 테스트 (추가):");

        // 일반 List + lock 테스트
        regularList.Clear();
        Stopwatch sw = Stopwatch.StartNew();
        Parallel.For(0, NumTasks, (i) =>
        {
            for (int j = 0; j < Iterations / NumTasks; j++)
            {
                lock (lockObject)
                {
                    regularList.Add(j);
                }
            }
        });
        sw.Stop();
        Console.WriteLine($"List + lock: {sw.ElapsedMilliseconds} ms. Count: {regularList.Count}");

        // ConcurrentQueue 테스트
        concurrentQueue.Clear();
        sw = Stopwatch.StartNew();
        Parallel.For(0, NumTasks, (i) =>
        {
            for (int j = 0; j < Iterations / NumTasks; j++)
            {
                concurrentQueue.Enqueue(j);
            }
        });
        sw.Stop();
        Console.WriteLine($"ConcurrentQueue: {sw.ElapsedMilliseconds} ms. Count: {concurrentQueue.Count}");
        // NumTasks > 1 일 때 ConcurrentQueue가 훨씬 빠를 것으로 예상
    }
}

요약: 컬렉션 주변에서 lock을 발견하면, 종종 Concurrent 대응물을 사용하는 쪽으로 갈아타는 게 신호입니다.

6. 유용한 세부 팁

contention이 성능에 미치는 영향

Contention — 많은 스레드가 동시에 같은 자원에 접근할 때 발생합니다. 경쟁이 심할수록 대기 증가와 성능 저하가 발생합니다.

Concurrent 컬렉션은 경쟁 완화를 위해 설계되어 있습니다: 예를 들어 ConcurrentBag<T>는 스레드 로컬 스토리지를 활용하고, ConcurrentDictionary<TKey, TValue>는 스트라이프된 락(segment 기반)을 사용합니다.

성능의 핵심은 contention 감소: 가능하면 데이터를 스레드별로 분리하거나 여러 컬렉션을 사용하세요.

특정 시나리오에 맞는 컬렉션 선택

컬렉션 순서 언제 사용 언제 사용하지 말아야 할지
ConcurrentQueue<T>
FIFO (First-In, First-Out) 작업 큐, 로깅, 비동기 이벤트 처리, Producer-Consumer 패턴. 순서가 중요하지 않거나 LIFO가 필요하거나 크기 제한과 블로킹이 필요한 경우.
ConcurrentStack<T>
LIFO (Last-In, First-Out) 작업 히스토리(Undo/Redo), 그래프 DFS 탐색, '가장 최근에 추가된' 풀에서 객체 재사용. FIFO가 필수이거나 순서의 안정성이 중요한 경우.
ConcurrentBag<T>
순서 보장 안 됨 객체 풀, 생산자와 소비자가 보통 같은 스레드인 경우; TPL 시나리오에서 로컬리티가 중요한 경우. 요소 순서가 중요한 경우.
ConcurrentDictionary<TKey, TValue>
해당 없음 캐싱, 사용자 세션, 통계 집계, 병렬 집계 작업. 딕셔너리가 필요 없을 때.
BlockingCollection<T> (내부에 ConcurrentQueue 사용 가능) FIFO (또는 내부 베이스 컬렉션에 따름) 블로킹 연산과 크기 제한, 우아한 종료가 필요한 Producer-Consumer 시나리오. 블로킹 연산이나 크기 제한이 필요하지 않을 때.

7. 최적화 팁

핫스팟에서는 자주 ToArray() 하지 마세요

ToArray()는 컬렉션 전체의 복사본을 만듭니다 — 메모리와 시간 측면에서 비쌉니다. '스냅샷'이 필요할 때만, 그리고 가능한 적게 사용하세요. 개수는 Count로 알 수 있지만 호출 시점의 스냅샷이라는 점을 기억하세요.

Concurrent 컬렉션을 순회할 때 주의

병렬로 수정되는 동안 이터레이터는 안정성을 보장하지 않습니다: 요소를 빠뜨리거나 일관성 없는 뷰를 볼 수 있습니다. 안정적인 뷰가 필요하면 먼저 ToArray()로 스냅샷을 만드세요.

// 안 좋음: 요소를 빠뜨리거나 순회 중 변경을 볼 수 있음
foreach (var item in myConcurrentQueue) { /* ... */ }

// 좋음: 고정된 스냅샷으로 순회
var snapshot = myConcurrentQueue.ToArray();
foreach (var item in snapshot) { /* ... */ }

컬렉션을 통한 '트래픽'을 최소화하라

작업/데이터를 묶어 보내세요: Add/Take 호출 수를 줄이면 잠재적 contention도 줄어듭니다. 예를 들어 1000개의 개별 메시지 대신 1000개를 묶은 하나의 '배치'를 사용하세요.

경쟁의 원천을 모니터링하라

성능 저하가 보이면 어디에서 가장 큰 contention이 발생하는지 측정하세요. 디자인을 바꿔서 스레드들이 로컬 데이터를 사용하거나 서로 다른 컬렉션을 사용하게 할 수 있다면 큰 개선을 기대할 수 있습니다.

2
과제
C# SELF, 레벨 58, 레슨 4
잠금
Interlocked를 사용한 스레드 안전 카운터
Interlocked를 사용한 스레드 안전 카운터
1
설문조사/퀴즈
Concurrent-컬렉션, 레벨 58, 레슨 4
사용 불가능
Concurrent-컬렉션
스레드-안전 컬렉션
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION