Продолжение: часть III.
Прошлая заметка о async (часть I) была введением. В этой я продолжу начатую тему: я расскажу о том, что async взаимодействует с SynchronizationContext, и как это влияет на разработку асинхронных графических приложений.
Тестовым полигоном будет пример DiningPhilosophers, который идет вместе с расширением для асинхронного программирования. Это программа — визуализация знаменитой задачи Дейкстры об обедающих философах (ссылка). Прежде, чем читать дальше, лучше ознакомиться с условиями задачи.
Программа короткая, поэтому просмотрим её. В самом начале определяются классы представляющие основные сущности задачи: состояние философа, философ и вилка.
В качестве вилки используется класс BufferBlock, который лежит в пространстве имен System.Threading.Tasks.Dataflow. Это пространство позволяет писать многопоточные приложения на основе потока данных. Самый простой пример этого подхода — каналы, которые используются в Limbo, Go, Axum; их суть в том, что два потока взаимодействуют через каналы (аналог очереди), в которые можно писать и читать, если поток пытается прочитать из канала, а канал пуст, то поток блокируется до того момента, пока в канале не появятся данные. Отказ от общих объектов и использование каналов для обмена данными и средства синхронизации позволяет писать более понятный и безопасный код. BufferBlock является так раз таким каналом, метод Post добавляет данные, Receive получает, а ReceiveAsync является методом-расширением получающим данные асинхронно. Сущность задачи идеально ложиться на этот класс: доступная вилка описывается каналом, в котором что-то есть, занятая вилка — пустым каналом, если философ — поток выполнения, то при обращении к свободной вилке (каналу) он продолжит выполнение, а к занятой — будет ждать.
В программе класс Philosopher представляет не самого философа, а его состояние, которое визуализируется. В данном случае это стандартный примитив WPF — Ellipse и разное состояние философа представлено разным цветом. Важно, что раз это графический объект, то обращаться к нему можно только из одного потока.
Как я уже написал, самого философа будет представлять поток выполнения (метод RunPhilosopherAsync).
Метод MainWindow практически не интересен, в нем происходит инициализация структур класса. Единственное, что можно заметить про него, так это то, что он вызывает метод, в сигнатуре которого встречается async void, вызывая такие методы мы запускаем асинхронную операцию и теряем контром над ней, например, мы не можем дождаться её завершения.
Код RunPhilosopherAsync описывает действия действия философа, он достаточно прямолинеен особенно для асинхронного: думаем, ждем вилки, едим, кладем вилки обратно и думаем снова. Паузы (TaskEx.Delay) расставлены, чтобы можно было наблюдать разные стадии.
Во-первых, он не работает (deadlock) в случае когда число философов два из-за строчки:
Нужно forks[1] заменить на forks[0]. Но это придирки, пусть число философов — пять.
Во-вторых, код не должен работать, так как обращаемся к элементу gui из другого потока, но самое странное, что он работает. Если избавиться от async/await в коде, заменив везде «await x» на «x.Wait()», а «RunPhilosopherAsync(...)» на «new Task(() => RunPhilosopherAsync(...)).Start()» и удалив маркер async, то мы получим ожидаемый InvalidOperationException.
Теперь ясно, что вся магия заключена в await.
Вспомним, как принято взаимодействовать с 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:
При запуске будет выведено несколько раз «MyContext.Post», следовательно await действительно обращается к SynchronizationContext.
Тот факт, что await использует SynchronizationContext при работе, позволяет писать асинхронные графические приложения еще проще, так как не нужно беспокоиться о том, в каком потоке мы обращаемся к графическим элементам.
Кстати, SynchronizationContext — хороший пример, когда singleton не является абсолютным злом.
Написать эту заметку меня побудил пост в блоге ikvm.net от 1 ноября. А разобраться в теме мне помогли статьи «Understanding SynchronizationContext» (Part I, Part II и Part III).
Прошлая заметка о async (часть I) была введением. В этой я продолжу начатую тему: я расскажу о том, что async взаимодействует с SynchronizationContext, и как это влияет на разработку асинхронных графических приложений.
Тестовым полигоном будет пример DiningPhilosophers, который идет вместе с расширением для асинхронного программирования. Это программа — визуализация знаменитой задачи Дейкстры об обедающих философах (ссылка). Прежде, чем читать дальше, лучше ознакомиться с условиями задачи.
Программа короткая, поэтому просмотрим её. В самом начале определяются классы представляющие основные сущности задачи: состояние философа, философ и вилка.
В качестве вилки используется класс 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.
Теперь ясно, что вся магия заключена в 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).