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-приложения).

  1. Метод верхнего уровня вызывает GetJsonAsync (внутри UI/ASP.NET контекста).
  2. GetJsonAsync инициирует REST-запрос, вызывая HttpClient.GetStringAsync (все еще внутри контекста).
  3. GetStringAsync возвращает незавершенную задачу Task, указывая, что REST-запрос еще не исполнен.
  4. GetJsonAsync ожидает выполнение задачи Task, возвращаемой методом GetStringAsync. Контекст захватывается и будет позже использован для продолжения выполнения метода GetJsonAsync. GetStringAsync возвращает незавершенную задачу Task, указывая, что REST-запрос еще не исполнен.
  5. Метод верхнего уровня синхронно блокирует объект Task, возвращаемый GetJsonAsync. Это приводит к блокировке потока контекста.
  6. ...В конечном счете, REST-запрос завершиться, что приведет к звершению задачи Task, которая возвращается методом GetStringAsync.
  7. Код, установленный в продолжении метода GetJsonAsync уже готов выполниться, но он ожидает получение доступа к контексту, чтобы выполниться в этом контексте.
  8. Происходит взаимная блокировка. Метод верхнего уровня блокирует поток контекста, ожидая завершения GetJsonAsync, а GetJsonAsync ожидает освобождение контекста для своего завершения.

Для примера с пользовательским интерфейсом, "контекст" - это UI-контекст; для примера с ASP.NET, "контекст" - это контекст запроса ASP.NET. Такой тип взаимной блокировки может случиться и в других "контекстах".

Предотвращение взаимной блокировки

Существует два наилучших способа избежать подобной ситуации.

  1. В ваших "библиотечных" асинхронных методах где только возможно используйте ConfigureAwait(false).
  2. Не блокируйте возвращаемые задачи (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, но их совместное использование должно применяться для получения максимальной производительности и отзывчивости.

Данный тип взаимной блокировки является результатом смешивания синхронного и асинхронного кода. Обычно так происходит, когда разработчики пытаются использовать асинхронное программирование с одним маленьким участком кода, а в дальнейшем повсюду используют синхронный код. К сожалению, частично асинхронный код гораздо более сложный и коварный, чем полностью асинхронный.