내용 보기

작성자

관리자 (IP : 192.168.0.1)

날짜

2022-12-28 00:14

제목

[C#] [스크랩] 직접 만들어 보는 SynchronizationContext


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);
        }
    }

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


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

출처1

https://www.sysnet.pe.kr/2/0/13191

출처2