Microsoft MVP성태의 닷넷 이야기
.NET Framework: 2077. C# - 직접 만들어 보는 SynchronizationContext [링크 복사], [링크+제목 복사]
조회: 6115
글쓴 사람
정성태 (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

비밀번호

댓글 작성자
 




... [16]  17  18  19  20  21  22  23  24  25  26  27  28  29  30  ...
NoWriterDateCnt.TitleFile(s)
13230정성태1/26/20234105개발 환경 구성: 660. WSL 2 내부로부터 호스트 측의 네트워크로 UDP 데이터가 1개의 패킷으로만 제한되는 문제
13229정성태1/25/20235102.NET Framework: 2090. C# - UDP Datagram의 최대 크기
13228정성태1/24/20235227.NET Framework: 2089. C# - WMI 논리 디스크가 속한 물리 디스크의 정보를 얻는 방법 [2]파일 다운로드1
13227정성태1/23/20234926개발 환경 구성: 659. Windows - IP MTU 값을 바꿀 수 있을까요? [1]
13226정성태1/23/20234595.NET Framework: 2088. .NET 5부터 지원하는 GetRawSocketOption 사용 시 주의할 점
13225정성태1/21/20233848개발 환경 구성: 658. Windows에서 실행 중인 소켓 서버를 다른 PC 또는 WSL에서 접속할 수 없는 경우
13224정성태1/21/20234206Windows: 221. Windows - Private/Public/Domain이 아닌 네트워크 어댑터 단위로 방화벽을 on/off하는 방법
13223정성태1/20/20234407오류 유형: 838. RDP 연결 오류 - The two computers couldn't connect in the amount of time allotted
13222정성태1/20/20234093개발 환경 구성: 657. WSL - DockerDesktop.vhdx 파일 위치를 옮기는 방법
13221정성태1/19/20234321Linux: 57. C# - 리눅스 프로세스 메모리 정보파일 다운로드1
13220정성태1/19/20234433오류 유형: 837. NETSDK1045 The current .NET SDK does not support targeting .NET ...
13219정성태1/18/20234007Windows: 220. 네트워크의 인터넷 접속 가능 여부에 대한 판단 기준
13218정성태1/17/20233944VS.NET IDE: 178. Visual Studio 17.5 (Preview 2) - 포트 터널링을 이용한 웹 응용 프로그램의 외부 접근 허용
13217정성태1/13/20234524디버깅 기술: 185. windbg - 64비트 운영체제에서 작업 관리자로 뜬 32비트 프로세스의 덤프를 sos로 디버깅하는 방법
13216정성태1/12/20234775디버깅 기술: 184. windbg - 32비트 프로세스의 메모리 덤프인 경우 !peb 명령어로 나타나지 않는 환경 변수
13215정성태1/11/20236344Linux: 56. 리눅스 - /proc/pid/stat 정보를 이용해 프로세스의 CPU 사용량 구하는 방법 [1]
13214정성태1/10/20235880.NET Framework: 2087. .NET 6부터 SourceGenerator와 통합된 System.Text.Json [1]파일 다운로드1
13213정성태1/9/20235425오류 유형: 836. docker 이미지 빌드 시 "RUN apt install ..." 명령어가 실패하는 이유
13212정성태1/8/20235176기타: 85. 단정도/배정도 부동 소수점의 정밀도(Precision)에 따른 형변환 손실
13211정성태1/6/20235196웹: 42. (https가 아닌) http 다운로드를 막는 웹 브라우저
13210정성태1/5/20234296Windows: 219. 윈도우 x64의 경우 0x00000000`7ffe0000 아래의 주소는 왜 사용하지 않을까요?
13209정성태1/4/20234195Windows: 218. 왜 윈도우에서 가상 메모리 공간은 64KB 정렬이 된 걸까요?
13208정성태1/3/20234180.NET Framework: 2086. C# - Windows 운영체제의 2MB Large 페이지 크기 할당 방법파일 다운로드1
13207정성태12/26/20224444.NET Framework: 2085. C# - gpedit.msc의 "User Rights Assignment" 특권을 코드로 설정/해제하는 방법파일 다운로드1
13206정성태12/24/20224682.NET Framework: 2084. C# - GetTokenInformation으로 사용자 SID(Security identifiers) 구하는 방법 [3]파일 다운로드1
13205정성태12/24/20224990.NET Framework: 2083. C# - C++과의 연동을 위한 구조체의 fixed 배열 필드 사용 (2)파일 다운로드1
... [16]  17  18  19  20  21  22  23  24  25  26  27  28  29  30  ...