Перейти к основному содержимому

Асинхронность в 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; здесь — как выбрать модель и не блокировать сервер.


Синхронный код и блокировка (на пальцах)

Синхронный вызов:

let text = client.GetStringAsync(url).Result // плохая практика на сервере

.Result блокирует текущий поток, пока сеть не ответит. В ASP.NET Core это съедает потоки пула и снижает пропускную способность. Правильный путь — ожидать операцию внутри async или task без блокировки.


Две модели — async и task

async { }task { }
Тип результатаAsync<'T>Task<'T>
Запуск в F#Async.RunSynchronously, Async.Starttask.Result (осторожно), await из C#
Вызов из C#через Async.StartAsTaskawait напрямую
Новый веб на .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

Построчно:

  1. use client = new HttpClient() — клиент будет уничтожен в конце блока.
  2. GetStringAsync в .NET возвращает Task<string>, не строку сразу.
  3. |> Async.AwaitTask — мост Task → Async, чтобы использовать let! в async.
  4. return text.Length — длина строки как результат Async<int>.
  5. 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 (186), а исключения — для действительно аварийных ситуаций.


MailboxProcessor — агент с очередью

Агент — объект с почтовым ящиком: сообщения приходят в очередь, обрабатываются по одному в цикле. Состояние меняется только внутри этого цикла — проще рассуждать, чем о lock на общем mutable.

type CounterMsg =
| Add of int
| Get of AsyncReplyChannel<int>

let createCounter () =
MailboxProcessor.Start(fun inbox ->
let rec loop total =
async {
let! msg = inbox.Receive()
match msg with
| Add n -> return! loop (total + n)
| Get reply ->
reply.Reply total
return! loop total
}
loop 0)

let agent = createCounter ()
agent.Post (Add 10)
let current = agent.PostAndReply (fun reply -> Get reply)
ЭлементСмысл
CounterMsgDU — тип сообщений
Add n«увеличить на n»
Get reply«верни текущее значение через канал ответа»
inbox.Receive()асинхронно ждать следующее сообщение
return! loop (...)хвостовая рекурсия: новый цикл с обновлённым total
Postотправить сообщение без ответа
PostAndReplyотправить и дождаться ответа
ПодходитМенее подходит
Очередь команд к одному ресурсуМассовый параллельный CPU на всех ядрах
Троттлинг, батчинг событийОдин разовый task без состояния
Модель «актора» в процессеРаспределённый кластер без доп. библиотек

Для высокой нагрузки смотрят System.Threading.Channels и Task; агент остаётся идиоматичным инструментом F#.


Типичные ошибки

  1. Async.RunSynchronously или .Result в обработчике HTTP — блокировка потока пула.
  2. Общий mutable из нескольких потоков без агента или lock — гонки данных.
  3. Игнорирование отмены — передавать CancellationToken в GetStringAsync и проверять в длинных циклах.
  4. Новый 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 прерывает ожидание, если операция ещё не завершилась — важно для таймаутов и остановки службы.


Дальше


См. также

Другие статьи этого же раздела в боковом меню (как на странице «О разделе»).