У попередніх темах ми розглянули фундамент асинхронності: async/await, Task, SynchronizationContext, та базові сценарії використання. Проте реальні production системи стикаються з викликами, що виходять за межі простих HTTP-запитів чи читання файлів.
Розглянемо три типові проблеми, що вимагають просунутих паттернів:
Проблема перша: Legacy API без async підтримки. Ви інтегруєте стару бібліотеку, що використовує callback-based модель (APM — Asynchronous Programming Model) або event-based підхід. Код виглядає як BeginRead(callback) або FileSystemWatcher.Changed += handler. Як перетворити це на сучасний await-able код без переписування всієї бібліотеки?
Проблема друга: Streaming великих наборів даних. API повертає мільйони записів з бази даних або paginated REST endpoint з тисячами сторінок. Завантажити все в пам'ять через List<T> — це OOM (Out of Memory). Потрібен спосіб обробляти дані "по мірі надходження", асинхронно, з підтримкою CancellationToken.
Проблема третя: Performance-critical hot paths. Профайлер показує, що 80% часу витрачається на allocation Task<T> об'єктів у методах, що виконуються мільйони разів на секунду. Кожен Task — це heap allocation, GC pressure. Як оптимізувати без втрати async зручності?
Ця тема надає інструменти для вирішення всіх трьох проблем: TaskCompletionSource<T> для інтеграції legacy API, IAsyncEnumerable<T> для streaming, ValueTask<T> для zero-allocation scenarios, та набір best practices для уникнення типових пасток.
TaskCompletionSource<T> (скорочено TCS) — це "обіцянка" (promise) у термінах JavaScript, або "майбутнє" (future) у термінах Scala. Це об'єкт, що дозволяє вручну контролювати стан Task<T>: коли він завершиться, з яким результатом, чи з помилкою.
Стандартний Task.Run() або async метод автоматично керують станом Task — ви не можете "зупинити" Task ззовні або "вручну" встановити результат. TCS дає саме цю можливість.
Анатомія TaskCompletionSource:
var tcs = new TaskCompletionSource<int>();
// Отримуємо Task, який "чекає" на результат
Task<int> task = tcs.Task;
// Пізніше, з іншого потоку або callback:
tcs.SetResult(42); // Task завершується успішно з результатом 42
// або
tcs.SetException(new Exception("Помилка")); // Task завершується з exception
// або
tcs.SetCanceled(); // Task переходить у стан Canceled
Ключова ідея: tcs.Task — це "читаємий" Task, а tcs сам — це "записуваний" контролер. Ви передаєте tcs.Task споживачу (який робить await), а tcs тримаєте у себе для встановлення результату.
Найпоширеніший use case — обгортання старих API, що використовують callbacks. Розглянемо класичний приклад: Timer.
Проблема: System.Threading.Timer приймає callback, але не повертає Task. Як зробити "async delay" через Timer?
using System.Threading;
static Task DelayWithTimerAsync(int millisecondsDelay, CancellationToken ct = default)
{
var tcs = new TaskCompletionSource<bool>();
// Реєструємо cancellation ДО створення Timer
ct.Register(() =>
{
tcs.TrySetCanceled(ct);
});
// Створюємо Timer, що спрацює один раз через millisecondsDelay
Timer? timer = null;
timer = new Timer(_ =>
{
timer?.Dispose(); // Звільняємо ресурси
tcs.TrySetResult(true); // Завершуємо Task
}, null, millisecondsDelay, Timeout.Infinite);
// Якщо cancellation вже запитано — одразу dispose
if (ct.IsCancellationRequested)
{
timer.Dispose();
tcs.TrySetCanceled(ct);
}
return tcs.Task;
}
// Використання — ідентично Task.Delay
await DelayWithTimerAsync(2000);
Console.WriteLine("2 секунди минуло через Timer!");
TrySetResult замість SetResult? Метод TrySet* повертає bool і не викидає exception, якщо Task вже завершено. Це важливо у race conditions: якщо cancellation та callback спрацюють одночасно, другий виклик просто поверне false замість падіння.Другий поширений сценарій — обгортання подій. Приклад: FileSystemWatcher генерує події при зміні файлів, але не має async API.
Завдання: Дочекатися першої зміни файлу асинхронно.
using System.IO;
static Task<FileSystemEventArgs> WaitForFileChangeAsync(
string path,
CancellationToken ct = default)
{
var tcs = new TaskCompletionSource<FileSystemEventArgs>();
var watcher = new FileSystemWatcher(path)
{
EnableRaisingEvents = true
};
// Обробник події — спрацює при першій зміні
void OnChanged(object sender, FileSystemEventArgs e)
{
watcher.EnableRaisingEvents = false; // Зупиняємо спостереження
watcher.Dispose();
tcs.TrySetResult(e);
}
watcher.Changed += OnChanged;
watcher.Created += OnChanged;
watcher.Deleted += OnChanged;
// Підтримка cancellation
ct.Register(() =>
{
watcher.EnableRaisingEvents = false;
watcher.Dispose();
tcs.TrySetCanceled(ct);
});
return tcs.Task;
}
// Використання
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
try
{
var change = await WaitForFileChangeAsync(@"C:\Temp", cts.Token);
Console.WriteLine($"Файл змінено: {change.FullPath}, тип: {change.ChangeType}");
}
catch (OperationCanceledException)
{
Console.WriteLine("Таймаут — жодних змін за 30 секунд");
}
ct.Register() виконується, але Task ніколи не завершується (наприклад, файл ніколи не змінюється), FileSystemWatcher залишається в пам'яті назавжди. Правильне рішення — зберігати CancellationTokenRegistration і викликати Dispose() після завершення Task.TaskCompletionSource приймає TaskCreationOptions у конструкторі, що впливає на поведінку створеного Task:
// За замовчуванням — Task виконується на ThreadPool
var tcs1 = new TaskCompletionSource<int>();
// RunContinuationsAsynchronously — continuation НЕ виконується синхронно
// при SetResult (запобігає блокуванню потоку, що встановлює результат)
var tcs2 = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
tcs2.Task.ContinueWith(t =>
{
// Цей код виконається на ThreadPool, а НЕ на потоці, що викликав SetResult
Console.WriteLine($"Результат: {t.Result}");
});
// Потік, що встановлює результат, НЕ блокується на виконанні continuation
tcs2.SetResult(42);
TaskCreationOptions.RunContinuationsAsynchronously для TCS у бібліотечному коді. Це запобігає ситуації, коли continuation блокує потік, що встановлює результат (особливо критично для UI потоків або ASP.NET request threads).Уявіть API, що повертає мільйон записів з бази даних:
// ❌ ПОГАНО: Завантажує ВСЕ в пам'ять одразу
async Task<List<User>> GetAllUsersAsync()
{
var users = new List<User>();
// SELECT * FROM Users — 1,000,000 рядків
// Споживання RAM: ~500 MB
return users;
}
var allUsers = await GetAllUsersAsync(); // OOM на великих даних
foreach (var user in allUsers)
{
ProcessUser(user);
}
Проблема: метод повертає Task<List<T>> — це означає, що весь результат має бути завантажений у пам'ять перед поверненням. Для мільйона записів це сотні мегабайт RAM.
Рішення до C# 8: Повертати Task<IEnumerable<T>> і використовувати yield return всередині синхронного ітератора. Але це не дозволяє робити await всередині ітератора.
Рішення C# 8+: IAsyncEnumerable<T> — асинхронний аналог IEnumerable<T>, що дозволяє yield return + await одночасно.
using System.Collections.Generic;
using System.Threading;
// Метод повертає IAsyncEnumerable<T> і використовує yield return
async IAsyncEnumerable<int> GenerateNumbersAsync(int count)
{
for (int i = 0; i < count; i++)
{
// Можемо робити await всередині ітератора!
await Task.Delay(100); // Імітація async операції
yield return i; // Повертаємо елемент "по мірі готовності"
}
}
// Споживання через await foreach
await foreach (var number in GenerateNumbersAsync(10))
{
Console.WriteLine($"Отримано: {number}");
// Виводиться по одному числу кожні 100ms, а НЕ все одразу після 1 секунди
}
Ключова різниця з Task<List<T>>:
Task<List<T>>: Метод завершується після генерації всіх елементів. Споживач чекає весь час.IAsyncEnumerable<T>: Метод повертає елементи по мірі готовності. Споживач обробляє кожен елемент одразу після отримання.Типовий сценарій — REST API з пагінацією (GitHub, Twitter, будь-який CRUD API):
using System.Net.Http;
using System.Text.Json;
using System.Collections.Generic;
using System.Threading;
record User(int Id, string Name);
async IAsyncEnumerable<User> FetchAllUsersAsync(
HttpClient http,
[EnumeratorCancellation] CancellationToken ct = default)
{
int page = 1;
bool hasMore = true;
while (hasMore)
{
// Запит до API: GET /users?page=1&limit=100
var response = await http.GetStringAsync(
$"https://api.example.com/users?page={page}&limit=100", ct);
var users = JsonSerializer.Deserialize<List<User>>(response);
if (users == null || users.Count == 0)
{
hasMore = false;
yield break; // Завершуємо ітерацію
}
// Повертаємо кожного користувача окремо
foreach (var user in users)
{
yield return user;
}
page++;
}
}
// Споживання — обробка по мірі надходження
var http = new HttpClient();
await foreach (var user in FetchAllUsersAsync(http))
{
Console.WriteLine($"Обробка: {user.Name}");
// Кожна сторінка завантажується ТІЛЬКИ коли попередня оброблена
// Споживання RAM: ~100 користувачів одночасно, а НЕ мільйон
}
[EnumeratorCancellation] атрибут — це спеціальний атрибут, що автоматично передає CancellationToken з await foreach у метод-генератор. Без нього ct буде default, навіть якщо споживач передав токен через WithCancellation().Споживач може передати CancellationToken через extension method WithCancellation():
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
try
{
await foreach (var user in FetchAllUsersAsync(http).WithCancellation(cts.Token))
{
Console.WriteLine(user.Name);
// Якщо обробка займе > 10 секунд — OperationCanceledException
}
}
catch (OperationCanceledException)
{
Console.WriteLine("Обробка скасована через таймаут");
}
Так само як Task.ConfigureAwait(false), IAsyncEnumerable підтримує ConfigureAwait:
await foreach (var item in GetItemsAsync().ConfigureAwait(false))
{
// Continuation НЕ повертається на captured SynchronizationContext
// Важливо для бібліотечного коду
}
Деякі ресурси вимагають асинхронного звільнення. Приклад: DbConnection.CloseAsync(), Stream.FlushAsync(), HttpClient з pending requests.
Стандартний IDisposable.Dispose() — синхронний метод. Якщо всередині Dispose() викликати .Result або .Wait() на async операції — це deadlock у UI/ASP.NET контекстах.
Рішення: IAsyncDisposable інтерфейс з методом DisposeAsync():
public interface IAsyncDisposable
{
ValueTask DisposeAsync();
}
// Клас, що реалізує IAsyncDisposable
class AsyncResource : IAsyncDisposable
{
private readonly HttpClient _http = new();
public async Task<string> FetchDataAsync()
{
return await _http.GetStringAsync("https://example.com");
}
public async ValueTask DisposeAsync()
{
Console.WriteLine("Async cleanup...");
// Асинхронне звільнення ресурсів
await Task.Delay(100); // Імітація async операції (flush, close connection)
_http.Dispose();
Console.WriteLine("Cleanup завершено");
}
}
// Використання через await using
await using (var resource = new AsyncResource())
{
var data = await resource.FetchDataAsync();
Console.WriteLine(data);
} // DisposeAsync() викликається автоматично тут
// Або скорочений синтаксис
await using var resource2 = new AsyncResource();
// DisposeAsync() викликається в кінці scope
CloseAsync, FlushAsync), або якщо Dispose() має викликати async операції — реалізуйте IAsyncDisposable. Приклади: database connections, network streams, file handles з buffering.Часто клас має реалізовувати обидва IDisposable та IAsyncDisposable для сумісності:
class DatabaseConnection : IDisposable, IAsyncDisposable
{
private bool _disposed;
// Async dispose — рекомендований спосіб
public async ValueTask DisposeAsync()
{
if (_disposed) return;
// Async cleanup
await FlushBuffersAsync();
await CloseConnectionAsync();
_disposed = true;
GC.SuppressFinalize(this); // Не потрібен finalizer
}
// Sync dispose — fallback для legacy коду
public void Dispose()
{
if (_disposed) return;
// Синхронне закриття (може бути менш ефективним)
FlushBuffersAsync().GetAwaiter().GetResult(); // Блокуюче очікування
CloseConnectionAsync().GetAwaiter().GetResult();
_disposed = true;
GC.SuppressFinalize(this);
}
private async Task FlushBuffersAsync()
{
await Task.Delay(50); // Імітація flush
Console.WriteLine("Buffers flushed");
}
private async Task CloseConnectionAsync()
{
await Task.Delay(50); // Імітація close
Console.WriteLine("Connection closed");
}
}
.GetAwaiter().GetResult() у Dispose() може призвести до deadlock у UI/ASP.NET контекстах. Якщо можливо, документуйте, що споживачі мають використовувати await using замість using.Кожен Task<T> — це heap-allocated об'єкт. Для методів, що викликаються мільйони разів на секунду (hot paths у high-performance системах), це створює значний GC pressure.
Benchmark сценарій: Метод, що повертає кешоване значення (синхронне завершення):
// ❌ Allocation: кожен виклик створює новий Task<int>
async Task<int> GetCachedValueAsync()
{
return _cachedValue; // Синхронне повернення, але Task все одно allocate
}
// Виклик 1,000,000 разів = 1,000,000 Task allocations
for (int i = 0; i < 1_000_000; i++)
{
int value = await GetCachedValueAsync();
}
Рішення: ValueTask<T> — це struct, що може представляти як синхронне завершення (без allocation), так і асинхронне (з allocation Task<T> всередині).
public readonly struct ValueTask<T>
{
// Внутрішня реалізація (спрощено):
private readonly T _result; // Для синхронного завершення
private readonly Task<T>? _task; // Для асинхронного завершення
private readonly bool _hasResult; // Прапорець: синхронне чи async
// Конструктор для синхронного результату (zero allocation)
public ValueTask(T result) { ... }
// Конструктор для асинхронного Task (fallback)
public ValueTask(Task<T> task) { ... }
}
Ключова ідея: Якщо результат доступний синхронно — ValueTask тримає його у _result (struct field, stack allocation). Якщо потрібна асинхронність — всередині створюється Task<T>.
Task<T> простіший для споживачів, ValueTask має обмеження.using System.Collections.Concurrent;
class AsyncCache<TKey, TValue> where TKey : notnull
{
private readonly ConcurrentDictionary<TKey, TValue> _cache = new();
private readonly Func<TKey, Task<TValue>> _valueFactory;
public AsyncCache(Func<TKey, Task<TValue>> valueFactory)
{
_valueFactory = valueFactory;
}
// Повертає ValueTask — синхронно якщо в кеші, async якщо ні
public async ValueTask<TValue> GetAsync(TKey key)
{
// Cache hit — синхронне повернення (zero allocation)
if (_cache.TryGetValue(key, out var cached))
{
return cached;
}
// Cache miss — асинхронне завантаження
var value = await _valueFactory(key);
_cache[key] = value;
return value;
}
}
// Використання
var cache = new AsyncCache<int, string>(async id =>
{
await Task.Delay(100); // Імітація DB query
return $"User_{id}";
});
// Перший виклик — async (cache miss)
string user1 = await cache.GetAsync(1); // 100ms delay
// Другий виклик — sync (cache hit, zero allocation)
string user1Again = await cache.GetAsync(1); // Instant, no Task allocation
await той самий ValueTask двічі:ValueTask<int> vt = GetValueAsync();
int result1 = await vt; // ✅ OK
int result2 = await vt; // ❌ UNDEFINED BEHAVIOR!
ValueTask у поле класу або довгоживучу змінну:private ValueTask<int> _task; // ❌ ПОГАНО!
Task<T>, .Result на незавершеному ValueTask — undefined behavior.ValueTask<int> vt = GetValueAsync();
Task<int> task = vt.AsTask(); // Тепер можна await кілька разів
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
[MemoryDiagnoser]
public class TaskVsValueTaskBenchmark
{
private int _cachedValue = 42;
[Benchmark(Baseline = true)]
public async Task<int> TaskBased()
{
return await GetWithTaskAsync();
}
[Benchmark]
public async ValueTask<int> ValueTaskBased()
{
return await GetWithValueTaskAsync();
}
private Task<int> GetWithTaskAsync()
{
// Синхронне повернення, але Task все одно allocate
return Task.FromResult(_cachedValue);
}
private ValueTask<int> GetWithValueTaskAsync()
{
// Синхронне повернення, zero allocation
return new ValueTask<int>(_cachedValue);
}
}
// Запуск: dotnet run -c Release
BenchmarkRunner.Run<TaskVsValueTaskBenchmark>();
Висновок: ValueTask у 3.5 рази швидший і не створює allocation для синхронних шляхів.
// ❌ ПОГАНО: async void у звичайному методі
async void ProcessDataAsync() // Exceptions "втрачаються"!
{
await Task.Delay(100);
throw new Exception("Boom"); // Падіння всього процесу
}
// ✅ ДОБРЕ: async Task
async Task ProcessDataAsync()
{
await Task.Delay(100);
throw new Exception("Boom"); // Exception можна catch через await
}
// ✅ ВИНЯТОК: event handlers
button.Click += async (sender, e) => // async void OK тут
{
await LoadDataAsync();
};
async void методі не можна catch через try/catch навколо виклику. Exception потрапляє у TaskScheduler.UnobservedTaskException або падає весь процес.// ❌ ПОГАНО: блокуюче очікування у UI/ASP.NET
public void ButtonClick()
{
var result = GetDataAsync().Result; // DEADLOCK у UI thread!
}
// ✅ ДОБРЕ: async all the way
public async void ButtonClick() // async void OK для event handler
{
var result = await GetDataAsync();
}
// ❌ ПОГАНО: "fake async" у ASP.NET
public async Task<IActionResult> GetUsers()
{
return await Task.Run(() => // Марнує ThreadPool потік!
{
var users = _db.Users.ToList(); // Синхронний EF Core
return Ok(users);
});
}
// ✅ ДОБРЕ: справжній async
public async Task<IActionResult> GetUsers()
{
var users = await _db.Users.ToListAsync(); // Async EF Core
return Ok(users);
}
Task.Run() бере другий потік з пулу, щоб виконати синхронну роботу — це марнування ресурсів. Використовуйте справжні async API (EF Core *Async, ADO.NET *Async).// Бібліотечний код — НЕ потребує повернення на UI thread
public async Task<string> FetchDataAsync()
{
var response = await _http.GetStringAsync(url).ConfigureAwait(false);
var processed = await ProcessAsync(response).ConfigureAwait(false);
return processed;
}
// UI код — потребує повернення на UI thread
private async void Button_Click(object sender, EventArgs e)
{
var data = await FetchDataAsync(); // Без ConfigureAwait
textBox.Text = data; // Доступ до UI — потрібен UI thread
}
// ✅ ДОБРЕ: кожен async метод приймає CancellationToken
public async Task<List<User>> GetUsersAsync(CancellationToken ct = default)
{
var response = await _http.GetAsync("/users", ct);
var users = await response.Content.ReadFromJsonAsync<List<User>>(ct);
return users;
}
// Споживач може скасувати операцію
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
try
{
var users = await GetUsersAsync(cts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("Запит скасовано через таймаут");
}
// Метод викликається мільйони разів — використовуйте ValueTask
public ValueTask<int> GetCountAsync()
{
if (_cache.TryGetValue("count", out int cached))
return new ValueTask<int>(cached); // Sync path, zero allocation
return new ValueTask<int>(LoadCountFromDbAsync()); // Async path
}
async Task<int> LoadCountFromDbAsync()
{
// Реальна async робота
return await _db.QuerySingleAsync<int>("SELECT COUNT(*) FROM Users");
}
Проблема: Блокуюче очікування async операції у синхронному контексті.
// ❌ АНТИПАТЕРН: sync-over-async
public string GetData()
{
// Deadlock у UI/ASP.NET, thread starvation у консолі
return GetDataAsync().Result;
}
// ✅ РІШЕННЯ 1: Зробіть метод async
public async Task<string> GetDataAsync()
{
return await FetchFromApiAsync();
}
// ✅ РІШЕННЯ 2: Якщо НЕМОЖЛИВО зробити async (legacy constraint)
public string GetData()
{
// Використовуйте Task.Run + .GetAwaiter().GetResult() як останній варіант
return Task.Run(async () => await GetDataAsync()).GetAwaiter().GetResult();
}
SynchronizationContext гарантує, що continuation після await виконається на тому ж потоці. Якщо цей потік заблокований через .Result — continuation ніколи не виконається, бо потік чекає на Task, а Task чекає на потік.Проблема: Запуск async операції без очікування результату — exceptions губляться.
// ❌ ПОГАНО: exception у LogAsync губиться
public void ProcessRequest()
{
LogAsync(); // Compiler warning CS4014: "Because this call is not awaited..."
// Якщо LogAsync кине exception — ніхто не дізнається
}
// ✅ РІШЕННЯ 1: Await якщо можливо
public async Task ProcessRequestAsync()
{
await LogAsync();
}
// ✅ РІШЕННЯ 2: Явний fire-and-forget з обробкою помилок
public void ProcessRequest()
{
_ = LogAndForgetAsync(); // Явно ігноруємо Task
}
async Task LogAndForgetAsync()
{
try
{
await LogAsync();
}
catch (Exception ex)
{
// Логуємо помилку у безпечне місце
Console.Error.WriteLine($"Log failed: {ex}");
}
}
Проблема: Потрібна ледача ініціалізація async ресурсу (виконується один раз при першому зверненні).
class ExpensiveResource
{
// ❌ ПОГАНО: Lazy<T> не підтримує async
private readonly Lazy<Task<string>> _data = new(() =>
{
// Не можемо використати await тут!
return LoadDataAsync();
});
// ✅ ДОБРЕ: Lazy<Task<T>> pattern
private readonly Lazy<Task<string>> _dataLazy = new(async () =>
{
await Task.Delay(1000); // Імітація дорогої операції
return "Expensive data";
});
public Task<string> GetDataAsync()
{
// Перший виклик — запускає async ініціалізацію
// Наступні виклики — повертають той самий Task
return _dataLazy.Value;
}
}
// Використання
var resource = new ExpensiveResource();
string data1 = await resource.GetDataAsync(); // 1 секунда затримки
string data2 = await resource.GetDataAsync(); // Instant — той самий Task
Nito.AsyncEx надає AsyncLazy<T> — спеціалізовану реалізацію для async lazy initialization з правильною обробкою exceptions та cancellation.Проблема: Потрібно повторити async операцію N разів при помилці.
static async Task<T> RetryAsync<T>(
Func<Task<T>> operation,
int maxRetries = 3,
TimeSpan? delay = null,
CancellationToken ct = default)
{
delay ??= TimeSpan.FromSeconds(1);
for (int attempt = 1; attempt <= maxRetries; attempt++)
{
try
{
return await operation();
}
catch (Exception ex) when (attempt < maxRetries)
{
Console.WriteLine($"Спроба {attempt} невдала: {ex.Message}. Повтор через {delay}...");
await Task.Delay(delay.Value, ct);
}
}
// Останній attempt без catch — exception пробросується
return await operation();
}
// Використання
var result = await RetryAsync(
operation: async () =>
{
var response = await httpClient.GetAsync("https://flaky-api.com/data");
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
},
maxRetries: 5,
delay: TimeSpan.FromSeconds(2)
);
Проблема: Конструктори не можуть бути async.
// ❌ НЕМОЖЛИВО: async constructor
class MyClass
{
public async MyClass() // Compiler error!
{
await InitializeAsync();
}
}
// ✅ РІШЕННЯ 1: Factory Method
class MyClass
{
private MyClass() { } // Private constructor
public static async Task<MyClass> CreateAsync()
{
var instance = new MyClass();
await instance.InitializeAsync();
return instance;
}
private async Task InitializeAsync()
{
await Task.Delay(100);
}
}
// Використання
var obj = await MyClass.CreateAsync();
// ✅ РІШЕННЯ 2: Lazy initialization
class MyClass
{
private Task? _initTask;
public MyClass()
{
// Конструктор синхронний, ініціалізація відкладена
}
private async Task EnsureInitializedAsync()
{
if (_initTask == null)
{
_initTask = InitializeAsync();
}
await _initTask;
}
public async Task DoWorkAsync()
{
await EnsureInitializedAsync();
// Робота...
}
private async Task InitializeAsync()
{
await Task.Delay(100);
}
}
Будь-який тип може стати "awaitable", якщо він реалізує pattern GetAwaiter(). Це дозволяє використовувати await з кастомними типами.
Мінімальний контракт:
using System.Runtime.CompilerServices;
// Кастомний awaitable тип
class CustomTask
{
private readonly Task _innerTask;
public CustomTask(Task task) => _innerTask = task;
// Pattern method — компілятор шукає саме цей метод
public CustomAwaiter GetAwaiter() => new CustomAwaiter(_innerTask);
}
// Awaiter — виконує реальну роботу
class CustomAwaiter : INotifyCompletion
{
private readonly TaskAwaiter _innerAwaiter;
public CustomAwaiter(Task task) => _innerAwaiter = task.GetAwaiter();
// Чи операція вже завершена?
public bool IsCompleted => _innerAwaiter.IsCompleted;
// Отримати результат (викликається після завершення)
public void GetResult() => _innerAwaiter.GetResult();
// Зареєструвати continuation (що виконати після завершення)
public void OnCompleted(Action continuation) => _innerAwaiter.OnCompleted(continuation);
}
// Використання — await працює з CustomTask!
var customTask = new CustomTask(Task.Delay(1000));
await customTask;
Console.WriteLine("Custom await завершено!");
Створимо awaitable, що автоматично додає timeout до будь-якої операції:
using System.Runtime.CompilerServices;
static class TaskExtensions
{
public static TimeoutAwaitable<T> WithTimeout<T>(this Task<T> task, TimeSpan timeout)
{
return new TimeoutAwaitable<T>(task, timeout);
}
}
class TimeoutAwaitable<T>
{
private readonly Task<T> _task;
private readonly TimeSpan _timeout;
public TimeoutAwaitable(Task<T> task, TimeSpan timeout)
{
_task = task;
_timeout = timeout;
}
public TaskAwaiter<T> GetAwaiter()
{
// Створюємо Task, що завершиться або при завершенні _task, або при timeout
var timeoutTask = Task.Delay(_timeout).ContinueWith(_ =>
throw new TimeoutException($"Операція не завершилась за {_timeout}"));
var completedTask = Task.WhenAny(_task, timeoutTask)
.ContinueWith(t => _task.Result); // Повертаємо результат _task
return completedTask.GetAwaiter();
}
}
// Використання — елегантний синтаксис
var result = await FetchDataAsync().WithTimeout(TimeSpan.FromSeconds(5));
Task та ValueTask. Кастомні awaitables корисні для бібліотек, що надають специфічну семантику (timeout, retry, circuit breaker) або оптимізацій (zero-allocation для специфічних сценаріїв).Побудуємо повноцінний async pipeline для обробки даних з використанням всіх вивчених паттернів.
dotnet new console -n AsyncPipeline
cd AsyncPipeline
dotnet add package System.Threading.Channels
using System.Collections.Generic;
using System.Threading;
class DataProducer
{
// Генеруємо дані асинхронно (імітація paginated API)
public async IAsyncEnumerable<int> ProduceAsync(
int totalItems,
[EnumeratorCancellation] CancellationToken ct = default)
{
for (int i = 1; i <= totalItems; i++)
{
ct.ThrowIfCancellationRequested();
// Імітація затримки мережі
await Task.Delay(50, ct);
yield return i;
}
}
}
using System.Collections.Concurrent;
class DataProcessor
{
private readonly ConcurrentDictionary<int, string> _cache = new();
// ValueTask — синхронно для кешованих, async для нових
public async ValueTask<string> ProcessAsync(int value, CancellationToken ct = default)
{
// Cache hit — синхронне повернення
if (_cache.TryGetValue(value, out var cached))
{
return cached;
}
// Cache miss — обробка
await Task.Delay(100, ct); // Імітація обробки
var result = $"Processed_{value}";
_cache[value] = result;
return result;
}
}
using System.IO;
class DataConsumer : IAsyncDisposable
{
private readonly StreamWriter _writer;
public DataConsumer(string outputPath)
{
_writer = new StreamWriter(outputPath, append: false);
}
public async Task ConsumeAsync(string data, CancellationToken ct = default)
{
await _writer.WriteLineAsync(data.AsMemory(), ct);
}
public async ValueTask DisposeAsync()
{
await _writer.FlushAsync();
await _writer.DisposeAsync();
}
}
class AsyncPipeline
{
private readonly DataProducer _producer = new();
private readonly DataProcessor _processor = new();
public async Task RunAsync(
int itemCount,
string outputPath,
CancellationToken ct = default)
{
await using var consumer = new DataConsumer(outputPath);
int processed = 0;
var sw = System.Diagnostics.Stopwatch.StartNew();
// IAsyncEnumerable — streaming без завантаження всього в пам'ять
await foreach (var item in _producer.ProduceAsync(itemCount, ct))
{
// ValueTask — оптимізація для кешованих значень
var result = await _processor.ProcessAsync(item, ct);
// IAsyncDisposable — правильний async cleanup
await consumer.ConsumeAsync(result, ct);
processed++;
if (processed % 10 == 0)
{
Console.WriteLine($"Оброблено: {processed}/{itemCount} " +
$"({sw.Elapsed.TotalSeconds:F1}s)");
}
}
sw.Stop();
Console.WriteLine($"\n✅ Pipeline завершено: {processed} елементів за {sw.Elapsed.TotalSeconds:F1}s");
}
}
using System;
using System.Threading;
var cts = new CancellationTokenSource();
// Ctrl+C для graceful shutdown
Console.CancelKeyPress += (_, e) =>
{
e.Cancel = true;
Console.WriteLine("\n⚠️ Скасування pipeline...");
cts.Cancel();
};
var pipeline = new AsyncPipeline();
try
{
await pipeline.RunAsync(
itemCount: 100,
outputPath: "output.txt",
ct: cts.Token
);
}
catch (OperationCanceledException)
{
Console.WriteLine("Pipeline скасовано користувачем");
}
catch (Exception ex)
{
Console.WriteLine($"❌ Помилка: {ex.Message}");
}
dotnet run
TaskCompletionSource<T>
TrySetResult/Exception/Canceled для безпекиRunContinuationsAsynchronously для бібліотекIAsyncEnumerable<T>
async + yield return одночасноawait foreach для споживання[EnumeratorCancellation] для CancellationTokenIAsyncDisposable
await using синтаксис.GetAwaiter().GetResult() у Dispose()ValueTask<T>
.AsTask() для гнучкостіНапишіть обгортку для System.Threading.Timer, що:
Task, що завершується після заданого часуCancellationToken (скасування таймера)Тести:
Реалізуйте систему обробки логів:
LogProducer читає файл по рядках через IAsyncEnumerable<string>LogParser парсить рядки у structured logs (async, з кешуванням через ValueTask)LogAggregator групує логи за рівнем (ERROR, WARN, INFO)LogWriter записує результат у JSON файл (IAsyncDisposable)Вимоги:
Реалізуйте власний AsyncLock через TaskCompletionSource:
LockAsync() повертає Task<IDisposable> (або ValueTask<IAsyncDisposable>)Тести:
Benchmark:
SemaphoreSlim(1,1) за швидкістю та allocationSynchronizationContext та ConfigureAwait — Контекст Виконання
Глибокий академічний розбір SynchronizationContext у .NET — UI thread affinity, ConfigureAwait(false), deadlock scenarios, ExecutionContext, AsyncLocal<T> та best practices для бібліотечного та UI коду.
System.Threading.Channels — Async Producer-Consumer
Повний розбір System.Threading.Channels — async-native producer/consumer patterns для high-performance pipelines. Bounded та Unbounded channels, BoundedChannelFullMode, multi-stage pipelines, BackgroundService інтеграція та benchmarks проти BlockingCollection.