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

비밀번호

댓글 작성자
 




... 31  32  33  [34]  35  36  37  38  39  40  41  42  43  44  45  ...
NoWriterDateCnt.TitleFile(s)
12779정성태8/13/20218970개발 환경 구성: 596. 공공 데이터 포털에서 버스 노선 및 위치 정보 조회 API 사용법
12778정성태8/12/20216238오류 유형: 755. PyCharm - "Manage Repositories"의 목록이 나오지 않는 문제
12777정성태8/12/20217872오류 유형: 754. Visual Studio - Input or output cannot be redirected because the specified file is invalid.
12776정성태8/12/20217201오류 유형: 753. gunicorn과 uwsgi 함께 사용 시 ERR_CONNECTION_REFUSED
12775정성태8/12/202117648스크립트: 22. 파이썬 - 윈도우 환경에서 개발한 Django 앱을 WSL 환경의 gunicorn을 이용해 실행
12774정성태8/11/20218799.NET Framework: 1087. C# - Collection 개체의 다중 스레드 접근 시 "Operations that change non-concurrent collections must have exclusive access" 예외 발생
12773정성태8/11/20217971개발 환경 구성: 595. PyCharm - WSL과 연동해 Django App을 윈도우에서 리눅스 대상으로 개발
12772정성태8/11/20219481스크립트: 21. 파이썬 - 윈도우 환경에서 개발한 Django 앱을 WSL 환경의 uwsgi를 이용해 실행 [1]
12771정성태8/11/20217888Windows: 196. "Microsoft Windows Subsystem for Linux Background Host" / "Vmmem"을 종료하는 방법
12770정성태8/11/20218570.NET Framework: 1086. C# - Windows Forms 응용 프로그램의 자식 컨트롤 부하파일 다운로드1
12769정성태8/11/20216520오류 유형: 752. Python - ImportError: No module named pip._internal.cli.main 두 번째 이야기
12768정성태8/10/20217555.NET Framework: 1085. .NET 6에 포함된 신규 BCL API [1]파일 다운로드1
12767정성태8/10/20218651오류 유형: 752. Python - ImportError: No module named pip._internal.cli.main
12766정성태8/9/20217170Java: 32. closing inbound before receiving peer's close_notify
12765정성태8/9/20216493Java: 31. Cannot load JDBC driver class 'org.mysql.jdbc.Driver'
12764정성태8/9/202144932Java: 30. XML document from ServletContext resource [/WEB-INF/applicationContext.xml] is invalid
12763정성태8/9/20217949Java: 29. java.lang.NullPointerException - com.mysql.jdbc.ConnectionImpl.getServerCharset
12762정성태8/8/202111505Java: 28. IntelliJ - Unable to open debugger port 오류
12761정성태8/8/20218689Java: 27. IntelliJ - java: package javax.inject does not exist [2]
12760정성태8/8/20216090개발 환경 구성: 594. 전용 "Command Prompt for ..." 단축 아이콘 만들기
12759정성태8/8/20219219Java: 26. IntelliJ + Spring Framework + 새로운 Controller 추가 [2]파일 다운로드1
12758정성태8/7/20218579오류 유형: 751. Error assembling WAR: webxml attribute is required (or pre-existing WEB-INF/web.xml if executing in update mode)
12757정성태8/7/20219257Java: 25. IntelliJ + Spring Framework 프로젝트 생성
12756정성태8/6/20218066.NET Framework: 1084. C# - .NET Core Web API 단위 테스트 방법 [1]파일 다운로드1
12755정성태8/5/20217173개발 환경 구성: 593. MSTest - 단위 테스트에 static/instance 유형의 private 멤버 접근 방법파일 다운로드1
12754정성태8/5/20218094오류 유형: 750. manage.py - Your project may not work properly until you apply the migrations for app(s): admin, auth, contenttypes, sessions.
... 31  32  33  [34]  35  36  37  38  39  40  41  42  43  44  45  ...