내용 보기

작성자

관리자 (IP : 172.17.0.1)

날짜

2021-11-29 12:14

제목

ConfigureAwait FAQ / ​SynchronizationContext는 무엇인가? / TaskScheduler란 무엇인가?


https://devblogs.microsoft.com/dotnet/configureawait-faq/



.NET는 몇년 전에 언어와 라이브러리에 async/await를 추가했다. 그당시 이것은 들불과 같이 유행했는데, .NET 생태계에 걸쳐서 뿐만 아니라 수많은 다른 언어와 프레임워크에서 복제되었다. 이것은 또한 .NET에서 많이 개선되었는데, 비동기성을 활용하는 추가적인 언어 구성체, 비동기 지원을 제공하는 API 그리고 async/await를 확인하게 하는 내부구조상의 근본적인 개선이 이루어졌다.


하지만 계속 의문이 생기는 async/await의 한가지 국면이 ConfigureAwait이다. 이번 포스트에서 나는 이 질문들에 답할 것이다. 


ConfigureAwait를 진짜로 이해하려면, 좀 더 일찍 시작해야 한다.



​SynchronizationContext는 무엇인가? 


문서에는 System.Threading.SynchronizationContext는 "다양한 동기화 모델에서 동기화 문맥을 전파하기 위한 기본 기능을 제공한다"라고 한다. 이것은 완전히 명백한 설명은 아니다.


99.9%의 경우, SynchronizationContext는 단지 가상 Post 메소드를 제공하는 타입인데, 이 메소드는 비동기적으로 수행되는 델리게이트를 취한다. 기반 타입의 Post는 말 그대로 ThreadPool.QueueUserWorkItem을 호출하여 제공된 델리게이트를 비동기적으로 호출한다. 하지만 파생 타입은 Post를 재정의하여 그 델리게이트가 가장 적당한 곳과 가장 적당한 시간에 수행될 수 있게 한다.


예를 들어, Windows Form은 SynchronizationContext 파생 타입을 가지는데, 이것은 Post를 재정의하여 Control.BeginInvoke와 동일한 일을 한다. 즉 메시지들을 UI 스레드 상에서 동작하도록 한다. Windows Presentation Foundation(WPF)와 Windows Runtime도 이와 같은 파생 타입들을 가진다.


이것은 단지 "UI 스레드 상에서 해당 델리게이트를 동작시키는 것" 이상이다. 누구나 어떤 것이든 할 수 있는 Post를 가지는 SynchronizationContext를 구현할 수 있다. 예를 들어 델리게이트가 어떤 스레드 상에서 동작하는지는 관심없지만, 나의 SynchronizationContext에 전달된 모든 델리게이트들은 제한된 동시성 정도를 가지고 수행됨을 보장하고 싶다면, 다음과 같이 구현할 수 있다.


internal sealed class MaxConcurrencySynchronizationContext : SynchronizationContext
 
 {
 
     private readonly SemaphoreSlim _semaphore;
 
 
 
     public MaxConcurrencySynchronizationContext(int maxConcurrencyLevel) =>
 
         _semaphore = new SemaphoreSlim(maxConcurrencyLevel);
 
 
 
     public override void Post(SendOrPostCallback d, object state) =>
 
         _semaphore.WaitAsync().ContinueWith(delegate
 
         {
 
             try { d(state); } finally { _semaphore.Release(); }
 
         }, default, TaskContinuatioonOptions.None, TaskScheduler.Default);
 
 
 
     public override void Send(SendOrPostCallback d, object state)
 
     {
 
         _semaphore.Wait();
 
         try { d(state); } finally { _semaphore.Release(); }
 
     }
 
 }
cs

사실, 단위 테스트 프레임워크 xunit는 이것과 매우 비슷하게 SynchronizationContext를 제공하는데, 이것은 동시에 수행될 수 있는 테스트 코드의 양을 제한한다.


이 모든 것들의 이점은 추상화와 동일하다: 이것은 처리를 위해 델리게이트를 큐잉하는데 사용될 수 있는 단일 API를 제공하는데, 구현자는 그 구현의 자세한 내용이 알려지는 것을 바라지 않는다. 그러므로 만약 내가 라이브러리를 작성하고 있고, 어떤 작업을 하고 그런 다음 델리게이트를 그 원래 위치의 "문맥"으로 큐잉하고자 한다면, 단지 SynchronizationContext를 가지고 있다가 일을 마쳤을 때 그 문맥의 Post를 호출한다. 나는 Control을 가지고 있다가 BeginInvoke를 사용해야 하는 Windows Form을, 혹은 Dispatcher를 가지고 있다가 BeginInvoke를 사용해야 하는 WPF를, 아니면 xunit를 알 필요가 없다. 나는 단순히 현재의 SynchronizationContext만 가지고 있다가 나중에 사용하면 된다. 이렇게 하기 위해 SynchronizationContext는 Current 속성을 제공한다.


public void DoWork(Action worker, Action completion)
 
 {
 
     SynchronizationContext sc = SynchronizationContext.Current;
 
     ThreadPool.QueueUserWorkItem(_ =>
 
     {
 
         try { worker(); }
 
         finally { sc.Post(_ => coompletion(), null); }
 
     });
 
 }
cs

Current 속성으로 커스텀 문맥을 노출하려는 프레임워크는 SynchronizationContext.SetSynchronizationContext 메소드를 사용한다.



​TaskScheduler란 무엇인가? 


SynchronizationContext는 "스케줄러"에 대한 일반적 추상화이다. 개별 프레임워크는 때때로 어떤 스케줄러를 위해 자신만의 추상화를 가지는데, System.Threading.Tasks도 예외는 아니다. Task들이 델리게이트로 지원되어 큐잉되고 수행될 수 있을 때, 이들은 System.Threading.Tasks.TaskScheduler와 연관된다. SynchronizationContext가 가상 Post 메소드를 제공하여 델리게이트의 호출을 큐잉하는 것처럼, TaskScheduler는 추상 QueueTask 메소드를 제공한다.


TaskScheduler.Default에 의해 반환되는 기본 스케줄러는 스레드풀인데, TaskScheduler로부터 파생시켜서 적절한 메소드들을 재정의하여 Task가 호출될 때와 장소를 위해 임의의 행동을 구현할 수 있다.


SynchronizationContext와 같이, TaskScheduler 또한 Current 속성을 가지고 있는데, 이 속성은 "현재"의 TaskScheduler를 반환한다. 하지만 SynchronizationContext와는 달리 현재 스케줄러를 설정하는 메소드는 없다. 대신에 현재 스케줄러는 현재 동작중인 Task와 연관된 스케줄러이고, 스케줄러는 Task를 시작할 때 시스템에 제공된다.


class Program
 
 {
 
     static void Main()
 
     {
 
         var cesp = new ConcurrentExclusiveSchedulerPair();
 
         Task.Factory.StartNew(() =>
 
         {
             Console.WriteLine(TaskScheduler.Current == cesp.ExclusiveScheduler);
 
         }, default, TaskCreationOptions.None, cesp.ExclusiveScheduler).Wait();
 
     }
 
 }
cs

흥미롭게도 TaskScheduler는 정적 FromCurrentSynchronizationContext 메소드를 제공하는데, 이것은 SynchronizationContext.Current 가 반환하는 것에서 동작시키기 위해 Task들을 큐잉하는 새로운 TaskScheduler를 만들고, Task들을 큐잉하기 위해 Post 메소드를 사용한다.



​SynchronizationContext와 TaskScheduler는 어떻게 await와 연관되어 있는가?


​Button을 가지고 있는 UI 앱을 작성한다고 해보자. Button을 클릭하면 웹싸이트로부터 텍스트를 다운받아 Button의 Content에 설정하고자 한다. Button은 이것을 소유하고 있는 UI 스레드에서만 접근되어야 한다.


private static readonly HttpClient s_httpClient = new HttpClient();
 
 
 
 private void downloadBtn_Click(object sender, RoutedEventArgs e)
 
 {
 
     SynchronizationContext sc = SynchronizationContext.Current;
 
     s_httpClient.GetStringAsync("http://example.com/currenttime").ContinueWith(task =>
 
     {
 
         sc.Post(delegate
 
         {
 
             downloadBtn.Context = task.Result;
 
         }, null);
 
     });
 
 }
cs

하지만 이들 모두는 명시적으로 콜백을 사용해야 한다.

대신에 우리는 async/await로 자연스런 코드를 작성할 수 있다.


private static readonly HttpClient s_httpClient = new HttpClient();
 
 
 
 private async void downloadBtn_Click(object sender, RoutedEventArgs e)
 
 {
 
     string text = await s_httpClient.GetStringAsync("http://example.com/currenttime");
 
     downloadBtn.Context = text;
 
 }
cs

이것은 "단순히 동작"하는데, UI 스레드 상에서 Content를 설정한다. 왜냐하면 위의 수동으로 구현한 버전과 마찬가지로 Task를 await하는 것은 기본적으로 SynchronizationContext.Current 뿐만 아니라 TaskScheduler.Current에 접근하기 때문이다. 여러분은 C#에서 어떤 것이든 await를 하면, 컴파일러는 "awaiter"에 대해(이번 경우 TaskAwaiter<string>) "awaitable"을(이번 경우 Task)를 요청하는 코드로 변환시킨다. awaiter는 기다려지는 객체가 완료되었을 때 상태머신에서 호출되는 콜백을 연결시킬 책임이 있고, 콜백이 등록되는 시점에 캡쳐되는 Context/scheduler가 무엇이든지 간에 그것을 사용한다. 여기서 사용되는 코드는 정확하지는 않지만 대충 다음과 같다.


object scheduler = SynchronizationContext.Current;
 
 if (scheduler is null && TaskScheduler.Current != TaskScheduler.Default)
 
 {
 
     scheduler = TaskScheduler.Current;
 
 }
cs



출처1

https://m.blog.naver.com/PostView.naver?isHttpsRedirect=true&blogId=oidoman&logNo=221791183855

출처2