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

(시리즈 글이 11개 있습니다.)
.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

닷넷: 2278. WPF - 스레드에 종속되는 DependencyObject
; https://www.sysnet.pe.kr/2/0/13682

닷넷: 2298. C# - Console 프로젝트에서의 await 대상으로 Main 스레드 활용하는 방법
; https://www.sysnet.pe.kr/2/0/13743




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에 대해 더 궁금하신 것은 없을 것입니다. ^^




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







[최초 등록일: ]
[최종 수정일: 9/27/2024]

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

비밀번호

댓글 작성자
 




... 91  92  93  94  95  96  97  98  99  100  101  102  [103]  104  105  ...
NoWriterDateCnt.TitleFile(s)
11358정성태11/15/201726694사물인터넷: 9. Visual Studio 2017에서 Raspberry Pi C++ 응용 프로그램 제작 [1]
11357정성태11/15/201727189개발 환경 구성: 336. 윈도우 10 Bash 쉘에서 C++ 컴파일하는 방법
11356정성태11/15/201728775사물인터넷: 8. Raspberry Pi Zero(OTG)를 다른 컴퓨터에 연결해 가상 마우스 + 키보드로 쓰는 방법 [4]
11355정성태11/15/201724505사물인터넷: 7. Raspberry Pi Zero(OTG)를 다른 컴퓨터에 연결해 가상 마우스로 쓰는 방법 [2]파일 다운로드2
11354정성태11/14/201728724사물인터넷: 6. Raspberry Pi Zero(OTG)를 다른 컴퓨터에 연결해 가상 키보드로 쓰는 방법 [8]
11353정성태11/14/201725897사물인터넷: 5. Raspberry Pi Zero(OTG)를 다른 컴퓨터에 연결해 가상 이더넷 카드로 쓰는 방법 [1]
11352정성태11/14/201721987사물인터넷: 4. Samba를 이용해 윈도우와 Raspberry Pi간의 파일 교환 [1]
11351정성태11/7/201725236.NET Framework: 698. C# 컴파일러 대신 직접 구현하는 비동기(async/await) 코드 [6]파일 다운로드1
11350정성태11/1/201721218디버깅 기술: 108. windbg 분석 사례 - Redis 서버로의 호출을 기다리면서 hang 현상 발생
11349정성태10/31/201721705디버깅 기술: 107. windbg - x64 SOS 확장의 !clrstack 명령어가 출력하는 Child SP 값의 의미 [1]파일 다운로드1
11348정성태10/31/201718159디버깅 기술: 106. windbg - x64 역어셈블 코드에서 닷넷 메서드 호출의 인자를 확인하는 방법
11347정성태10/28/201721769오류 유형: 424. Visual Studio - "클래스 다이어그램 보기" 시 "작업을 완료할 수 없습니다. 해당 인터페이스를 지원하지 않습니다." 오류 발생
11346정성태10/25/201718340오류 유형: 423. Windows Server 2003 - The client-side extension could not remove user policy settings for 'Default Domain Policy {...}' (0x8007000d)
11338정성태10/25/201716740.NET Framework: 697. windbg - SOS DumpMT의 "BaseSize", "ComponentSize" 값에 대한 의미파일 다운로드1
11337정성태10/24/201718882.NET Framework: 696. windbg - SOS DumpClass/DumpMT의 "Vtable Slots", "Total Method Slots", "Slots in VTable" 값에 대한 의미파일 다운로드1
11336정성태10/20/201719659.NET Framework: 695. windbg - .NET string의 x86/x64 메모리 할당 구조
11335정성태10/18/201718661.NET Framework: 694. 닷넷 - <Module> 클래스의 용도
11334정성태10/18/201719694디버깅 기술: 105. windbg - k 명령어와 !clrstack을 조합한 호출 스택을 얻는 방법
11333정성태10/17/201718862오류 유형: 422. 윈도우 업데이트 - Code 9C48 Windows update encountered an unknown error.
11332정성태10/17/201719819디버깅 기술: 104. .NET Profiler + 디버거 연결 + .NET Exceptions = cpu high
11331정성태10/16/201718148디버깅 기술: 103. windbg - .NET 4.0 이상의 환경에서 모든 DLL에 대한 심벌 파일을 로드하는 파이썬 스크립트
11330정성태10/16/201717424디버깅 기술: 102. windbg - .NET 4.0 이상의 환경에서 DLL의 심벌 파일 로드 방법 [1]
11329정성태10/15/201721596.NET Framework: 693. C# - 오피스 엑셀 97-2003 .xls 파일에 대해 32비트/64비트 상관없이 접근 방법파일 다운로드1
11328정성태10/15/201724498.NET Framework: 692. C# - 하나의 바이너리로 환경에 맞게 32비트/64비트 EXE를 실행하는 방법파일 다운로드1
11327정성태10/15/201718262.NET Framework: 691. AssemblyName을 .csproj에서 바꾼 경우 빌드 오류 발생하는 문제파일 다운로드1
11326정성태10/15/201718549.NET Framework: 690. coreclr 소스코드로 알아보는 .NET 4.0의 모듈 로딩 함수 [1]
... 91  92  93  94  95  96  97  98  99  100  101  102  [103]  104  105  ...