Microsoft MVP성태의 닷넷 이야기
.NET Framework: 2077. C# - 직접 만들어 보는 SynchronizationContext [링크 복사], [링크+제목 복사],
조회: 7398
글쓴 사람
정성태 (techsharer at outlook.com)
홈페이지
첨부 파일

(시리즈 글이 9개 있습니다.)
.NET Framework: 612. UWP(유니버설 윈도우 플랫폼) 앱에서 콜백 함수 내에서의 UI 요소 접근 방법
; https://www.sysnet.pe.kr/2/0/11071

.NET Framework: 680. C# - 작업자(Worker) 스레드와 UI 스레드
; https://www.sysnet.pe.kr/2/0/11287

.NET Framework: 777. UI 요소의 접근은 반드시 그 UI를 만든 스레드에서!
; https://www.sysnet.pe.kr/2/0/11561

.NET Framework: 805. 두 개의 윈도우를 각각 실행하는 방법(Windows Forms, WPF)
; https://www.sysnet.pe.kr/2/0/11802

.NET Framework: 886. C# - Console 응용 프로그램에서 UI 스레드 구현 방법
; https://www.sysnet.pe.kr/2/0/12139

.NET Framework: 911. Console/Service Application을 위한 SynchronizationContext - AsyncContext
; https://www.sysnet.pe.kr/2/0/12231

.NET Framework: 1022. UI 요소의 접근은 반드시 그 UI를 만든 스레드에서! - 두 번째 이야기
; https://www.sysnet.pe.kr/2/0/12537

.NET Framework: 2076. C# - SynchronizationContext 기본 사용법
; https://www.sysnet.pe.kr/2/0/13190

.NET Framework: 2077. C# - 직접 만들어 보는 SynchronizationContext
; https://www.sysnet.pe.kr/2/0/13191




C# - 직접 만들어 보는 SynchronizationContext

지난 글에,

C# - SynchronizationContext 기본 사용법
; https://www.sysnet.pe.kr/2/0/13190

SynchronizationContext를 소개했는데요, 이번 글에서는 우리가 직접 SynchronizationContext를 만들어보겠습니다. ^^ (2개 만들어 볼 것입니다.)

우선 첫 번째 예제는 간단한 걸로, WindowsFormsSynchronizationContext와 동일한 역할을 하는 SynchronizationContext는 어떨까요? ^^ 사실 Control.Invoke/BeginInvoke로 호출을 대행하면 되므로 단 몇 줄의 코드로 쉽게 구현할 수 있습니다.

class MyWinFormsSynchronizationContext : SynchronizationContext
{
    private readonly Control _proxy;

    public MyWinFormsSynchronizationContext(Control control)
    {
        _proxy = control;
    }

    public override void Post(SendOrPostCallback d, object? state)
    {
        _proxy.BeginInvoke(() =>
        {
            d(state);
        });
    }

    public override void Send(SendOrPostCallback d, object? state)
    {
        _proxy.Invoke(() =>
        {
            d(state);
        });
    }
}

그럼, Windows Forms의 UI 스레드에 설정된 WindowsFormsSynchronizationContext를 다음과 같이 우리가 만든 타입으로 교체할 수 있습니다.

public partial class Form1 : Form
{
    MyWinFormsSynchronizationContext _syncContext;
    public Form1()
    {
        _syncContext = new MyWinFormsSynchronizationContext(this);
        SynchronizationContext.SetSynchronizationContext(_syncContext);
        InitializeComponent();
    }
}

실제로 동작하는지 테스트를 해봐야겠죠? ^^ 지난 글에, Task 타입에서 SynchronizationContext와 연동하는 코드를 소개했으니, 따라서 해당 코드를 그대로 사용해 보면,

public partial class Form1 : Form
{
    // ...[생략]...

    private void button1_Click(object sender, EventArgs e)
    {
        Task.Factory.StartNew(() =>
        {
            this.button1.Text = "TEST"; // MyWinFormsSynchronizationContext를 통해 UI 스레드에서 실행
        }, CancellationToken.None, TaskCreationOptions.None, TaskScheduler.FromCurrentSynchronizationContext());
    }
}

잘 동작하는 것을 확인할 수 있습니다. 물론 SynchronizationContext를 직접 사용하는 코드도,

private void button2_Click(object sender, EventArgs e)
{
    SynchronizationContext? ctx = SynchronizationContext.Current; // MyWinFormsSynchronizationContext

    Task.Run(() =>
    {
        ctx?.Post((arg1) =>
        {
            this.button2.Text = "TEST"; // MyWinFormsSynchronizationContext를 통해 UI 스레드에서 실행
        }, null);
    });
}

잘 동작합니다.




SynchronizationContext는 TaskScheduler와도,

C# - 직접 만들어 보는 TaskScheduler 실습 (SingleThreadTaskScheduler)
; https://www.sysnet.pe.kr/2/0/13188

엮일 수 있습니다. 사실, 이번 글을 쓰려고 TaskScheduler를 먼저 정리하는 글을 썼던 것입니다. ^^ 즉, 어떤 TaskScheduler를 사용했느냐에 따라 SynchronizationContext의 사용 여부가 결정되는데요, 일단 현재 나온 것들 중에는 SynchronizationContextTaskScheduler가 유일합니다.

그럼, 우리가 위에서 만든 MyWinFormsSynchronizationContext가 각각의 TaskScheduler 상황에서 어떻게 동작하는지 간단하게 테스트를 해보는 것도 이해를 높인다는 차원에서 좋을 듯한데요, 이를 위해 MyWinFormsSynchronizationContext의 Post에 다음과 같이 스레드 ID를 함께 출력하는 코드를 넣어보겠습니다.

public override void Post(SendOrPostCallback d, object? state)
{
    System.Diagnostics.Trace.WriteLine($"{Thread.CurrentThread.ManagedThreadId}: Post");
    _proxy.BeginInvoke(() =>
        {
            System.Diagnostics.Trace.WriteLine($"{Thread.CurrentThread.ManagedThreadId}: User Work");
            d(state);
        }
    );
}

이후 앞서 실습했던 (순서를 바꿔 button1_Click 대신) button2_Click을 실행해 보면 다음과 같은 메시지를 볼 수 있습니다.

private void button2_Click(object sender, EventArgs e)
{
    SynchronizationContext? ctx = SynchronizationContext.Current;

    Task.Run(() =>
    {
        ctx?.Post((arg1) =>
        {
            this.button2.Text = "TEST";
        }, null);
    });
}

/* 출력 결과
9: Post
1: User Work
*/

예측 가능한 결과입니다. Task.Run은 ThreadPoolTaskScheduler를 사용하므로 ThreadPool의 여유 스레드에 작업을 태워 실행하기 때문에 Post 메서드가 9번 스레드에 의해 실행은 되지만 "User Work"은 Control.BeginInvoke에 의해 UI 스레드에서 실행되었습니다.

반면, button1_Click의 실행은 어떨까요?

private void button1_Click(object sender, EventArgs e)
{
    Task.Factory.StartNew(() =>
    {
        this.button1.Text = "TEST"; // UI 스레드에서 실행
    }, CancellationToken.None, TaskCreationOptions.None, TaskScheduler.FromCurrentSynchronizationContext());
}

1: Post
1: User Work

그러니까, TaskScheduler.FromCurrentSynchronizationContext로 선택된 SynchronizationContextTaskScheduler로 인해 애당초 2차 스레드가 관여하지 않아 MyWinFormsSynchronizationContext 역시 결과적으로는 딱히 스레드의 변동이 없어 필요 없기까지 한 상황입니다. 즉, Post 메서드의 구현이 BeginInvoke를 호출하지 않아도 되는 상황이었던 것입니다.

따라서 좀 더 성능을 높이기 위해 다음과 같은 식으로 개선할 수 있습니다.

public override void Post(SendOrPostCallback d, object? state)
{
    if (_proxy.InvokeRequired)
    {
        _proxy.BeginInvoke(() =>
        {
            d(state);
        }
        );
    }
    else
    {
        d(state); // SynchronizationContextTaskScheduler인 경우, 직접 호출
    }
}

그럼, 혹시 처음부터 SynchronizationContextTaskScheduler 측에서 SynchronizationContext.Post로 넘길 필요조차 없지 않았을까요?

// SynchronizationContextTaskScheduler

protected override void QueueTask(Task task)
{
    // 아래의 코드 대신 그냥 "s_postCallback(task);"로 호출하는 코드로 바꾼다면?
    m_synchronizationContext.Post(s_postCallback, (object)task);
}

하지만, 그런 식으로 바꾸면 안 되는 상황이 하나 있습니다.

private void button1_Click(object sender, EventArgs e)
{
    var ctx = TaskScheduler.FromCurrentSynchronizationContext();

    Task.Run(() =>
    {
        Task.Factory.StartNew(() =>
        {
            System.Diagnostics.Trace.WriteLine($"{Thread.CurrentThread.ManagedThreadId}: StartNew");
            this.button1.Text = "TEST";
        }, CancellationToken.None, TaskCreationOptions.None, ctx);
    });
}

위의 경우, Task.Run 내에서 SynchronizationContext를 지정한 Task.Factory.StartNew를 중첩시켰는데요, 저런 상황에서는 QueueTask에서 m_synchronizationContext.Post를 했기 때문에 this.button1.Text에 대한 접근이 안전하게 될 수 있었던 것입니다.




이번엔 MyWinFormsSynchronizationContext가 async/await과 함께 사용하는 경우를 보겠습니다.

private async void button3_Click(object sender, EventArgs e)
{
    await Task.Delay(1000);

    System.Diagnostics.Trace.WriteLine($"{Thread.CurrentThread.ManagedThreadId}: await");
    this.button1.Text = "TEST";
}

지난 글에 설명한 것처럼,

C# - async/await 그리고 스레드 (3) Task.Delay 재현
; https://www.sysnet.pe.kr/2/0/13060

Task.Delay는 타이머 완료 후 콜백이 호출되는데, 그 콜백 메서드는 스레드풀의 여유 스레드를 받아 실행이 됩니다.

그런데, await 호출은 SynchronizationContext를 고려하므로 타이머 완료 처리를 하는 스레드 풀의 스레드 상에서 await 이후의 코드를 실행하지 않습니다. 즉, SynchronizationContext.Post로 작업을 맡겨 대행하는 것입니다. 이런 처리는 C# 컴파일러가 async 메서드에서 await 호출을 할 때 이미 고려가 됩니다. 예를 들어 위의 button3_Click은 다음과 같이 빌드가 되고,

private void button3_Click(object sender, EventArgs e)
{
    Form1.<button3_Click>d__4 <button3_Click>d__ = new Form1.<button3_Click>d__4();
    <button3_Click>d__.<>t__builder = AsyncVoidMethodBuilder.Create();
    <button3_Click>d__.<>4__this = this;
    <button3_Click>d__.sender = sender;
    <button3_Click>d__.e = e;
    <button3_Click>d__.<>1__state = -1;
    <button3_Click>d__.<>t__builder.Start<Form1.<button3_Click>d__4>(ref <button3_Click>d__);
}

AsyncVoidMethodBuilder.Create의 코드를 보면,

public static AsyncVoidMethodBuilder Create()
{
    SynchronizationContext synchronizationContext = SynchronizationContext.Current;
    if (synchronizationContext != null)
    {
        synchronizationContext.OperationStarted();
    }
    return new AsyncVoidMethodBuilder
    {
        _synchronizationContext = synchronizationContext
    };
}

SynchronizationContext.Current가 반영이 되고 있습니다. 이처럼, await 호출에는 Windows Forms/WPF 등의 환경에서라면 SynchronizationContext가 관여하기 때문에 button3_Click의 코드처럼 UI 요소에 대한 접근을 자연스럽게 할 수 있게 됩니다.

하지만, 이런 면에는 단점도 있습니다. 스레드가 엮이는 호출이다 보니, 하부에 숨어 있는 연동을 이해하지 못해 여러 가지 부작용이 발생하는 건데요, 아래의 글들에서 몇 가지 사례를 볼 수 있습니다.

async/await 사용 시 hang 문제가 발생하는 경우 - 두 번째 이야기
; https://www.sysnet.pe.kr/2/0/10801

비동기 메서드 내에서 await 시 ConfigureAwait 호출 의미
; https://www.sysnet.pe.kr/2/0/11418

WebClient 타입의 ...Async 메서드 호출은 왜 await + 동기 호출 시 hang 현상이 발생할까요?
; https://www.sysnet.pe.kr/2/0/11419

C# - Task.Yield 사용법
; https://www.sysnet.pe.kr/2/0/12241

아이러니하죠? ^^ UI 스레드에 대한 접근을 추상화하려고 SynchronizationContext를 도입했는데, 정작 await 호출에 대한 다양한 사례에서 정작 숨겨 놓았던 SynchronizationContext 존재를 알아야만 오동작의 원인을 알 수 있게 된 것입니다.




SynchronizationContext를 배려한 TaskScheduler의 특징으로 인해, SynchronizationContext 측의 Post 메서드를 어떻게 작성하느냐에 따라 TaskScheduler의 본래 의도를 왜곡, 좋게 말하면 사용자 정의할 수 있는 측면이 있습니다.

즉, UI 스레드와 연계하고 싶어 TaskScheduler.FromCurrentSynchronizationContext를 이용했는데, 사용자 정의된 SynchronizationContext 측에서 UI 스레드가 아닌 다른 처리를 할 수 있는 여지가 있습니다.

예를 들어, 지난 글에서 TaskScheduler의 사례로 SingleThreadTaskScheduler를 만들었는데요, 그와 같은 역할을 SynchronizationContext 측에서 하는 것도 가능합니다.

// 1개의 전담 스레드를 만들어 SynchronizationContext의 요청을 처리

class SingleThreadSynchronizationContext : SynchronizationContext
{
    static Thread _thread;
    static BlockingCollection<WorkItem> _workItems;

    class WorkItem
    {
        SendOrPostCallback _callback;
        public SendOrPostCallback Callback => _callback;
        object? _state;
        public object? State => _state;

        internal WorkItem(SendOrPostCallback callback, object? state)
        {
            _callback = callback;
            _state = state;
        }
    }

    static SingleThreadSynchronizationContext()
    {
        _workItems = new BlockingCollection<WorkItem>();

        _thread = new Thread(threadFunc);
        _thread.IsBackground = true;
        _thread.Start();
    }

    static void threadFunc()
    {
        while (true)
        {
            var item = _workItems.Take();
            item.Callback(item.State);
        }
    }

    public override void Post(SendOrPostCallback d, object? state)
    {
        _workItems.Add(new WorkItem(d, state));
    }

    public override void Send(SendOrPostCallback d, object? state)
    {
        throw new NotSupportedException();
    }
}

그럼, 이걸 다음과 같이 사용하는 것이 가능합니다.

public partial class Form1 : Form
{
    SingleThreadSynchronizationContext _singleContext;

    public Form1()
    {
        _singleContext = new SingleThreadSynchronizationContext();
        SynchronizationContext.SetSynchronizationContext(_singleContext);

        InitializeComponent();
    }

    private async void button3_Click(object sender, EventArgs e)
    {
        await Task.Delay(1000);

        // 이 코드는 SingleThreadSynchronizationContext가 제공하는 단일 스레드에서 실행
        System.Diagnostics.Trace.WriteLine($"{Thread.CurrentThread.ManagedThreadId}: await");

        // 따라서, UI 요소는 접근할 수 없으므로 그런 경우에는 Control.Invoke/BeginInvoke로 호출
        this.Invoke(() =>
        {
            this.button1.Text = "TEST";
        });        
    }
}

실행해 보면 "Output" 창에 다음과 같은 식의 출력을 얻을 수 있습니다.

11: await

button3_Click을 몇 번 실행해도 매번 스레드 ID는 11번으로 나오게 됩니다. 즉, await 비동기 호출 이후에 실행하는 코드는 언제나 단일 스레드로 직렬 호출이 보장됩니다. 물론, 해당 스레드에서 UI 요소를 접근할 수는 없으므로 필요한 경우 Control.Invoke/BeginInvoke를 사용해야 합니다.

그런데, 위의 코드를 다음과 같이 바꾸면 어떻게 될까요?

private async void button3_Click(object sender, EventArgs e)
{
    await Task.Delay(1000);
    System.Diagnostics.Trace.WriteLine($"{Thread.CurrentThread.ManagedThreadId}: await");

    await Task.Delay(1000);
    System.Diagnostics.Trace.WriteLine($"{Thread.CurrentThread.ManagedThreadId}: await");
}

출력 결과를 보면,

11: await
10: await

// 한 번 더 버튼을 눌러봐도,

11: await
15: await

보는 바와 같이 두 번째 호출에 대해서는 스레드 ID가 달라지고 있습니다. 혹시 원인을 눈치채셨을까요? ^^

왜냐하면, "11: await"이 호출된 스레드에는 SynchronizationContext가 설정돼 있지 않기 때문에 이후의 호출부터는 다시 스레드 풀의 스레드가 사용된 것입니다. 따라서 이것을 문제라고 생각한다면 SingleThreadSynchronizationContext에서 생성한 스레드에도 SynchronizationContext 설정을 해야 합니다.

class SingleThreadSynchronizationContext : SynchronizationContext
{
    // ...[생략]...

    static void threadFunc()
    {
        SynchronizationContext.SetSynchronizationContext(new SingleThreadSynchronizationContext());

        while (true)
        {
            var item = _workItems.Take();
            item.Callback(item.State);
        }
    }

    // ...[생략]...
}

이후부터는 원래 의도했던 대로 모든 호출이 직렬화가 됩니다.

(첨부 파일은 이 글의 예제 코드를 포함합니다.)

대충 이 정도까지의 내용을 이해했다면, 닷넷 응용 프로그램 개발자로서 SynchronizationContext와 TaskScheduler에 대해 더 궁금하신 것은 없을 것입니다. ^^




[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]







[최초 등록일: ]
[최종 수정일: 12/11/2022]

Creative Commons License
이 저작물은 크리에이티브 커먼즈 코리아 저작자표시-비영리-변경금지 2.0 대한민국 라이센스에 따라 이용하실 수 있습니다.
by SeongTae Jeong, mailto:techsharer at outlook.com

비밀번호

댓글 작성자
 




[1]  2  3  4  5  6  7  8  9  10  11  12  13  14  15  ...
NoWriterDateCnt.TitleFile(s)
13650정성태6/18/202452개발 환경 구성: 713. "WSL --debug-shell"로 살펴보는 WSL 2 VM의 리눅스 환경
13649정성태6/18/2024251오류 유형: 910. windbg - !py 확장 명령어 실행 시 "failed to find python interpreter" (2)
13648정성태6/17/2024260오류 유형: 909. C# - DynamicMethod 사용 시 System.TypeAccessException
13647정성태6/16/2024369개발 환경 구성: 712. Windows - WSL 2의 네트워크 통신 방법 - 세 번째 이야기 (같은 IP를 공유하는 WSL 2 인스턴스)
13646정성태6/14/2024389오류 유형: 908. Process Explorer - "Error configuring dump resources: The system cannot find the file specified."
13645정성태6/13/2024659개발 환경 구성: 711. Visual Studio로 개발 시 기본 등록하는 dev tag 이미지로 Docker Desktop k8s에서 실행하는 방법
13644정성태6/12/2024654닷넷: 2265. C# - System.Text.Json의 기본적인 (한글 등에서의) escape 처리
13643정성태6/12/2024696오류 유형: 907. MySqlConnector 사용 시 System.IO.FileLoadException 오류
13642정성태6/11/2024824스크립트: 65. 파이썬 - asgi 버전(2, 3)에 따라 달라지는 uvicorn 호스팅
13641정성태6/11/2024994Linux: 71. Ubuntu 20.04를 22.04로 업데이트
13640정성태6/10/20241015Phone: 21. C# MAUI - Android 환경에서의 파일 다운로드(DownloadManager)
13639정성태6/8/2024946오류 유형: 906. C# MAUI - Android Emulator에서 "Waiting For Debugger"로 무한 대기
13638정성태6/8/20241042오류 유형: 905. C# MAUI - 추가한 layout XML 파일이 Resource.Layout 멤버로 나오지 않는 문제
13637정성태6/6/20241106Phone: 20. C# MAUI - 유튜브 동영상을 MediaElement로 재생하는 방법
13636정성태5/30/20241126닷넷: 2264. C# - 형식 인자로 인터페이스를 갖는 제네릭 타입으로의 형변환파일 다운로드1
13635정성태5/29/2024951Phone: 19. C# MAUI - 안드로이드 "Share" 대상으로 등록하는 방법
13634정성태5/24/20241195Phone: 18. C# MAUI - 안드로이드 플랫폼에서의 Activity 제어
13633정성태5/22/20241138스크립트: 64. 파이썬 - ASGI를 만족하는 최소한의 구현 코드
13632정성태5/20/20241247Phone: 17. C# MAUI - Android 내에 Web 서비스 호스팅
13631정성태5/19/20241281Phone: 16. C# MAUI - /Download 등의 공용 디렉터리에 접근하는 방법
13630정성태5/19/20241842닷넷: 2263. C# - Thread가 Task보다 더 빠르다는 어떤 예제(?)
13629정성태5/18/20241622개발 환경 구성: 710. Android - adb.exe를 이용한 파일 전송
13628정성태5/17/20241620개발 환경 구성: 709. Windows - WHPX(Windows Hypervisor Platform)를 이용한 Android Emulator 가속
13627정성태5/17/20241587오류 유형: 904. 파이썬 - UnicodeEncodeError: 'ascii' codec can't encode character '...' in position ...: ordinal not in range(128)
13626정성태5/15/20241705Phone: 15. C# MAUI - MediaElement Source 경로 지정 방법파일 다운로드1
[1]  2  3  4  5  6  7  8  9  10  11  12  13  14  15  ...