본문 바로가기

C#

C# Custom SynchronizationContext 구현하기

비동기와 UI 스레드의 문맥 제어를 이해하면 안정적인 C# 코드를 작성할 수 있습니다. SynchronizationContext는 await 이후의 실행 위치를 결정하는 핵심 추상화이며, 직접 구현하면 전용 스레드에서 순차 실행, 테스트용 가상 UI 루프, 제한된 동시성 큐 등 다양한 시나리오를 만들 수 있습니다.

1. SynchronizationContext란?

SynchronizationContext는 작업을 특정 문맥으로 마샬링하는 추상 타입입니다. WinForms/WPF는 UI 스레드로, ASP.NET은 요청 문맥으로, 기본 스레드풀에서는 특별한 제약 없이 실행합니다. await는 기본적으로 현재 SynchronizationContext를 캡처해 이어서 실행합니다.

2. 언제 커스텀 구현이 필요한가

단일 스레드 메시지 루프가 필요한 워커, 테스트에서 UI 없이도 동일 스레드 재개를 보장하고 싶을 때, 순서를 보장하는 이벤트 처리기, 제한된 실행 환경에서 예측 가능한 동작이 필요할 때 커스텀 구현을 고려합니다.

3. 최소 구현 예제

아래 예제는 단일 스레드에서 Post/Send 요청을 큐잉해 순차 실행하는 SynchronizationContext입니다. ExecutionContext를 캡처해 흐름을 보존하며, RunOnCurrentThread로 메시지 루프를 구동하고 Complete로 종료합니다.

using System;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;

public sealed class SingleThreadSynchronizationContext : SynchronizationContext, IDisposable
{
    private readonly ConcurrentQueue<(SendOrPostCallback Callback, object State, ExecutionContext Ec)> _queue
        = new ConcurrentQueue<(SendOrPostCallback, object, ExecutionContext)>();
    private readonly AutoResetEvent _workSignal = new AutoResetEvent(false);
    private readonly int _threadId;
    private volatile bool _done;

    public SingleThreadSynchronizationContext()
    {
        _threadId = Thread.CurrentThread.ManagedThreadId;
    }

    public override void Post(SendOrPostCallback d, object state)
    {
        var ec = ExecutionContext.Capture();
        _queue.Enqueue((d, state, ec));
        _workSignal.Set();
    }

    public override void Send(SendOrPostCallback d, object state)
    {
        if (Thread.CurrentThread.ManagedThreadId == _threadId)
        {
            d(state);
            return;
        }

        using var evt = new ManualResetEventSlim(false);
        Exception ex = null;
        var ec = ExecutionContext.Capture();
        _queue.Enqueue(((s) =>
        {
            try { d(s); }
            catch (Exception e) { ex = e; }
            finally { evt.Set(); }
        }, state, ec));
        _workSignal.Set();
        evt.Wait();
        if (ex != null) throw ex;
    }

    public void RunOnCurrentThread()
    {
        while (!_done || !_queue.IsEmpty)
        {
            if (_queue.TryDequeue(out var item))
            {
                if (item.Ec != null)
                {
                    ExecutionContext.Run(item.Ec, s =>
                    {
                        var t = ((SendOrPostCallback Callback, object State, ExecutionContext Ec))s;
                        t.Callback(t.State);
                    }, item);
                }
                else
                {
                    item.Callback(item.State);
                }
            }
            else
            {
                _workSignal.WaitOne();
            }
        }
    }

    public void Complete() => _done = true;

    public void Dispose()
    {
        _done = true;
        _workSignal.Set();
        _workSignal.Dispose();
    }
}

4. 사용 예제: 전용 스레드 메시지 루프

전용 스레드를 만들고 해당 스레드에 커스텀 SynchronizationContext를 설정한 뒤, await 이후에도 같은 스레드에서 실행되도록 합니다.

using System;
using System.Threading;
using System.Threading.Tasks;

class Program
{
    static void Main()
    {
        var thread = new Thread(() =>
        {
            using var ctx = new SingleThreadSynchronizationContext();
            SynchronizationContext.SetSynchronizationContext(ctx);

            // 비동기 작업 게시: await 이후에도 같은 스레드에서 재개됩니다.
            ctx.Post(async _ =>
            {
                Console.WriteLine($"Start on thread {Thread.CurrentThread.ManagedThreadId}");
                await Task.Delay(100); // 기본적으로 현재 SynchronizationContext를 캡처합니다.
                Console.WriteLine($"After await on thread {Thread.CurrentThread.ManagedThreadId}");
                ctx.Complete(); // 메시지 루프 종료 신호
            }, null);

            // 메시지 루프 구동
            ctx.RunOnCurrentThread();
        });

        thread.IsBackground = true;
        thread.Start();
        thread.Join();
    }
}

위 예제에서 async/await는 커스텀 SynchronizationContext를 캡처하여 Post된 콜백과 이어지는 코드를 동일 스레드에서 실행합니다. 외부 스레드에서 ctx.Post를 호출해 작업을 전달할 수도 있습니다.

5. 구현 포인트와 주의사항

Send는 교착상태를 유발할 수 있으므로 같은 스레드인지 확인 후 즉시 실행하거나, 반드시 필요한 곳에만 사용합니다. 예외는 메시지 루프 내에서 처리하지 않으면 조용히 사라질 수 있으므로 로깅 또는 전달 전략을 두는 것이 좋습니다. OperationStarted/OperationCompleted를 적절히 호출하면 TaskScheduler.FromCurrentSynchronizationContext와의 상호 운용성이 좋아집니다. ExecutionContext.Capture/Run으로 논리 호출 문맥을 보존하면 AsyncLocal, 보안 컨텍스트 등이 올바르게 흐릅니다. ASP.NET Core나 일반 서버 환경에서는 SynchronizationContext를 커스텀으로 고정하지 말고 기본 스레드풀 동작을 유지하는 것이 안전합니다. UI 프레임워크(WinForms/WPF/UWP)에서는 해당 프레임워크의 SynchronizationContext를 사용해야 합니다. await에서 현재 문맥을 유지하고 싶지 않다면 ConfigureAwait(false)를 사용합니다.

커스텀 SynchronizationContext는 메시지 지향 구조와 단일 스레드 보장을 손쉽게 제공하는 도구입니다. 간단한 큐와 신호로 구성해도 충분히 실용적이며, 테스트 격리, 순차 처리, 재진입 제어 등 다양한 곳에서 유용합니다.