Async в C# и SynchronizationContext

    Продолжение: часть 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).
    • +36
    • 32,8k
    • 3
    Поделиться публикацией

    Похожие публикации

    Комментарии 3

      0
      Это та самая, которая при PHILOSOPHERS_NUMBER = 2 впадает в дедлок?
        0
        Ага, там левая и правая вилка оказывается одинаковой, и философ сам себя блокирует =)
        0
        А слабо такой же пример (и статью) создать на базе немерловых ComputationExpressions?

        Там тоже контекст можно переключать. И GUI-контекст имеется.

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое