05.01.2017 Взаимная блокировка (deadlock) в async/await
Вопросы о взаимной блокировке периодически появляются на форумах и ресурсе StackOverflow. Разработчики, которые недавно познакомились с основами асинхронного программирования, чаще всего наступают именно на эти грабли.
Intro (от переводчика)
Асинхронное программирование в наше время стало достаточно популярным способом разработки ПО во многом благодаря специализированным библиотекам, упрощающим его использование. А в некоторые языки программирования даже вводится поддержка ключевых слов, которые позволяют писать асинхронный код в "синхронном стиле", без использования методов обратного вызова. Так, в C# таким средством является пара ключевых слов async/await. Но, несмотря на всю простоту и прозрачность использования async/await, у этого средства есть определенные подводные камни, о которых и пойдет речь ниже. Данная статья является переводом заметки "Не блокируйте асинхронный код" в блоге Стивена Клири (Stephen Cleary).
Пример с пользовательским интерфейсом (UI-пример)
Рассмотрим код, показанный ниже. Клик на кнопке инициирует REST-запрос и отображает результаты в текстовом поле. (Данный пример приведен для Windows Forms, но те же самые принципы применимы к любому UI-приложению).
// My "library" method.
public static async Task<JObject> GetJsonAsync(Uri uri)
{
using (var client = new HttpClient())
{
var jsonString = await client.GetStringAsync(uri);
return JObject.Parse(jsonString);
}
}
// My "top-level" method.
public void Button1_Click(...)
{
var jsonTask = GetJsonAsync(...);
textBox1.Text = jsonTask.Result;
}
Вспомогательный метод GetJson ответственен за выполнение REST-запроса и парсинг ответа в формате json. Обработчик нажатия кнопки ожидает завершения выполнения вспомогательного метода и затем отображает результаты.
Данный способ приведет к взаимной блокировке (deadlock).
Пример в ASP.NET
Рассмотрим еще один похожий пример. Есть библиотечный метод, который выполняет REST-запрос, только в этот раз он используется в контексте ASP.NET (в данном случае WebAPI, но данные принципы применимы к любому ASP.NET приложению):
// My "library" method.
public static async Task<JObject> GetJsonAsync(Uri uri)
{
using (var client = new HttpClient())
{
var jsonString = await client.GetStringAsync(uri);
return JObject.Parse(jsonString);
}
}
// My "top-level" method.
public class MyController : ApiController
{
public string Get()
{
var jsonTask = GetJsonAsync(...);
return jsonTask.Result.ToString();
}
}
Данный код также приведет к deadlock по той же самой причине.
Что является причиной взаимной блокировки?
После ожидания задачи Task, код возобновит свое выполнение в контексте.
В первом случае - это контекст пользовательского интерфейса (UI-контекст), который применим к любому пользовательскому интерфейсу, кроме консольных приложений. Во втором случае - это контекст запроса ASP.NET.
Еще один интересный момент: контекст запроса ASP.NET не привязан к определенному потоку (в отличии от UI-контекста), но он позволяет выполнять единственный поток в каждый момент времени. Насколько мне известно, этот интересный аспект официально не документирован, но он упомянут в моей статье о контексте синхронизации (SynchronizationContext) в MSDN.
Итак, вот что происходит в коде, начиная с функции входа (Button1_Click для UI-интерфейса / MyController.Get для ASP.NET-приложения).
- Метод верхнего уровня вызывает GetJsonAsync (внутри UI/ASP.NET контекста).
- GetJsonAsync инициирует REST-запрос, вызывая HttpClient.GetStringAsync (все еще внутри контекста).
- GetStringAsync возвращает незавершенную задачу Task, указывая, что REST-запрос еще не исполнен.
- GetJsonAsync ожидает выполнение задачи Task, возвращаемой методом GetStringAsync. Контекст захватывается и будет позже использован для продолжения выполнения метода GetJsonAsync. GetStringAsync возвращает незавершенную задачу Task, указывая, что REST-запрос еще не исполнен.
- Метод верхнего уровня синхронно блокирует объект Task, возвращаемый GetJsonAsync. Это приводит к блокировке потока контекста.
- ...В конечном счете, REST-запрос завершиться, что приведет к звершению задачи Task, которая возвращается методом GetStringAsync.
- Код, установленный в продолжении метода GetJsonAsync уже готов выполниться, но он ожидает получение доступа к контексту, чтобы выполниться в этом контексте.
- Происходит взаимная блокировка. Метод верхнего уровня блокирует поток контекста, ожидая завершения GetJsonAsync, а GetJsonAsync ожидает освобождение контекста для своего завершения.
Для примера с пользовательским интерфейсом, "контекст" - это UI-контекст; для примера с ASP.NET, "контекст" - это контекст запроса ASP.NET. Такой тип взаимной блокировки может случиться и в других "контекстах".
Предотвращение взаимной блокировки
Существует два наилучших способа избежать подобной ситуации.
- В ваших "библиотечных" асинхронных методах где только возможно используйте ConfigureAwait(false).
- Не блокируйте возвращаемые задачи (Tasks); используйте async на всем протяжении.
Согласно первому способу, новый "библиотечный" метод выглядит таким образом:
public static async Task<JObject> GetJsonAsync(Uri uri)
{
using (var client = new HttpClient())
{
var jsonString = await client.GetStringAsync(uri).ConfigureAwait(false);
return JObject.Parse(jsonString);
}
}
Поведение кода, установленного в продолжение метода GetJsonAsync, изменяется таким образом, что его возобновление происходит уже не в контексте. Вместо этого GetJsonAsync возобновляет выполнение в одном из потоков из пула потоков (Thread Pool). Это дает возможность завершить задачу Task без необходимости повторного вхождения в контекст.
Согласно второму способу, новый метод самого высокого уровня выглядит так:
public async void Button1_Click(...)
{
var json = await GetJsonAsync(...);
textBox1.Text = json;
}
public class MyController : ApiController
{
public async Task<string> Get()
{
var json = await GetJsonAsync(...);
return json.ToString();
}
}
Мы изменяем поведение блокировки методов "высокого уровня" таким образом, что контекст в действительности никогда не блокируется; все "ожидания" на самом деле являются "асинхронными ожиданиями".
Замечание: Лучше всего задействовать оба способа. Любой из них предотвращает deadlock, но их совместное использование должно применяться для получения максимальной производительности и отзывчивости.
Данный тип взаимной блокировки является результатом смешивания синхронного и асинхронного кода. Обычно так происходит, когда разработчики пытаются использовать асинхронное программирование с одним маленьким участком кода, а в дальнейшем повсюду используют синхронный код. К сожалению, частично асинхронный код гораздо более сложный и коварный, чем полностью асинхронный.