Pull to refresh

Async/await в C#: подводные камни

Reading time 6 min
Views 104K
Original author: Vladimir Khorikov
Я бы хотел обсудить подводные камни, которые наиболее часто встречаются при работе с фичей async/await в C#, а также написать про то, как их можно обойти.

Как работает async/await


Внутренности async/await хорошо описаны Алексом Дэвисом в его книге, так что я только вкратце опишу их здесь. Рассмотрим следующий пример кода:

public async Task ReadFirstBytesAsync(string filePath1, string filePath2)
{
    using (FileStream fs1 = new FileStream(filePath1, FileMode.Open))
    using (FileStream fs2 = new FileStream(filePath2, FileMode.Open))
    {
        await fs1.ReadAsync(new byte[1], 0, 1); // 1
        await fs2.ReadAsync(new byte[1], 0, 1); // 2
    }
}


Эта функция читает по одному первому байту из двух файлов, пути к которым переданы через параметры. Что произойдет в строках “1” и “2”? Будут ли они выполнены параллельно? Нет. Эта функция будет «разбита» ключевым словом «await» на три части: часть, предшествующая «1», часть между «1» и «2» и часть, следующая за «2».

Функция запустит новый I/O bound поток в строке «1», передаст ему вторую часть себя же (ту часть, которая между «1» и «2») в качестве callback-а и возвратит управление. После того как I/O поток завершит работу, будет вызван callback, и метод продолжит выполнение. Метод создаст еще один I/O поток в строке «2», передаст ему третью часть себя в качестве callback-а и опять возвратит управление. После того как второй I/O поток завершит выполнение, будет запущена остальная часть метода.

Магия здесь присутствует благодаря компилятору, который преобразует методы, помеченные ключевым словом «async» в конечный автомат, по аналогии с тем, как он преобразует методы-итераторы.

Когда использовать async/await?


Существуют два основных сценария, в которых использование async/await предпочтительно.

В первую очередь, эта фича может быть использована в толстых клиентах для предоставления пользователям лучшего user experience. Когда пользователь нажимает на кнопку, стартуя тяжелую вычислительную операцию, наилучшим выходом будет выполнить эту операцию асинхронно, без блокировки UI потока. До .NET 4.5 подобная логика требовала гораздо больших усилий. Теперь ее можно запрограммировать примерно так:

private async void btnRead_Click(object sender, EventArgs e)
{
    btnRead.Enabled = false;
 
    using (FileStream fs = new FileStream(“File path”, FileMode.Open))
    using (StreamReader sr = new StreamReader(fs))
    {
        Content = await sr.ReadToEndAsync();
    }
 
    btnRead.Enabled = true;
}


Обратите внимание, что флаг Enabled в обоих случаях устанавливается UI-потоком. Этот подход устраняет необходимость написания такого некрасивого кода:

if (btnRead.InvokeRequired)
{
    btnRead.Invoke((Action)(() => btnRead.Enabled = false));
}
else
{
    btnRead.Enabled = false;
}


Другими словами, весь «легкий» код выполняется вызывающим потоком, в то время как «тяжелые» части делегируются отдельному потоку (I/O или CPU-bound). Такой подход позволяет существенно сократить количество усилий, необходимых для синхронизации доступа к UI элементам, т.к. управление ими происходит только из UI потока.

Во-вторых, async/await может быть использован в веб-приложениях для лучшей утилизации потоков. Команда ASP.NET MVC сделала асинхронные контроллеры очень простыми в имплементации. Вы можете просто написать action-метод как на примере ниже и ASP.NET сделает всю остальную работу:

public class HomeController : Controller
{
    public async Task<string> Index()
    {
        using (FileStream fs = new FileStream(“File path”, FileMode.Open))
        using (StreamReader sr = new StreamReader(fs))
        {
            return await sr.ReadToEndAsync(); // 1
        }
    }
}


В этом примере рабочий поток, выполняющий метод, стартует новый I/O поток на строке «1» и возвращается в пул потоков (thread pool). После того как I/O поток завершает работу, CLR выбирает новый поток из пула и тот продолжает выполнение метода. Таким образом, CPU-bound потоки из пула потоков используются намного более экономно.

Async/await в C#: подводные камни


Если вы разрабатываете стороннюю библиотеку, очень важно всегда настраивать await таким образом, чтобы остальная часть метода была выполнена произвольным потоком из пула. Другими словами, в коде сторонних библиотек всегда необходимо добавлять ConfigureAwait(false).

В первую очередь, сторонние библиотеки обычно не работают с UI контролами (если конечно это не UI библиотека), поэтому нет никакой необходимости связывать UI поток. Вы можете немного увеличить производительность если позволите CLR выполнять ваш код любым потоком из пула. Во-вторых, используя дефолтную имплементацию (или явно проставляя ConfigureAwait(true)), вы оставляете потенциальную дыру для дедлоков. Рассмотрим следующий пример:

private async void button1_Click(object sender, EventArgs e)
{
    int result = DoSomeWorkAsync().Result; // 1
}
 
private async Task<int> DoSomeWorkAsync()
{
    await Task.Delay(100).ConfigureAwait(true); //2
    return 1;
}


Клик по кнопке здесь приводит к дедлоку. UI поток стартует новый I/O поток на строке «2» и уходит в спящий режим на строке «1», ожидая завершения работы. После того как I/O поток заканчивает выполнение, оставшаяся часть метода DoSomeWorkAsync передается на выполнение вызывающему (UI) потоку. Но тот в это время находится в спящем режиме, ожидая завершения метода. Дедлок.

ASP.NET ведет себя таким же образом. Несмотря на то, что в ASP.NET нет выделенного UI потока, код в action-ах котроллеров не может выполняться более чем одним рабочим потоком одновременно.

Конечно, мы можем использовать await вместо обращения к свойству Result для того, чтобы избежать дедлока:

private async void button1_Click(object sender, EventArgs e)
{
    int result = await DoSomeWorkAsync();
}
 
private async Task<int> DoSomeWorkAsync()
{
    await Task.Delay(100).ConfigureAwait(true);
    return 1;
}


Но в .NET все равно существует как минимум один кейс, в котором у вас не получится обойти дедлок. Вы не можете использовать асинхронные методы внутри child action-ов ASP.NET MVC, т.к. они не поддерживаются. Таким образом, вам придется обращаться к свойству Result напрямую и если асинхронный метод, который вызывается вашим контроллером, не сконфигурирован правильно, вы получите дедлок. К примеру, если вы попытаетесь написать следующий код и SomeAction обращается к свойству Result асинхронного метода, который не был сконфигурирован через ConfigureAwait(false), вы опять же получите дедлок:

@Html.Action(“SomeAction“, “SomeController“)


Пользователи ваших библиотек как правило не имеют прямого доступа к коду этих библиотек, поэтому всегда заблаговременно проставляйте ConfigureAwait(false) в ваших асинхронных методах.

Как не нужно использовать PLINQ и async/await


Рассмотрим пример:

private async void button1_Click(object sender, EventArgs e)
{
    btnRead.Enabled = false;
    string content = await ReadFileAsync();
    btnRead.Enabled = true;
}
 
private Task<string> ReadFileAsync()
{
    return Task.Run(() => // 1
    {
        using (FileStream fs = new FileStream(“File path”, FileMode.Open))
        using (StreamReader sr = new StreamReader(fs))
        {
            return sr.ReadToEnd(); // 2
        }
    });
}


Выполняется ли этот код асинхронно? Да. Является ли этот код корректным примером написания асинхронного кода? Нет. UI поток здесь стартует новый CPU-bound поток на строке «1» и возвращает управление. Этот поток затем стартует новый I/O поток на строке «2» и переходит в спящий режим, ожидая выполнения.

Что происходит здесь? Вместо того, чтобы создать единственный I/O поток, мы создаем и CPU поток на строке «1», и I/O поток на строке «2». Это пустая трата потоков. Чтобы исправить ситуацию, нам нужно использовать асинхронную версию метода Read:

private Task<string> ReadFileAsync()
{
    using (FileStream fs = new FileStream(“File path”, FileMode.Open))
    using (StreamReader sr = new StreamReader(fs))
    {
        return sr.ReadToEndAsync();
    }
}


Еще один пример:

public void SendRequests()
{
    _urls.AsParallel().ForAll(url =>
    {
        var httpClient = new HttpClient();
        httpClient.PostAsync(url, new StringContent(“Some data”));
    });
}


Выглядит так, будто мы отправляем запросы параллельно, не так ли? Да, это так, но здесь мы имеем ту же проблему, что в предыдущем примере: вместо того, чтобы создать единственный I/O поток, мы создаем и I/O, и CPU-bound поток для каждого запроса. Исправить ситуацию можно используя метод Task.WaitAll:

public void SendRequests()
{
    IEnumerable<Task> tasks = _urls.Select(url =>
    {
        var httpClient = new HttpClient();
        return httpClient.PostAsync(url, new StringContent(“Some data”));
    });
    Task.WaitAll(tasks.ToArray());
}


Всегда ли необходимо выполнять I/O операции без связывания CPU-bound потоков?


Зависит от ситуации. В некоторых случаях это невозможно, в некоторых привносит слишком много сложности в код. К примеру, в NHibernate нет возможностей по асинхронной загрузке данных из БД. С другой стороны, в EntityFramework она есть, но использование ее не всегда имеет смысл.

Также, толстые клиенты (например, WPF или WinForms приложения) обычно не имеют больших нагрузок, так что для них подобная оптимизация по большей части не обязательна. Но в любом случае, необходимо знать что происходит «под капотом» этой фичи, чтобы иметь возможность принять сознательное решение в каждом конкретном случае.

Ссылка на оригинал статьи: Async/await in C#: pitfalls
Tags:
Hubs:
+34
Comments 46
Comments Comments 46

Articles