⏳
Урок истории
До .NET 4.5 асинхронный код писали вручную через обратные вызовы и явный маршалинг между потоками. Это было сложно, многословно и доступно только опытным разработчикам. C# 5 и Visual Basic с новым синтаксисом
async/
await убрали эту сложность.
Что происходит внутриКомпилятор превращает каждый
async-метод в конечный автомат. Вот простой пример:
public static async Task SimpleBodyAsync() {
Console.WriteLine("Hello, Async World!");
}После компиляции это превращается примерно в:
public static Task SimpleBodyAsync() {
var d = new <SimpleBodyAsync>d__0();
d.<>t__builder = AsyncTaskMethodBuilder.Create();
d.MoveNext();
return d.<>t__builder.Task;
}Плюс отдельная
struct с методом
MoveNext, блоком
try/catch и полями для хранения состояния. JIT не сможет встроить такой метод по месту вызова. Появляются издержки на вызов методов инфраструктуры
SetResult,
SetException и запись в поля конечного автомата.
Когда async лишнийЕсли метод всегда выполняется синхронно, оборачивать его в
async нет смысла. Тауб приводит пример
MemoryStream.ReadAsync: чтение из памяти и без того быстрое, и каждый вызов будет создавать новый объект
Task<int> просто чтобы вернуть число.
Решение: возвращать кешированный результат вручную.
private Task<int> m_lastTask;
public override Task<int> ReadAsync(
byte[] buffer, int offset, int count,
CancellationToken cancellationToken)
{
int numRead = this.Read(buffer, offset, count);
return m_lastTask != null && numRead == m_lastTask.Result
? m_lastTask
: (m_lastTask = Task.FromResult(numRead));
}
Это позволяет при повторяющихся вызовах с одним и тем же результатом не создавать новые объекты совсем.
SynchronizationContext и лишние переходыПо умолчанию
await захватывает текущий
SynchronizationContext и возвращает продолжение в него. Для UI-потока это удобно: не нужно вручную делать маршалинг. Но в библиотечном коде это создаёт лишние переходы между потоками.
Если из UI-потока запустить цикл копирования с
await на каждой операции чтения и записи мегабайта данных, получится более 500 переходов из фоновых потоков обратно в UI-поток. Чтобы этого избежать, в библиотеках следует использовать
ConfigureAwait(false):
while ((numRead = await source.ReadAsync(buffer, 0, buffer.Length)
.ConfigureAwait(false)) > 0)
{
await destination.WriteAsync(buffer, 0, numRead)
.ConfigureAwait(false);
}
Без
ConfigureAwait(false) в библиотечном коде возможна и взаимоблокировка: если вызывающий код в UI-потоке зовёт
t.Wait(), а продолжение пытается вернуться в тот же заблокированный поток, оба будут ждать друг друга бесконечно.
Локальные переменные и сбор мусораКомпилятор поднимает все локальные переменные асинхронного метода в поля конечного автомата, который упаковывается в кучу при первом реальном ожидании. На момент выхода статьи компиляторы поднимали иногда больше переменных, чем нужно: даже те, что после
await уже не читались.
// Лишнее поле в конечном автомате
public static async Task FooAsync() {
var dto = DateTimeOffset.Now;
var dt = dto.DateTime;
await Task.Yield();
Console.WriteLine(dt);
}
// Лучше так:
public static async Task FooAsync() {
var dt = DateTimeOffset.Now.DateTime;
await Task.Yield();
Console.WriteLine(dt);
}
Чем больше объектов создаётся, тем чаще срабатывает сборщик мусора. Это влияет на всю систему, а не только на конкретный метод.
Меньше await — лучшеКаждое
await-выражение несёт накладные расходы. Если нужно подождать несколько задач, лучше объединить их через
Task.WhenAll, чем ждать по одной:
// Хуже: три отдельных await
int ra = await a;
int rb = await b;
int rc = await c;
// Лучше: одно await на все три
int[] results = await Task.WhenAll(a, b, c);
async/
await упростил жизнь разработчикам, но не отменил необходимость понимать, что происходит внутри.
➡️
Блог разработчиков📍 Навигация:
Вакансии • Задачи •
Собесы🐸
Библиотека шарписта#sharp_view