Lang/C#

[More Effective C#] Chapter 4. 요약 (1)

Henu 2024. 4. 21. 22:35

[ Item 35 : PLINQ가 병렬 알고리즘을 구현하는 방법을 이해하라 ]

PLINQ 를 사용하면 멀티코어 프로그래밍에 쉽게 접근할 수 있다.

  1. 데이터 접근을 위해 언제 동기화돼야 하는지 알아야 한다.
  2. ParallelEnumerable에 선언된 병렬 버전과 순차 버전 메서드의 효과를 측정해야 한다.
  3. 개별 요소들을 반드시 순차적으로 접근해야만 하는 메서드가 있다는 것을 알아야 한다.

PLINQ를 사용하는 예시

  1. 150 보다 작은 모든 수에 대해 n! 을 계산하는 쿼리
var nums = data.Where(d => d < 150).Select(n => Factorial(n));

 

파이프라인의 첫 번째 메서드에 AsParallel()을 추가하면 병렬 연산이 가능해진다.

 

var nums = data.AsParallel().Where(d => d < 150).Select(n => Factorial(n));

 

AsParallel()은 수행할 연산을 멀티 코어에서 실행한다.

그리고 반환 값은 IParallelEnumerable() 이고 Enumerable 클래스의 확장 메서드와 거의 동일하다.

그래서 단순히 LINQ 패턴을 그대로 사용하는 것 만으로도 PLINQ를 사용할 수 있다.

물론 위의 예시처럼 공유 데이터가 없고 결과의 순서도 중요하지 않기 때문에 쉽게 적용할 수 있다는 말이다.

 

 

모든 병렬 쿼리는 분할(partitioning)과정부터 시작한다.

PLINQ는 데이터를 분할한 후, 쿼리를 실행하기 위해 생성한 태스크들에게 분할된 요소들을 분배해야 한다.

분할에 너무 많은 시간을 사용해선 안된다.

 

PLINQ의 분할 알고리즘

 

1. 범위 분할(Range Partitioning)

데이터의 범위를 태스크 수로 나누고, 나눈 집합을 각 태스크에 할당한다.

ex) 1,000 개의 값을 쿼드 코어에서 실행한다면 범위를 4분할 하여 각각 요소가 250개인 집합 4개를 만든다.

이 방법은 시퀀스에 몇 개의 요소가 있는지 알 수 있는 경우만 사용할 수 있고, 순서를 구분하는 인덱스를 지원해야 한다. 즉 IList<T> 인터페이스를 지원하는 시퀀스가 사용한다.

 

2. 덩어리 분할(Chunk Partitioning)

컬렉션의 데이터를 작은 청크로 나눠서 각 청크를 병렬로 처리하는 방법이다.

매우 작은 크기의 청크로 나누는 것부터 시작해야 하는데, 그래야 시퀀스를 하나의 태스크로 할당하는 사태를 방지할 수 있다.

 

3. 줄 단위 분할(Striped Partitioning)

작업 스레드는 N개의 요소를 건너뛴 후 그다음 M개의 요소를 처리한다. M개를 다 처리한 후에는 다시 N개를 건너뛴다. 마치 줄무늬를 그리는 상황을 상상하면 이해하기 쉽다.

 

4. 해시 분할(Hash Partitioning)

해시 분할은 Join, GroupJoin, GroupBy, Distinct, Except, Union, Intersect를 사용하는 쿼리를 위해 특별히 설계된 알고리즘이다. 이런 쿼리들은 비교적 무거운 연산이므로 특별한 분할 알고리즘을 사용하면 병렬화 성능을 크게 개선할 수 있다.

 

태스크 병렬화 알고리즘 (분할 알고리즘과 별개)

1. 파이프라이닝

파이프라이닝은 태스크를 여러 단계로 나누고, 각 단계가 연속적으로 데이터를 처리하도록 구성하는 방식이다.

각 단계는 동시에 다른 데이터 항목을 처리할 수 있기 때문에 데이터 스트림 처리나 요청 처리 같은 상황에서 유용하다.

하나의 스레드가 순회 과정(foreach 블록, 쿼리 시퀀스)을 처리하고 시퀀스의 각 요소를 처리하는 쿼리는 N개의 스레드가 처리한다.

 

2. 스톱앤고

스톱앤고는 태스크가 여러 단계로 나누어져 있으며, 각 단계 사이에 동기화 포인트를 두는 방식이다.

ToList(), ToArray() 등을 사용해 쿼리 결과를 즉시 반환할 필요가 있을 때 사용된다.

스톱앤고는 병렬 처리의 이점을 제공하면서도, 단계 간의 엄격한 동기화를 유지할 필요가 있는 경우에 적합하다. 대신 많은 메모리를 사용한다는 단점이 있다.

 

3. 역열거형

결과를 생성하지는 않고, 모든 쿼리식의 결과에 다른 액션을 취하는 경우에 사용된다.

지연 평가 쿼리에 대해서 쿼리 결과를 처리할 때 액션이 다를 수 있는데, 이때 필요할 것이 역열거형 방식이다.

 

 


[ Item 36 : 예외를 염두에 두고 병렬 알고리즘을 만들라 ]

자식 스레드는 언제든 예외를 일으킬 수 있다.

백그라운드 스레드에서 발생하는 예외는 매우 복잡한데, 예외는 스레드의 경계를 넘어서 다른 스레드로 전달될 수 없기 때문이다.

호출 스택이 계속 이어지지 않기 때문에, 다른 스레드를 호출한 스레드는 거기서 발생한 예외를 가져오거나 예외 관련 처리를 할 방법이 없다.

 

이번 아이템에서 병렬 처리 중에 발생한 예외를 처리하는 대표적인 방법을 살펴보자.

 

외부 스레드에서 예외가 발생한 경우

  1. 복원할 수 있는 예외만 캐치하기
  2. 예외가 백그라운드 작업을 벗어나지 못하게 하기
private static Task<byte[]> StartDownload(string url)
{
    var tcs = new TaskCompletionSource<byte[]>();
    var wc = new WebClient();
    wc.DownloadDataCompleted += (sender, e) =>
    {
        if (e.UserState == tcs)
        {
            if (e.Cancelled)
            {
                tcs.TrySetCanceled();

            }
        }
        else if (e.Error != null)
        {
            if (e.Error is WebException)
            {
                tcs.TrySetResult(new byte[0]);
            }
            else
            {
                tcs.TrySetException(e.Error);
            }
        }
        else
        {
            tcs.TrySetResult(e.Result);
        }
    };
    
    wc.DownloadDataAsync(new Uri(url), tcs);
    return tcs.Task;
}

 

WebException이 발생하면 0바이트를 읽었다는 사실을 결과로 반환한다.

반면 다른 예외가 발생하면 일반적인 방법으로 예외를 설정한다.

즉, 병렬 작업을 수행시키는 스레드는 AggregateException 예외에 대해서 대응하는 작업이 필요하다.

AggregateException : 비동기 태스크를 수행하는 스레드에서 발생한 예외들을 모아둔 클래스

 

 

일반적인 LINQ를 사용하면 지연 연산으로 인해 쿼리의 결과를 실제로 사용하는 곳에서 평가가 이루어지기 때문에 사용하는 곳만 try-catch로 감싸는 작업이 필요하다.

반면 PLINQ를 사용하면 쿼리를 정의한 부분도 try-catch 블록으로 감싸야 한다.

 

 


[ Item 37 : 스레드를 생성하지 말고 스레드 풀을 사용하라 ]

.NET의 스레드 풀은 스레드 리소스 관리를 위해 필요한 다양한 처리를 수행한다.

Task 기반 라이브러리에서 Task.Run() 으로 태스크를 수행하면 이 스레드 풀을 활용한다.

 

스레드 풀의 장점

1. 스레드 풀 내부의 스레드 수는 가용 스레드의 개수를 최대화하고, 할당 후 사용되지 않는 리소스의 개수를 최소화하는 방식으로 조율된다.

스레드 풀은 작업을 수행할 준비가 된 스레드를 재사용한다. 수동으로 생성한다면 작업마다 새로운 스레드를 만들기 때문에 스레드 생성, 폐기 비용이 스레드 풀 관리 비용보다 비싸진다.

암묵적 스레딩이라고도 부르며 사용한 스레드를 빠르게 작업에 할당해주는 것이 스레드 풀의 주요 목적이다. - ‘요청을 던지고 잊으라’

 

2. 스레드 풀은 태스크의 생명 주기를 자동으로 관리해준다.

태스크가 종료되더라도 스레드는 제거되지 않으며, 다른 태스크를 처리할 수 있는 상태로 바뀐다.

 

3. 스레드 풀의 활성 태스크 수는 시스템이 관리한다.

만약 시스템이 리소스를 거의 다 소비한 상태라면 스레드 풀은 새로운 태스크를 시작하지 않는다.

반대로 시스템 리소스에 여유가 많다면 스레드 풀에서 추가 태스크를 바로 실행한다.

즉, 스레드 풀이 알아서 부하를 분산하기 때문에 부하 분산 로직을 직접 작성할 필요가 없다.

 

 


[ Item 38 : 스레드 간 커뮤니케이션에는 BackgroundWorker를 사용하라 ]

BackgroundWorker 는 UI 작업을 돕기 위해서 System.ComponentModel.Component 클래스를 기반으로 만들어진 컴포넌트다.

백그라운드 스레드와 포그라운드 스레드 간 커뮤니케이션(작업 완료 감지, 진행 상태 추적, 일시정지, 취소 등)을 위해서라면 BackgroundWorker 컴포넌트를 사용해야 한다.

var backgroundWorkerExample = new BackgroundWorker();
backgroundWorkerExample.DoWork += (sender, args) =>
{
    // 작업 내용
};
backgroundWorkerExample.RunWorkerAsync();

 

BackgroundWorker 는 포그라운드 스레드와 백그라운드 스레드 사이의 커뮤니케이션을 위해 이벤트를 사용한다.

포그라운드 스레드가 요청을 보내면 BackgroundWorker 가 백그라운드 스레드 상에서 DoWork 이벤트를 발생시킨다. 그리고 DoWork 이벤트 핸들러가 주어진 매개변수를 읽어 작업을 시작한다.

 

백그라운드 스레드가 작업을 끝내면(DoWork에 등록된 핸들러 종료), BackgroundWorker 가 포그라운드 스레드에게 RunWorkerCompleted 이벤트를 발생시킨다.

그리고 포그라운드 스레드는 백그라운드 스레드가 완료된 후의 후속 처리를 진행할 수 있다.

 

BackgroundWorker 의 이벤트 외에도, 프로퍼티를 이용하면 백그라운드, 포그라운드 스레드의 상호작용 방법을 제어할 수 있다.

예를 들어, WorkerRepostsProgress 프로퍼티는 BackgroundWorker 에게 진행 상태를 정기적으로 포그라운드 스레드에게 보고 한다는 사실을 알릴 수 있다.

 

BackgroundWorker 클래스는 현재 태스크의 취소 요청, 진행 상태 보고, 완료 보고, 오류 보고 등을 위한 다수의 이벤트를 지원한다.

포그라운드 태스크의 코드는 이러한 추가 이벤트를 요청하면 되고, 관련 이벤트 핸들러를 등록해야 한다.