Как связанны TaskScheduler и SynchronizationContext в C#?

Рейтинг: 2Ответов: 1Опубликовано: 04.08.2023

У меня есть некоторое непонимание относительно связи этих механизмов. Как я понимаю TaskScheduler - это механизм для планировки выполнения задач, по умолчанию используется планировщик пула потоков. Но так-же есть планировщик текущего контекста синхронизации. Контекст синхронизации - это механизм который выполняет наш код в определенном месте, как я понимаю он есть только в приложениях в пользовательским интерфейсом(по умолчанию). Напрашивается вопрос - у контекста синхронизации есть свой собственный планировщик? Я могу получить к нему доступ? Как эти два понятия работают между собой?

P.S - видел похожий вопрос на англоязычной версии сайта - https://stackoverflow.com/questions/9580061/what-is-the-conceptual-difference-between-synchronizationcontext-and-taskschedul. Но из него я так и не смог получить ответ на свой вопрос.

Ответы

▲ 0

SynchronizationContext и TaskScheduler - две абстракции, управляющие тем, где (в каком потоке) будет исполнен код, вызванный асинхронно.

Разница в том, что SynchronizationContext управляет тем, где (в каком потоке) будет вызван Continuation (то есть, например, код после await), а TaskScheduler определяет, где (в каком потоке) будет выполнен само тело Task, т.е. где будет выполнен код делегата, который мы передаём в конструктор Task.

Рассмотрим пример.

Создадим приложение WindowsForms.

Мы знаем, что WindowsForms при создании главной формы устанавливает WindowsFormsSynchronizationContext в качестве Current для текущего потока (это главный, т.е. UI поток).

Метод WindowsFormsSynchronizationContext.Post реализован следующим образом:

public override void Post(SendOrPostCallback d, object state) {
    controlToSendTo?.BeginInvoke(d, new object[] { state });
}

Значит, код после await будет направляться на выполнение в UI поток.

Реализуем свой глупенький TaskScheduler, который запоминает только последний Task, переданный ему, чтобы поставить в очередь, и раз в пять секунд запускает задачу из "очереди", состоящей из одного элемента. В нём нет практического смысла, просто для демонстрации:

class MyTaskScheduler : TaskScheduler {
    Task? scheduledTask;
    object lockObj = new();
    System.Timers.Timer t;

    public MyTaskScheduler()
    {
        t = new(5000);
        t.Elapsed += (o, e) => StartTask();
    }

    protected override IEnumerable<Task>? GetScheduledTasks() {
        lock (lockObj) {          
            return scheduledTask != null ? new List<Task> {scheduledTask} : new List<Task>(0);
        }
    }

    protected override void QueueTask(Task task) {
        lock (lockObj) {
            scheduledTask = task;
            Debug.WriteLine($"Task {task.Id} scheduled");
            if (!t.Enabled)
                t.Start();
        }
    }


    void StartTask() {
        lock (lockObj) {
            if (scheduledTask != null)
                base.TryExecuteTask(scheduledTask);
        }
    }

    protected override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued) {
        return false;
    }
}

В обрабочике клика запустим Task, передав экземпляр нашего планировщика в соответствующем параметре метода Task.Factory.StartNew:

public partial class Form1 : Form {
    MyTaskScheduler scheduler = new();
    static int counter = 0;

    public Form1() {
        InitializeComponent();
        // раскоментируй, чтобы присвоение текста после await 
        // вызывало Exception (доступ не из UI Thread)
        // SynchronizationContext.SetSynchronizationContext(null);
    }

    private async void button1_Click(object sender, EventArgs e) {
        Debug.WriteLine($"UI Therad ID: {Thread.CurrentThread.ManagedThreadId}");
        counter++;
        Task<string> t = Task.Factory.StartNew(() => {
            Debug.WriteLine($"Task {Task.CurrentId} executing in Therad: "
                + "{Thread.CurrentThread.ManagedThreadId}");
            Thread.Sleep(3000);
            //button1.Text = "!!!"; // Exception - доступ не из UI потока!
            return counter.ToString();
        }, CancellationToken.None, TaskCreationOptions.None, scheduler);
        // Сработал Synchronization Context и continuation выполняется в UI потоке
        button1.Text = await t; 
        Debug.WriteLine($"Continuation for Task {t.Id} in Therad: " 
            + "{Thread.CurrentThread.ManagedThreadId}");
    }

}

Сразу после нажатия кнопки в окне Output в Visual Studio видим сообщения о том, что ID UI-потока - 1, что наш Task поставлен в очередь.

Через пять секунд увидим, что наш Task выполнился в другом потоке.

Ещё через три секунды увидим, что продолжение метода button1_Click (код после await) выполнился в UI-потоке, и текст кнопки был изменён.

То есть, тем, где выплолнять сам Task, ведает TaskScheduler, а тем где выполнять продолжение - SynchronizationContext.