Асинхронность в F# — async, task и агенты
Асинхронность в F# - async, task и агенты
Запрос в интернет, чтение файла, ожидание ответа БД — долгие операции. Пока поток ждёт сеть, его можно отдать другим задачам. В .NET для этого используют Task и async/await в C#.
F# добавляет:
- вычислительное выражение
async { }— историческая модель F# с типомAsync<'T>; - вычислительное выражение
task { }(с F# 6) — обёртка надTask/Task<'T>, удобная рядом с ASP.NET Core и C#; - агенты (
MailboxProcessor) — очередь сообщений и одно "потоковое" обновление состояния.
В обзоре F# может встретиться классический async; здесь — как выбрать модель и не блокировать сервер.
Практическая эвристика:
- пишете публичный API для ASP.NET Core или C#-клиентов ->
taskиTask; - пишете внутреннюю функциональную композицию в F# ->
async; - нужен безопасный доступ к общему состоянию без гонок -> агент
MailboxProcessor.
Синхронный код и блокировка (на пальцах)
Синхронный вызов:
let text = client.GetStringAsync(url).Result // плохая практика на сервере
.Result блокирует текущий поток, пока сеть не ответит. В ASP.NET Core это съедает потоки пула и снижает пропускную способность. Правильный путь — ожидать операцию внутри async или task без блокировки.
Две модели — async и task
async { } | task { } | |
|---|---|---|
| Тип результата | Async<'T> | Task<'T> |
| Запуск в F# | Async.RunSynchronously, Async.Start | task.Result (осторожно), await из C# |
| Вызов из C# | через Async.StartAsTask | await напрямую |
| Новый веб на .NET 6+ | допустимо | предпочтительно для публичного API |
let! внутри блока — "дождаться асинхронной операции и продолжить с результатом" (аналог await в C#).
return — итоговое значение блока.
use / use! — освободить IDisposable при выходе из блока (закрыть поток, клиент).
Блок async — разбор примера
open System.Net.Http
let fetchLength (url: string) =
async {
use client = new HttpClient()
let! text = client.GetStringAsync(url) |> Async.AwaitTask
return text.Length
}
let len = fetchLength "https://example.com" |> Async.RunSynchronously
Построчно:
- клиент будет уничтожен в конце блока.
use client = new HttpClient()
GetStringAsyncв .NET возвращаетTask<string>, не строку сразу.|> Async.AwaitTask— мост Task → Async, чтобы использоватьlet!вasync.return text.Length— длина строки как результатAsync<int>.Async.RunSynchronously— дождаться в текущем потоке (допустимо в консоли и тестах; на сервере в обработчике запроса — нет).
Параллельно несколько URL:
let urls = [ "https://example.com"; "https://dotnet.microsoft.com" ]
let lengths =
urls
|> List.map fetchLength
|> Async.Parallel
|> Async.RunSynchronously
Async.Parallel запускает несколько Async и собирает массив результатов. Для чистого CPU на всех ядрах смотрят также Task и пулы потоков.
Блок task — разбор примера
let fetchLengthTask (url: string) =
task {
use client = new HttpClient()
let! text = client.GetStringAsync url
return text.Length
}
GetStringAsync здесь сразу ожидается в task — без Async.AwaitTask. Возвращаемый тип — Task<int>. Такой метод естественно вызывать из C#:
int len = await fetchLengthTask("https://example.com");
Правило для новых библиотек на .NET 6+: внешний контракт — Task / ValueTask; внутри F#-модуля — task и чистые функции.
Переход Task ↔ Async
// Task -> Async
let taskToAsync (t: System.Threading.Tasks.Task<'T>) = Async.AwaitTask t
// Async -> Task
let asyncToTask a = Async.StartAsTask a
Библиотека для C# должна экспортировать Task, а не заставлять потребителей знать про Async.
Ресурсы и исключения
let readFile path =
async {
use stream = new System.IO.FileStream(path, System.IO.FileMode.Open)
use reader = new System.IO.StreamReader(stream)
return reader.ReadToEnd()
}
Исключения внутри async/task ловят try/with как в обычном коде. Для ожидаемых сбоев (файл не найден, неверный id) предпочтительнее Result и match (Сопоставление с образцом в F# — практикум), а исключения — для действительно аварийных ситуаций.
MailboxProcessor — агент с очередью
Агент — объект с почтовым ящиком: сообщения приходят в очередь, обрабатываются по одному в цикле. Состояние меняется только внутри этого цикла — проще рассуждать, чем о lock на общем mutable.
Код ITЗагрузка примера кода…
| Элемент | Смысл |
|---|---|
CounterMsg | DU — тип сообщений |
Add n | "увеличить на n" |
Get reply | "верни текущее значение через канал ответа" |
inbox.Receive() | асинхронно ждать следующее сообщение |
return! loop (...) | хвостовая рекурсия: новый цикл с обновлённым total |
Post | отправить сообщение без ответа |
PostAndReply | отправить и дождаться ответа |
| Подходит | Менее подходит |
|---|---|
| Очередь команд к одному ресурсу | Массовый параллельный CPU на всех ядрах |
| Троттлинг, батчинг событий | Один разовый task без состояния |
| Модель "актора" в процессе | Распределённый кластер без доп. библиотек |
Для высокой нагрузки смотрят System.Threading.Channels и Task; агент остаётся идиоматичным инструментом F#.
Типичные ошибки
Async.RunSynchronouslyили.Resultв обработчике HTTP — блокировка потока пула.- Общий
mutableиз нескольких потоков без агента илиlock— гонки данных. - Игнорирование отмены — передавать
CancellationTokenвGetStringAsyncи проверять в длинных циклах. - Новый
HttpClientна каждый запрос в production — один долгоживущий клиент илиIHttpClientFactoryв ASP.NET Core.
Отмена
let fetch ct (url: string) =
task {
use client = new HttpClient()
let! text = client.GetStringAsync(url, ct)
return text.Length
}
CancellationToken прерывает ожидание, если операция ещё не завершилась — важно для таймаутов и остановки службы.
Дальше
- Структура F#-проекта
- Справочник по F#
- Справочник языка F# (Learn) — разделы async / task
- Императивные конструкции —
useв синхронном коде
Базовый разбор HTTP и HTTPS находится в отдельной статье — HTTP как основа веб-интеграций.