Pull to refresh

Async в C# и SynchronizationContext

Reading time 5 min
Views 44K
Продолжение: часть III.

Прошлая заметка о async (часть I) была введением. В этой я продолжу начатую тему: я расскажу о том, что async взаимодействует с SynchronizationContext, и как это влияет на разработку асинхронных графических приложений.

Тестовым полигоном будет пример DiningPhilosophers, который идет вместе с расширением для асинхронного программирования. Это программа — визуализация знаменитой задачи Дейкстры об обедающих философах (ссылка). Прежде, чем читать дальше, лучше ознакомиться с условиями задачи.

image

Программа короткая, поэтому просмотрим её. В самом начале определяются классы представляющие основные сущности задачи: состояние философа, философ и вилка.

В качестве вилки используется класс BufferBlock, который лежит в пространстве имен System.Threading.Tasks.Dataflow. Это пространство позволяет писать многопоточные приложения на основе потока данных. Самый простой пример этого подхода — каналы, которые используются в Limbo, Go, Axum; их суть в том, что два потока взаимодействуют через каналы (аналог очереди), в которые можно писать и читать, если поток пытается прочитать из канала, а канал пуст, то поток блокируется до того момента, пока в канале не появятся данные. Отказ от общих объектов и использование каналов для обмена данными и средства синхронизации позволяет писать более понятный и безопасный код. BufferBlock является так раз таким каналом, метод Post добавляет данные, Receive получает, а ReceiveAsync является методом-расширением получающим данные асинхронно. Сущность задачи идеально ложиться на этот класс: доступная вилка описывается каналом, в котором что-то есть, занятая вилка — пустым каналом, если философ — поток выполнения, то при обращении к свободной вилке (каналу) он продолжит выполнение, а к занятой — будет ждать.

В программе класс Philosopher представляет не самого философа, а его состояние, которое визуализируется. В данном случае это стандартный примитив WPF — Ellipse и разное состояние философа представлено разным цветом. Важно, что раз это графический объект, то обращаться к нему можно только из одного потока.

Как я уже написал, самого философа будет представлять поток выполнения (метод RunPhilosopherAsync).

using Philosopher = Ellipse;
using Fork = BufferBlock<bool>;

Метод MainWindow практически не интересен, в нем происходит инициализация структур класса. Единственное, что можно заметить про него, так это то, что он вызывает метод, в сигнатуре которого встречается async void, вызывая такие методы мы запускаем асинхронную операцию и теряем контром над ней, например, мы не можем дождаться её завершения.

public MainWindow()
{
    for (int i = 0; i < philosophers.Length; i++)
    {
        diningTable.Children.Add(philosophers[i] = CreatePhilosopher());
        forks[i] = new Fork();
        forks[i].Post(true);
    }
    // Разрешаем философам думать и есть
    for (int i = 0; i < philosophers.Length; i++)
    {
        RunPhilosopherAsync(
            philosophers[i],
            i < philosophers.Length - 1 ? forks[i] : forks[1],
            i < philosophers.Length - 1 ? forks[i + 1] : forks[i]
        );
    }
}

Код RunPhilosopherAsync описывает действия действия философа, он достаточно прямолинеен особенно для асинхронного: думаем, ждем вилки, едим, кладем вилки обратно и думаем снова. Паузы (TaskEx.Delay) расставлены, чтобы можно было наблюдать разные стадии.

private async void RunPhilosopherAsync(Philosopher philosopher, Fork fork1, Fork fork2)
{
    // Думает, ждет вилки, есть, и все по кругу
    while (true)
    {
        // Думает (желтый)
        philosopher.Fill = Brushes.Yellow;
        await TaskEx.Delay(_rand.Next(10) * TIMESCALE);
        // Ждет вилки (Красный)
        philosopher.Fill = Brushes.Red;
        await fork1.ReceiveAsync();
        await fork2.ReceiveAsync();
        // Ест (Зеленый)
        philosopher.Fill = Brushes.Green;
        await TaskEx.Delay(_rand.Next(10) * TIMESCALE);
        // После еды кладет вилки на стол
        fork1.Post(true);
        fork2.Post(true);
    }
}

Что можно сказать про этот код?


Во-первых, он не работает (deadlock) в случае когда число философов два из-за строчки:

i < philosophers.Length - 1 ? forks[i] : forks[1]

Нужно forks[1] заменить на forks[0]. Но это придирки, пусть число философов — пять.

Во-вторых, код не должен работать, так как обращаемся к элементу gui из другого потока, но самое странное, что он работает. Если избавиться от async/await в коде, заменив везде «await x» на «x.Wait()», а «RunPhilosopherAsync(...)» на «new Task(() => RunPhilosopherAsync(...)).Start()» и удалив маркер async, то мы получим ожидаемый InvalidOperationException.

image

Теперь ясно, что вся магия заключена в await.

Взаимодействие с gui-потоками в .Net Framework

Вспомним, как принято взаимодействовать с gui-потоками в .Net Framework.

В случае WinForms, достаточно у любого контрола вызвать метод Invoke и передать ему делегат с кодом, который необходимо выполнить в gui потоке. В случае WPF, нужно обратиться к свойству Dispatcher любого контрола и вызвать метод Invoke, опять же передав ему делегат.

Это напоминает начало классического определения паттерна по Кристоферу: проблема повторяющаяся снова и снова. Саму проблему можно описать, как необходимость выполнять код в определенном потоке, когда инициализатор выполнения — код из другого потока. На самом деле это проблема не специфична для gui, так же она всплывает и в COM, и в WCF… Логично, что у неё должно быть решение, поток должен предоставлять средство запуска кода в нем, подобно тому, как это делают контролы WinForms и WPF. Такое решение есть, объект типа SynchronizationContext предоставляет методы Send и Post (асинхронный) для выполнения кода на другом потоке. Для получения доступа к объект типа SynchronizationContext какого-либо потока, нужно в этом потоке обратиться к SynchronizationContext.Current (per thread singleton).

Важно, чтобы SynchronizationContext и поток были согласованы, например, если поток работает на основе event loop, SynchronizationContext должен иметь доступ к очереди событий. То есть каждой реализации event loop нужно наследовать и переопределить методы SynchronizationContext. Если создать объект SynchronizationContext, то при вызовах Send и Post он будет использовать поток из ThreadPool.

Теперь можно предположить, что результат асинхронной операции await выполняет с помощью объекта SynchronizationContext, который он получает при первом вызове. Эту гипотезу легко проверить: установим свой SynchronizationContext:

class MyContext : SynchronizationContext 
{
    public override void Post(SendOrPostCallback d, object state)
    {
        Console.WriteLine("MyContext.Post");
        base.Post(d, state);
    }
    public override void Send(SendOrPostCallback d, object state)
    {
        Console.WriteLine("MyContext.Send");
        base.Send(d, state);
    }
}
class Program
{
    static async Task SavePage(string file, string a)
    {
        using (var stream = File.AppendText(file))
        { 
            var html = await new WebClient().DownloadStringTaskAsync(a);
            await stream.WriteAsync(html);
        }
    }
    static void Main(string[] args)
    {
        SynchronizationContext.SetSynchronizationContext(new MyContext());
        var task = SavePage("habrahabr", "http://habrahabr.ru");
        task.Wait();
    }
}

При запуске будет выведено несколько раз «MyContext.Post», следовательно await действительно обращается к SynchronizationContext.

Заключение

Тот факт, что await использует SynchronizationContext при работе, позволяет писать асинхронные графические приложения еще проще, так как не нужно беспокоиться о том, в каком потоке мы обращаемся к графическим элементам.

Кстати, SynchronizationContext — хороший пример, когда singleton не является абсолютным злом.

Написать эту заметку меня побудил пост в блоге ikvm.net от 1 ноября. А разобраться в теме мне помогли статьи «Understanding SynchronizationContext» (Part I, Part II и Part III).
Tags:
Hubs:
+36
Comments 3
Comments Comments 3

Articles