C#을 통해 배우는 동시성 프로그래밍 Part 1.

2022. 5. 17. 13:58TIL💡/Others

✔︎ 동시성
한 번에 두 가지 이상의 작업을 수행

✔︎ 멀티스레딩
다수의 실행 스레드를 사용하는 동시성의 한 형태

✔︎ 병렬처리
많은 작업을 여러 스레드에 나눠서 동시에 수행

✔︎ 비동기 프로그래밍
불필요한 스레드의 사용을 피하려고 future나 callback을 사용하는 동시성의 한 형태

✔︎ 리액티브 프로그래밍
애플리케이션이 이벤트에 대응하게 하는 선언형 프로그래밍 방식

동시성은 멋진 소프트웨어의 핵심적인 특징이다.

최종 사용자 애플리케이션은 데이터베이스에 쓰는 동안 사용자의 입력에 응답하려고 동시성을 사용한다. 

서버 애플리케이션은 첫 번째 요청을 완료하는 동안 다른 작업을 수행해야 한다면 동시성이 필요하다.

 

대부분의 개발자들이 '동시성'이라는 단어를 들으면 즉시 '멀티 스레딩'을 떠올린다.

이 두 가지를 구분해야 한다.

멀티스레딩은 말 그대로 다수의 스레드를 사용한다는 뜻이다. 멀티스레딩은 동시성의 한 형태일 뿐 유일한 형태가 아니다.

사실 요즘 애플리케이션은 하위 레벨 스레드를 직접 사용할 이유가 거의 없다.

상위 레벨 추상화가 더 강력하고 효율적이다.

하지만 멀티 스레딩이 완전히 죽은 개념은 아니다. 멀티 스레딩은 스레드 풀(Thread Pool) 안에 살아 숨쉬고 있다.

 

스레드 풀은 필요에 따라 자동으로 작업을 할당하는 유용한 개념이다. 결과적으로 스레드 풀 덕분에 병렬 처리(Parallel Processing)이라는 다른 중요한 형태의 동시성을 사용할 수 있다.

 

병렬 처리 또는 병렬 프로그래밍은 멀티 스레딩을 사용해서 멀티 코어 프로세서를 최대한 활용하는 방법이다. 요즘 CPU는 코어가 여러 개다. 해야할 작업이 많을 때 하나의 코어에 모든 일을 맡기고 다른 코어를 쉬게 두면 비효율적이다. 

병렬 처리는 작업을 나눠서 각각 다른 코어에서 독립적으로 실행할 수 있는 여러 스레드에 맡긴다.

 

병렬 처리는 멀티 스레딩의 한 형태고, 멀티 스레딩은 동시성의 한 형태다.

그 밖에 동시성의 다른 형태로 비동기 프로그래밍이 있다.

future or promise는 나중에 완료될 연산을 나타내는 형식이다.

닷넷의 최신 퓨처 형식으로는 Task, Task<TResult>가 있다.

구식 비동기 API는 퓨처가 아닌 콜백이나 이벤트를 사용한다.

비동기 프로그래밍은 나중에 완료되는 연산인 비동기 연산이라는 개념에 중점을 두고 있다.

비동기 연산은 진행하는 동안 원래 스레드를 가로막지 않는다.

즉 해당 연산을 시작한 스레드는 자유롭게 다른 작업을 할 수 있다.

연산이 끝나면 퓨처에 알리거나 콜백 또는 이벤트를 실행해서 애플리케이션에 연산이 끝났음을 알린다.

 

비동기 프로그래밍은 동시성 중에서도 강력한 형태이지만, 최근까지도 엄청나게 복잡한 코드가 필요했다.

요즘 언어에서 지원하는 지원하는 async, await는 비동기 프로그래밍을 거의 동기 방식의 프로그래밍처럼 쉽게 만들어준다.

 

그 밖의 동시성의 형태로 리액티브 프로그래밍(Reactive Programming)이 있다. 

비동기 프로그래밍은 결국 애플리케이션이 나중에 완료될 연산을 시작한다는 뜻이다.

리액티브 프로그래밍은 비동기 프로그래밍과 밀접한 연관이 있지만, 비동기 연산이 아닌 비동기 이벤트를 바탕으로 한다.

비동기 이벤트는 실제로 '시작'이 없을 수 있으며, 언제든 발생할 수 있고, 여러 번 발생할 수도 있다.

 

애플리케이션을 커다란 상태 머신(State Machine)이라고 생각한다면 애플리케이션의 동작이란 이벤트에 따라 상태를 업데이트하는 식으로 일련의 이벤트에 대응하는 것이라 설명할 수 있다.

비동기 프로그래밍

비동기 프로그래밍은 크게 두 가지 이점이 있다.

1) 최종 사용자용 GUI 프로그램은 비동기 프로그래밍을 통해 응답성을 확보할 수 있다.

작업을 실행하는 동안 일시적으로 GUI가 잠기는 프로그램을 사용해봤을 것이다.

비동기 프로그램이라면 작업 중에도 사용자 입력에 반응할 수 있다.

 

2) 서버 프로그램은 비동기 프로그래밍을 통해 규모를 변경할 수 있다.

서버 애플리케이션은 스레드 풀만 사용해도 어느 정도 규모를 변경할 수 있지만, 비동기 서버 애플리케이션은 대개 훨씬 큰 단위로 규모를 변경할 수 있다.

 

비동기 프로그래밍의 두 가지 이점은 모두 비동기 프로그래밍이 스레드를 가로막지 않고 자유롭게 풀어 준다는 특징을 바탕으로 한다.

GUI 프로그램에서 비동기 프로그래밍은 UI 스레드를 자유롭게 풀어 준다. 따라서 GUI 애플리케이션은 사용자 입력에 응답성을 유지할 수 있다. 서버 애플리케이션에서 비동기 프로그래밍은 요청 스레드를 자유롭게 풀어 준다. 따라서 서버는 자신의 스레드를 사용해서 더 많은 요청을 처리할 수 있다.

 

최신 비동기 닷넷 애플리케이션은 async와 await, 두 가지 키워드를 사용한다.

메서드 선언에 추가하는 async 키워드는 두 가지 목적을 지닌다.

1) await 키워드 사용할 수 있게 한다.

2) 컴파일러에 해당 메서드의 상태 머신을 생성하라고 지시한다. yield가 작업을 반환하는 방식과 비슷하다.

 

async 메서드는 값을 반환해야할 때는 Task<TResult>를 반환하고, 값을 반환하지 않을 때는 Task 또는 ValueTask 같은 유사 Task 형식을 반환한다. 또 async 메서드는 열거형에 속하는 여러 값을 반환해야 할 때 IAsyncEnumerable<T>나 IAsyncEnumerator<T>를 반환할 수 있다. 유사 Task 형식은 퓨처를 나타내면 async 메서드가 완료할 때 호출한 코드에 알릴 수 있다.

 

async void는 사용하지 말아야 한다. void를 반환하는 async 메서드도 있을 수 있지만 async 이벤트 핸들러를 작성할 때만 사용해야 한다. 보통 반환 값이 없는 async 메서드는 void가 아닌 Task를 반환해야 한다.

 

작동 방식

async Task DoSomethingAsync()
{
	
	int value = 13;
    
    // 비동기적으로 1초를 대기한다.
    await Task.Delay(TimeSpan.FromSeconds(1));
    
    value *= 2;
    
    // 비동기적으로 1초를 대기한다.
    await Task.Delay(TimeSpan.FromSeconds(1));
    
    Trace.WriteLine(value);
}

async 메서드는 다른 메서드와 마찬가지로 동기적으로 실행하기 시작한다.

async 메서드 안의 await 키워드는 인수로 지정한 만큼 비동기적으로 대기한다. 먼저 작업이 끝났는지 확인하고 끝났으면 동기적으로 실행을 계속한다. 아니면 async 메서드를 일시 정지하고 불완전한 작업을 반환한다. 얼마 후에 작업이 끝나면 async 메서드의 실행을 재개한다.

 

첫 번째 동기적 부분은 메서드를 호출한 스레드에서 실행한다.

하지만 다른 부분은 어디에서 실행하는걸까? 답은 조금 복잡하다.

 

흔히 await로 작업을 기다리다가 await가 메서드를 일시 정지하기로 하면 컨텍스트(context)를 저장한다.

널(null)이 아니면 현재 SynchronizationContext를 저장하고, 이때 컨텍스트는 현재 TaskScheduler다. 

메서드는 저장한 컨텍스트 안에서 실행을 재개한다.

ex)

UI 스레드에서 호출했으면 UI 컨텍스트이고, 다른 상황이라면 스레드 풀 컨텍스트다.

참고로 과거 닷넷 코어 버전 전의 ASP.NET 클래식 애플리케이션이면 ASP.NET 요청 컨텍스트일 수도 있다.

ASP.NET 코어는 별도의 요청 컨텍스트가 아닌 스레드 풀 컨텍스트를 사용한다. 

 

따라서 앞서 소개한 코드에서 모든 동기적 부분은 원래 컨텍스트에서 실행을 재개하려고 시도한다.

DoSomethingAsync를 UI 스레드에서 호출하면 각 동기적 부분을 UI 스레드에서 실행하지만, 스레드 풀 스레드에서 호출하면 각 동기적 부분을 스레드 풀 스레드에서 실행한다.

 

ConfigureAwait 확장 메서드의 continueOnCapturedContext 매개 변수에 false를 전달하고 결과를 await로 대기하면 이런 기본 동작을 벗어날 수 있다. 다음 코드는 호출한 스레드에서 시작하지만, await로 일시정지한 뒤에는 스레드 풀 스레드에서 재개한다.

 

async Task DoSomethingAsync()
{
	
	int value = 13;
    
    // 비동기적으로 1초를 대기한다.
    await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false);
    
    value *= 2;
    
    // 비동기적으로 1초를 대기한다.
    await Task.Delay(TimeSpan.FromSeconds(1).ConfigureAwait(false));
    
    Trace.WriteLine(value);
}
항상 코어 라이브러리 메서드 안에서 ConfigureAwait를 호출하고, 필요할 때만 다른 외부 사용자 인터페이스(UI)에서 컨텍스트를 재개하는 것이 좋다.

 

await 키워드는 Task가 아니더라도 정해진 패턴을 따라 대기 가능한 모든 대상에 사용할 수 있다.

기본 클래스 라이브러리에 들어있는 ValueTask<T> 형식이 좋은 예다.

ValueTask<T>는 메모리 내 캐시에서 결과를 읽을 수 있는 등 대개 결과를 동기화하면 메모리 할당을 줄일 수 있는 형식이다.

 

일반적으로 Task가 반환되면 힙에 객체가 할당된다. 이는 오버헤드를 키우는 일이고 만약 수행 중인 작업의 결과를 즉시 사용하는 경우거나 동기식으로 사용하는 경우에는 할당이 필요없다. ValueTask의 경우에는 힙에 할당을 하지 않아서 불필요한 오버헤드를 줄일 수 있다는 장점이 있다.

 

ValueTask<T>는 Task<T>로 바로 변환할 수 없지만, 대기 가능한 패턴을 따르므로 직접 await로 대기할 수 있다.

그 밖에 다른 예도 있고, 직접 만들 수도 있지만 대개 await의 대상은 Task나 Task<TResult>다.

 

추가 참고

https://www.infoworld.com/article/3565433/how-to-use-valuetask-in-csharp.html

 

How to use ValueTask in C#

Take advantage of ValueTask in C# to avoid allocation when returning task objects from an asynchronous method

www.infoworld.com

 

Task 인스턴스를 만드는 방법은 기본적으로 두 가지다. CPU가 실행해야할 실제 코드를 나타내는 계산 작업은 Task.Run을 호출해서 생성해야 한다. 단, 특정 스케줄러에서 실행해야 한다면 TaskFactor.StartNew를 호출한다. 

 

그 밖에 알림을 나타내는 작업 같은 이벤트 기반 작업은 TaskCompletionSource<TResult> 또는 이 메서드의 축약형으로 생성한다. 대부분 I/O작업은 TaskCompletionSource<TResult>를 사용한다.

 

async와 await는 기본적으로 오류 처리가 필요하다. 잡힌 예외는 자체적으로 적절한 스택 Trace를 보존하고 있다.

async 메서드는 예외가 발생하거나 예외를 전파(propagate)할 때 반환할 Task에 해당 예외를 배치한 다음에 Task를 완료 한다.

만약 Task가 대기 상태이면 await 연산자가 예외를 수신하고 원래 스택 추적을 보존한 채로 다시 예외를 일으킨다.

따라서 다음과 같은 코드는 PossibleExceptionAsync가 async 메서드면 예상대로 동작한다.

async Task TrySomethingAsync() 
{
	// 예외는 바로 일어나지 않고 Task에서 발생한다.
    Task task = PossibleExceptionAsync();
    
    try
    {
    	// 여기 await에서 Task의 예외가 발생한다.
        await task;
    }
    catch (NotSupportedException ex)
    {
    	LogException(ex);
        throw;
    }
}

 

async 메서드에 관해 중요한 지침이 하나 더 있다.

async를 사용하기 시작했다면 코드를 통해 확장해 나가는 것이 좋다.

async 메서드를 호출하면 결국 작업의 반환을 기다려야 한다.

Task.Wait, Task<TResult>.Result 또는 GetAwaiter().GetResult()를 호출하고 싶은 유혹을 이겨 내야 한다.

자칫하면 교착상태(Deadlock, 데드락)을 초래할 수 있기 때문이다.

 

async Task WaitAsync()
{
	// 이 await는 현재 컨텍스트를 저장하고...
    await Task.Delay(TimeSpan.FromSeconds(1));
    
    // ... 여기에서 저장한 컨텍스트 안에서 메서드를 재개하려고 시도한다.
    
}

void Deadlock()
{
	// 지연 시작
    Task task = WaitAsync();
    
    // 동기적으로 차단하고 async 메서드의 완료를 기다린다.(물론 완료되지 못한다)
    task.Wait();
}

이 코드는 UI 컨텍스트나 ASP.NET 클래식 컨텍스트에서 호출하면 교착 상태에 빠진다.

두 컨텍스트 모두 한 번에 하나의 스레드만 허용하기 때문이다.

Deadlock은 지연을 시작하는 WaitAsync를 호출한다. 그리고 Deadlock은 동기적으로 메서드의 완료를 대기하면서 컨텍스트 스레드를 차단한다. 지연이 끝나면 await가 저장한 컨텍스트 안에서 WaitAsync를 재개하려고 시도하지만, 이미 컨텍스트에서 차단한 스레드이고 컨텍스트는 한 번에 하나의 스레드만 허용하므로 재개할 수 없다.💡

 

교착상태는 두 가지 방법으로 예방할 수 있다.

1. WaitAsync 안에서 ConfigureAwait(false)를 사용해서 await가 컨텍스트를 무시하게 하는 방법

2. WaitAsync 호출을 await로 대기해서 Deadlock를 async 메서드로 만드는 방법이 있다.

 

async를 사용하면 끝까지 async를 사용하는 게 좋다.

📌 추가 참고

https://docs.microsoft.com/ko-kr/dotnet/csharp/async

 

비동기 프로그래밍 - C#

.NET Core에서 제공하는 C# 언어 수준 비동기 프로그래밍 모델에 대해 알아봅니다.

docs.microsoft.com

https://docs.microsoft.com/ko-kr/dotnet/standard/asynchronous-programming-patterns/task-based-asynchronous-pattern-tap

 

TAP(작업 기반 비동기 패턴): 소개 및 개요

TAP(작업 기반 비동기 패턴)에 대해 알아보고 APM(비동기 프로그래밍 모델) 및 이벤트 기반 EAP(비동기 패턴)와 레거시 패턴과 비교합니다.

docs.microsoft.com

비동기 스트림(Asynchronous Stream)은 async와 await를 토대로 다수의 값을 처리할 수 있게 만들어졌다.

비동기 스트림은 비동기 Enumerable의 개념을 바탕으로 만들어졌고, enumerable은 연속적인 데이터에서 다음 데이터를 검색할 때 비동기적으로 작업할 수 있다는 점 말고는 일반적인 enumerable과 다를 바 없다.

비동기 스트림은 매우 강력한 개념으로 일련의 데이터가 한 번에 하나씩 또는 chunk로 도착할 때 특히 유용하다.

예를 들어 limit와 offset을 매개 변수로 paging을 사용하는 API의 응답을 처리하는 애플리케이션이면 비동기 스트림이 이상적인 추상화다.

 

병렬 프로그래밍

독립적으로 나눌 수 있는 계산 작업이 많다면 언제든 병렬 프로그래밍을 사용해야 한다.

병렬 프로그래밍은 일시적으로 CPU 사용량을 늘려서 처리량을 개선하는 방법이라 CPU가 유휴 상태일 때가 많은 클라이언트 시스템에는 바람직하지만 대개 서버 시스템에는 적합하지 않다.

대부분 서버는 기본적으로 몇 가지 병렬 기능을 지닌다. 예를 들어 ASP.NET은 복수의 요청을 병렬로 처리한다.

동시에 접속하는 사용자 수가 항상 적다는 점을 알고 있는 상황 등 서버에서 병렬 코드를 작성해도 유용한 상황이 여전히 있을 수 있다. 하지만 일반적으로 서버에서의 병렬 프로그래밍은 서버의 기본 병렬 처리 기능과 충돌하므로 실질적인 이익이 없다.

 

병렬 프로그래밍은 데이터 병렬과 작업 병렬 두 가지 형태로 나뉜다.

데이터 병렬은 처리해야 할 데이터 항목이 여럿이고 각 데이터 항목을 대부분 다른 데이터 항목과 독립적으로 처리할 수 있을 때 해당한다.

작업 병렬은 처리해야 할 작업 풀이 있고 각 작업이 대부분 다른 작업과 독립적일 때 해당한다. 작업 병렬은 동적일 수 있다.

하나의 작업 때문에 여러 개의 추가 작업이 생기면 모두 작업 풀에 추가할 수 있다.

 

데이터 병렬

데이터 병렬에는 몇 가지 다른 방식이 있다. 뒤에서 소개할 Parallel.ForEach는 foreach 루프와 비슷하여 사용할 수 있으면 최대한 사용해야 한다. 마찬가지로 Parallel 클래스가 지원하는 Parallel.For는 for 루프와 비슷하고 인덱스에 따라 데이터 처리가 달라져야 할 때 사용할 수 있다.

 

void RotateMatrices(IEnumerable<Matrix> matriecs, float degrees)
{
	Paralle.ForEach(matrices, matrix => matrix.Rotate(degrees));
}

그 밖의 선택지로 LINQ 쿼리용 AsParallel 확장 메서드를 제공하는 PLINQ(Parallel LINQ)가 있다.

Parallel은 PLINQ보다 리소스 친화적이다.

PLINQ는 기본적으로 모든 CPU에 퍼지려고 노력하지만 Parallel은 시스템 안의 다른 프로세스와 잘 어우러져서 동작한다.

Parallel의 단점은 명시적으로 작성해야 해서 코드가 길어질 수 있다는 점이다.

PLINQ의 코드가 훨씬 우아할 때가 많다.

IEnumerable<bool> PrimalityTest(IEnumerable<int> values)
{
	return values.AsParalle().Select(value => IsPrime(value));
}
작업은 가능한 다른 작업과 독립적이어야 한다.

모든 작업이 독립적인 작업이면 병렬 처리를 극대화할 수 있다.

여러 스레드가 상태를 공유하기 시작하면 즉시 공유한 상태로의 접근을 동기화해야 하므로 애플리케이션의 병렬성이 떨어진다.

 

병렬 처리의 결과는 다양한 방법으로 처리할 수 있다.

결과를 동시 컬렉션에 매핑하거나 집계해서 요약할 수 있다. 집계는 병렬 처리에서 흔한 일이다.

이런 매핑이나 집계도 Parallel 클래스의 메서드 오버로드를 통해 지원한다.

 

작업 병렬

데이터 처리에 초점을 맞추고 있으며 작업 병렬은 작업의 수행과 관련이 있다. 상위 레벨에서 보면 '데이터 처리'는 일종의 '작업'이므로 데이터 병렬과 작업 병렬은 비슷하다.

 

Parallel.Invoke는  Parallel 메서드의 하나로 일종의 분기/병합 작업 병렬을 수행한다. 이 메서드에 병렬로 실행하고 싶은 대리자만 전달하면 된다.

void ProcessArray(double[] array)
{
	Parallel.Invoke(
    	() => ProcessPartialArray(array, 0, array.Length / 2),
        () => ProcessPartialArray(array, array.Length / 2, array.Length)
    );
}

void ProcessPartialArray(double[] array, int begin, int end)
{
	// CPU 집약적인 처리를 한다.
}

Task 형식은 원래 작업 병렬용으로 만들어졌지만 요즘은 비동기 프로그래밍에도 쓰인다.

Task 인스턴스는 작업 병렬에서 쓰일 때처럼 작업을 나타낸다.

Wait 메서드를 사용하면 작업이 완료할 때까지 대기할 수 있고, Result와 Exception 속성을 사용하면 작업의 결과를 얻을 수 있다.

Task를 직접 사용하는 코드는 Parallel을 사용하는 코드보다 훨씬 복잡하지만 런타임까지 이어지는 병렬 처리의 구조를 잘 모른다면 유용할 수 있다. 이런 동적 병렬 처리는 시작할 때 필요한 작업의 수를 알 수 없고 진행하면서 알 수 있다. 일반적으로 동적 작업은 필요한 모든 하위 작업을 시작한 뒤에 하위 작업의 완료를 기다려야 한다. Task 형식에는 이럴 때 사용할 수 있는 특별한 플래그인 TaskCreationOptions.AttachedParent가 있다.

 

작업 병렬도 데이터 병렬과 마찬가지로 최대한 독립적이어야 한다. 대리자가 독립적일수록 프로그램이 더 효율적일 수 있다.

또 대리자가 독립적이지 않으면 동기화가 필요한 코드는 올바르게 작성하기 어렵다.

작업 병렬에서는 특히 클로저(closure) 안에 캡처한 변수에 주의해야 한다. 클로저는 값이 아닌 참조를 캡처하므로 명확하지 않은 공유가 일어날 수 있다는 점을 기억해야 한다.

 

모든 병렬 처리의 오류 처리는 비슷하다. 작업을 병렬로 진행하므로 복수의 예외가 발생할 수 있어서 예외를 AggregateException으로 감싼 뒤에 코드로 보낸다. Parallel.ForEach, Parallel.Invoke, Task.Wait 등 모두 한결같이 이렇게 동작한다.

AggregateException에는 Flatten, Handle처럼 오류 처리 코드를 쉽게 만들어 주는 유용한 메서드가 있다.

try
{
	Parallel.Invoke(() => { throw new Exception(); },
    () => { throw new Exception(); } });
}
catch (AggregateException ex)
{
	ex.Handle(exception =>
    {
    	Trace.WriteLine(exception);
        return true;	// 처리함
    });
}

대개 스레드 풀이 작업을 처리하는 방식은 걱정할 필요 없다.

데이터 병렬과 작업 병렬은 동적으로 조절할 수 있는 파티셔너를 사용해서 작업을 작업 스레드에 분배한다.

스레드 풀은 필요에 따라 스레드 수를 늘린다. 스레드 풀은 하나의 작업 큐(Queue)를 지니며 각 스레드 풀 스레드도 자체적으로 작업 큐를 지닌다. 스레드 풀 스레드가 추가 작업을 큐에 넣을 때는 먼저 자신의 큐로 보낸다. 대개 현재 작업과 연관이 있기 때문이다.

이렇게 해서 스레드가 자신의 작업에 몰두하게 하며 캐시 히트(Cache Hit)를 극대화한다.

할 일이 없는 스레드는 다른 스레드의 큐에서 작업을 가져온다.

 

작업은 너무 짧거나 너무 길지 않아야 한다.

작업이 너무 짧으면 데이터를 작업에 분배하고 스레드 풀에 작업을 스케줄링하는 데 드는 부담이 커진다.

작업이 너무 길면 스레드 풀이 동적으로 작업의 균형을 맞추기는 어렵다.

얼마나 짧아야 너무 짧다고 할지, 얼마나 길어야 너무 길다고 할지 결정하기는 어렵다.

실제로 해결할 문제와 대략적인 하드웨어의 성능에 따라 달라진다. 개인적으로는 성능 문제만 없다면 작업을 최대한 짧게 만들려 한다. 하지만 작업이 너무 짧아지면 성능이 급격히 떨어지므로 작업을 직접 다루지 말고 Parallel 형식 또는 PLINQ를 사용하는 쪽이 더 좋다.

이런 상위 레벨의 병렬 처리 방식에 기본적으로 들어 있는 파티셔닝 기능은 작업의 길이 문제를 자동으로 처리해 준다.

필요에 따라 런타임에 조절하기도 한다.

 

 

리액티브 프로그래밍

리액티브 프로그래밍은 다른 형태의 동시성과 비교해서 배우기 어렵고 리액티브 기술에 뒤처지지 않으려고 애쓰지 않으면 코드를 유지 보수하기도 어려울 수 있다. 하지만 기꺼이 배우고자 한다면 리액티브 프로그래밍은 매우 강력하다. 리액티브 프로그래밍을 사용하면 이벤트 스트림을 데이터 스트림처럼 다룰 수 있다. 개인적인 경험상 이벤트로 전해진 이벤트 인수를 사용하면 코드에서 일반적인 이벤트 핸들러가 아닌 System.Reactive를 사용하는 장점을 누릴 수 있다.

 

이미 잘 알고 있는 LINQ 쿼리와 매우 비슷하지만, LINQ to Objects와 LINQ to Entities가 LINQ 쿼리를 나열하고 쿼리를 통해 데이터를 끌어오는 pull 모델을 사용하는 반면 LINQ to Events 즉 System.Reactive는 이벤트가 도착하면 쿼리를 통해 스스로 이동하는 push 모델을 사용한다는 점이 가장 큰 차이점이다.

 

IObservable<DateTimeOffset> timestamps = Observable.Interval(Timespan.FromSeconds(1))
	.Timestamp()
    .Where(x = > x.Value % 2 == 0)
    .Select(x => x.Timestamp);
timestamps.Subscribe(x => Trace.WriteLine(x),
	ex => Trace.WriteLine(ex));

옵저버블 스트림으로 정의한 형식은 IObservable<TResult> 리소스로 사용할 수 있게 만드는 게 일반적이다.

그런 다음 다른 형식으로 이 스트림을 구독하거나 연산자를 통해 다른 형식과 합쳐서 새로운 옵저버블 스트림을 만들 수 있다.

 

System.Reactive 구독 역시 리소스다. Subscribe 연산자는 구독을 나타내는 IDisposable을 반환한다.

코드에서 옵저버블 스트림의 수신을 완료하면 구독을 삭제해야 한다.

 

구독은 Hot Observable, Cold Observable에서 다르게 동작한다.

 

Hot Observable은 언제든 발생할 수 있는 이벤트 스트림으로 이벤트가 발생할 때 구독이 없으면 해당 이벤트는 사라진다. 예를 들어 마우스 이동은 핫 옵저버블이다.

 

Cold Observable는 자동으로 발생하는 이벤트가 아예 없는 옵저버블이다. 콜드 옵저버블은 구독에 대응해서 이벤트를 순서대로 발생하기 시작한다. 예를 들어 구독을 시작해야 HTTP 요청을 전달하는 HTTP 다운로드는 콜드 옵저버블이다.

 

Subscribe 연산자는 항상 오류 처리 매개 변수를 같이 받아야 한다. 

 

멀티 스레드 프로그래밍

스레드는 독립적인 실행 단위다.💥🔥

프로세스는 여러 개의 스레드를 지니며 각 스레드는 동시에 다른 작업을 수행할 수 있다.

스레드는 자체적으로 독립적인 스택을 지니지만, 프로세스 안의 다른 모든 스레드와 같은 메모리를 공유한다.

특별한 스레드를 지니는 애플리케이션도 있다. 예를 들어 사용자 인터페이스(UI) 애플리케이션은 하나의 특별한 UI 스레드를 지니고, 콘솔 애플리케이션은 하나의 특별한 main 스레드를 지닌다.

 

모든 닷넷 애플리케이션은 스레드 풀을 지닌다. 스레드 풀에는 개발자가 작업을 시킬 때까지 대기하는 다수의 작업 스레드가 들어 있고, 스레드 풀은 언제든 스레드 수를 변경할 수 있다. 스레드 풀의 동작을 변경할 수 있는 설정이 많이 있지만, 스레드 풀은 대부분 실제 상황을 다룰 수 있게 세심하게 다듬어져 왔으므로 그대로 두는 편이 좋다.

 

개발자가 직접 새로운 스레드를 만들 필요는 거의 없다. Thread 인스턴스를 만들어야 할 때는 COM interop용 STA 스레드가 필요할 때뿐이다.

 

스레드는 하위 레벨 추상화다. 스레드 풀은 조금 더 상위 레벨 추상화다. 스레드 풀에 작업을 할당하면 스레드 풀은 필요에 따라 스스로 스레드를 생성한다.

 

동시성 애플리케이션용 컬렉션

동시 컬렉션, 불변 컬렉션의 범주에 속하는 컬렉션은 동시성 프로그래밍에 유용하다.

 

동시 컬렉션을 사용하면 동시에 여러 스레드가 컬렉션을 안전하게 업데이트할 수 있다.

대부분 동시 컬렉션은 하나의 스레드가 값을 살펴보는 동안 다른 스레드가 값을 추가하거나 삭제할 수 있게 하려고 스냅샷(Snapshot)을 사용한다. 동시 컬렉션은 대개 평범한 컬렉션을 잠금으로 보호하는 방식보다 더 효율적이다.

 

불변 컬렉션은 조금 다르다. 불변 컬렉션은 실제로 수정할 수 없다. 불변 컬렉션을 수정하려면 수정한 컬렉션을 나타내는 새로운 컬렉션을 생성해야 한다. 엄청나게 비효율적일 듯하지만, 불변 컬렉션은 컬렉션 인스턴스끼리  최대한 많은 메모리를 공유하므로 생각보다 나쁘지 않다. 불변 컬렉션의 장점은 모든 연산자가 순수 연산자라 함수형 코드에 매우 적합하다는 점이다.

 

 

 

정리

📌 동시성의 형태들

- 멀티 스레딩: 다수의 실행 스레드를 사용하는 동시성의 한 형태. 병렬처리는 멀티스레딩의 일종이다.

- 비동기 프로그래밍: 불필요한 스레드의 사용을 피하려고 future나 callback을 사용하는 동시성의 한 형태

- 리액티브 프로그래밍: 애플리케이션이 이벤트에 대응하게 하는 선언형 프로그래밍 방식

 

📌 병렬 프로그래밍의 주의점

모든 작업이 독립적인 작업이면 병렬 처리를 극대화할 수 있다.

여러 스레드가 상태를 공유하기 시작하면 즉시 공유한 상태로의 접근을 동기화해야 하므로 애플리케이션의 병렬성이 떨어진다.

 

 

참고

http://www.yes24.com/Product/Goods/101507577

 

C# 동시성 프로그래밍 2/e - YES24

비동기, 병렬 처리, 데이터 흐름, 멀티스레딩 등 최신 닷넷 프레임워크와 C# 언어가 제공하는 동시성 기술과 함께 리액티브 프로그래밍, 동시성 기술의 상호운용, 동시 컬렉션, 테스트, 취소 기능

www.yes24.com