CodeGym /행동 /C# SELF /Concurrent 컬렉션의 고급 패턴과 특징 ...

Concurrent 컬렉션의 고급 패턴과 특징 Concurrent-컬렉션

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

1. 생산자‑소비자 패턴 + Concurrent

우리는 이미 ConcurrentQueue를 이야기하면서 생산자‑소비자 패턴을 잠깐 봤어. 여기선 여러 생산자와 여러 소비자가 있을 때, 그리고 올바른 종료 신호를 다루는 방법을 좀 더 자세히 살펴볼게.

ConcurrentQueue (및 다른 Concurrent 컬렉션)의 장점은 스레드 안전을 자체적으로 처리한다는 점이야. EnqueueTryDequeuelock으로 감쌀 필요가 없어 — 여러 스레드가 같은 큐를 통해 안전하게 상호작용할 수 있어.

예제: 여러 생산자와 여러 소비자

여러 워커 스레드가 작업을 생성하고, 다른 스레드들이 그 작업을 처리하는 예제야.

using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;

ConcurrentQueue<string> taskQueue = new ConcurrentQueue<string>();
CancellationTokenSource cts = new CancellationTokenSource(); // 소비자 취소용

// 생산자 메서드
void Producer(string name, int count)
{
    for (int i = 0; i < count; i++)
    {
        string task = $"작업_{name}_{i}";
        taskQueue.Enqueue(task);
        Console.WriteLine($"[P:{name}] 추가함: {task}");
        Thread.Sleep(10); 
    }
}

// 소비자 메서드
void Consumer(string name)
{
    while (!cts.Token.IsCancellationRequested || taskQueue.Count > 0)
    {
        if (taskQueue.TryDequeue(out string task))
        {
            Console.WriteLine($"[C:{name}] 처리함: {task}");
            Thread.Sleep(20); 
        }
        else
        {
            Thread.Sleep(50); // 큐가 비어있으면 기다림
        }
    }
    Console.WriteLine($"[C:{name}] 작업 완료.");
}

// Main에서 예제 실행:
Task.Run(() => Producer("A", 10)); // 생산자 A
Task.Run(() => Producer("B", 10)); // 생산자 B
Task.Run(() => Consumer("1"));    // 소비자 1
Task.Run(() => Consumer("2"));    // 소비자 2

Thread.Sleep(1000); // 작업할 시간 조금 줌
cts.Cancel();       // 소비자들에게 종료 신호
Thread.Sleep(500); // 소비자들이 남은 작업을 처리하고 끝낼 시간

여기선 여러 생산자와 소비자가 동일한 ConcurrentQueue로 동시에 작업하면서 데이터 레이스 없이 동작해: EnqueueTryDequeue는 원자적이야.

종료 신호의 중요성 (CancellationTokenSource)

우린 소비자에게 종료를 알리기 위해 CancellationTokenSource (cts)를 사용해. 이건 Producer‑Consumer 패턴에서 아주 중요해:

  • 생산자들이 작업을 모두 끝냈을 때. 더 이상 요소를 추가하지 않으면 소비자가 무한히 빈 큐를 기다리면 안 돼.
  • 애플리케이션 종료 시. 소비자들을 올바르게 멈춰야 해.

CancellationTokenSourceCancellationToken은 표준 메커니즘을 제공해: 소비자는 주기적으로 IsCancellationRequested를 확인하고 필요하면 ThrowIfCancellationRequested()를 호출할 수 있어.

2. BlockingCollection<T>

ConcurrentQueue<T>는 생산자‑소비자에 잘 맞지만, 비어있을 때 기다리는 로직과 종료 신호를 수동으로 처리해야 해. 좀 더 편하게 하려면 .NET의 BlockingCollection<T>을 쓰면 좋아 — 이건 독립된 컬렉션이라기보다 IProducerConsumerCollection<T> (예: ConcurrentQueue) 위에 얹는 래퍼야.

BlockingCollection의 장점:

  • 블로킹 연산. Add()/Take()는 컬렉션이 가득/비었을 때 스레드를 블록해. IsEmpty를 수동으로 검사할 필요가 없어.
  • 크기 제한. Capacity를 지정할 수 있어서 메모리 제어에 유용해. Add()는 한계에 도달하면 블록된다.
  • 편한 종료 처리. CompleteAdding()로 추가 종료를 알리고, GetConsumingEnumerable()로 소비자는 끝까지 안전하게 처리할 수 있어.

예제: BlockingCollection을 사용한 Producer‑Consumer

using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;

// BlockingCollection은 기본적으로 ConcurrentQueue를 사용함
BlockingCollection<int> numbers = new BlockingCollection<int>(capacity: 10); // 용량 10인 큐

void ProducerBC(int count)
{
    for (int i = 0; i < count; i++)
    {
        numbers.Add(i); // 큐가 가득하면 블록됨
        Console.WriteLine($"[P] 추가함: {i}");
        Thread.Sleep(50);
    }
    numbers.CompleteAdding(); // 생산자가 추가를 끝났음을 알림
    Console.WriteLine("[P] 생산자 추가 완료.");
}

void ConsumerBC()
{
    // GetConsumingEnumerable는 요소가 있거나 CompleteAdding이 호출될 때까지 블록함
    foreach (var item in numbers.GetConsumingEnumerable())
    {
        Console.WriteLine($"[C] 처리함: {item}");
        Thread.Sleep(100);
    }
    Console.WriteLine("[C] 소비자 작업 완료.");
}

// Main에서 예제 실행:
Task producerTask = Task.Run(() => ProducerBC(15)); // 15개, 용량은 10
Task consumerTask = Task.Run(ConsumerBC);
Task.WaitAll(producerTask, consumerTask); // 완료 대기

GetConsumingEnumerable() 덕분에 소비자 코드는 훨씬 깔끔해졌지. 블로킹 연산이나 크기 제한이 필요하면 BlockingCollection이 좋은 선택이야.

3. 추가적인 메서드와 속성 — Concurrent-컬렉션

IsEmpty, Count

  • IsEmpty (bool): 컬렉션이 비어있는지 여부.
  • Count (int): 현재 요소 개수(순간적인 값).

예제: IsEmptyCount 사용

using System.Collections.Concurrent;

ConcurrentQueue<string> q = new ConcurrentQueue<string>();
Console.WriteLine($"큐가 비었나? {q.IsEmpty}"); // True

q.Enqueue("A");
q.Enqueue("B");
Console.WriteLine($"큐의 요소 수: {q.Count}"); // 2
Console.WriteLine($"큐가 비었나? {q.IsEmpty}"); // False

q.TryDequeue(out var itemA);
Console.WriteLine($"큐의 요소 수: {q.Count}"); // 1

배열로 변환하기 (ToArray())

모든 Concurrent-컬렉션은 ToArray()를 제공해서 요소들의 순간 스냅샷을 반환해.

예제: ToArray() 사용

using System.Collections.Concurrent;

ConcurrentStack<int> s = new ConcurrentStack<int>();
s.Push(10);
s.Push(20);
s.Push(30);

int[] items = s.ToArray(); // 새 배열 생성: [30, 20, 10] (스택 LIFO의 경우)
Console.WriteLine($"배열의 요소: {string.Join(", ", items)}");

// 컬렉션은 변하지 않음
Console.WriteLine($"ToArray 후 스택의 요소 수: {s.Count}"); // 3

컬렉션 비우기

.NET 6+부터 많은 Concurrent-컬렉션에 모든 요소를 제거하는 Clear() 메서드가 추가됐어.

예제: 컬렉션 비우기

using System.Collections.Concurrent;

ConcurrentBag<string> bag = new ConcurrentBag<string>();
bag.Add("Alpha");
bag.Add("Beta");
Console.WriteLine($"Bag의 요소 수: {bag.Count}"); // 2

bag.Clear(); // 컬렉션 비우기
Console.WriteLine($"비운 후 Bag의 요소 수: {bag.Count}"); // 0
Console.WriteLine($"Bag 비었나? {bag.IsEmpty}"); // True

4. Concurrent-컬렉션의 동작 특성

중요한 건 순간 스냅샷이라는 점이야. Count 같은 속성이나 ToArray()의 결과는 특정 시점의 상태를 반영해. 병렬로 변경이 일어나면 이런 값은 바로바로 오래되지 — 즉시 바뀔 수 있어.

예제: CountToArray() — "순간 스냅샷"

using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;

ConcurrentQueue<int> snapshotQueue = new ConcurrentQueue<int>();

void AddItemsContinuously()
{
    for (int i = 0; i < 1000; i++)
    {
        snapshotQueue.Enqueue(i);
        Thread.Sleep(1); 
    }
}

// Main에서 예제 실행:
Task.Run(AddItemsContinuously); // 계속 요소를 추가하는 스레드

Thread.Sleep(100); // 조금 추가될 시간을 줌
Console.WriteLine($"현재 Count: {snapshotQueue.Count}"); // 50, 80, 120... 될 수 있음
Thread.Sleep(100);
Console.WriteLine($"현재 Count 다시: {snapshotQueue.Count}"); // 다른 값일 것
int[] currentItems = snapshotQueue.ToArray();
Console.WriteLine($"ToArray()의 요소 수: {currentItems.Length}"); // 마지막 Count와 다를 수 있음

활성으로 변경이 일어나는 동안엔 Count만을 절대적인 값으로 믿지 마.

5. Concurrent-컬렉션 이터레이션 관련 뉘앙스

개별 연산들(Add, TryTake, TryPop, GetOrAdd 등)은 스레드 안전해. 하지만 다른 스레드가 동시에 수정하는 컬렉션을 foreach로 순회하면 모든 요소를 보거나 정확히 한 번만 보는 보장이 없어 — 누락이나 예기치 않은 동작이 생길 수 있어.

예제: 수정 중인 동안의 이터레이션

using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;

ConcurrentQueue<int> iterQueue = new ConcurrentQueue<int>();

// 초기 요소 추가
for (int i = 0; i < 10; i++) iterQueue.Enqueue(i);

// 수정자 스레드
void Modifier()
{
    for (int i = 10; i < 20; i++)
    {
        iterQueue.Enqueue(i); // 새 요소 추가
        Thread.Sleep(50);
    }
}

// 이터레이터 스레드
void Iterator()
{
    Console.WriteLine("이터레이션 시작...");
    int count = 0;
    foreach (var item in iterQueue) // 이터레이션
    {
        Console.Write($"{item} ");
        count++;
        Thread.Sleep(30); // 작업을 흉내내며 수정자가 컬렉션을 변경할 시간 제공
    }
    Console.WriteLine($"\n이터레이션 종료. 읽은 요소 수 {count}.");
    Console.WriteLine($"현재 큐 요소 수: {iterQueue.Count}");
}

// Main에서 예제 실행:
Task.Run(Modifier);
Task.Run(Iterator);
Thread.Sleep(1500); // 작업할 시간

규칙: 고정된 요소 집합이 필요하면(예: 리포트 용도) 먼저 ToArray()로 스냅샷을 만들고 그 배열을 순회해:

// 컬렉션이 변경될 수 있을 때 올바른 이터레이션 방식
int[] snapshot = iterQueue.ToArray();
foreach (var item in snapshot)
{
    // 이제 변경 불가능한 배열 스냅샷을 순회함
}

이로써 Concurrent 컬렉션의 고급 패턴과 특징에 대한 개요를 마무리해: 여러 참여자가 있는 Producer‑Consumer와 BlockingCollection 사용법, 그리고 Count, ToArray(), 이터레이션 관련 중요한 주의점을 다뤘어.

코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION