Каналы, блокировки и синхронизация

Примечание о специфичности примитивов синхронизации для среды выполнения.

Почему нам нужны асинхронные примитивы вместо использования синхронных.

Каналы (Channels)

  • В основном те же, что и в std, но с await
    • Общение между задачами (в одном потоке или разных)
  • Одноразовый канал (one shot)
  • Многопроизводительный, многопотребительский (mpsc)
  • Другие каналы
  • Ограниченные (bounded) и неограниченные (unbounded) каналы

Каналы — это механизм для передачи сообщений между задачами. Они позволяют задачам общаться и координировать свою работу, даже если они выполняются в разных потоках. Асинхронные каналы похожи на свои синхронные аналоги из стандартной библиотеки, но используют await для операций, которые могут блокировать, таких как отправка или получение сообщений, когда канал полон или пуст.

Одноразовый канал (one shot) используется для отправки одного сообщения от отправителя к получателю. Это легковесный канал для однократной коммуникации.

Многопроизводительный, многопотребительский канал (mpsc) позволяет нескольким отправителям отправлять сообщения одному получателю. Это один из самых распространенных типов каналов.

Ограниченные (bounded) и неограниченные (unbounded) каналы отличаются по емкости. Ограниченные каналы имеют фиксированный размер буфера, и операция отправки будет ожидать (await), пока в буфере не появится место. Неограниченные каналы теоретически могут принимать бесконечное количество сообщений, но могут привести к чрезмерному потреблению памяти, если производители обгоняют потребителей.

Блокировки (Locks)

  • Асинхронный Mutex
    • В сравнении с std::Mutex — может удерживаться через точки await (заимствуя мьютекс в guard, guard является Send, осведомлен о планировщике? или просто потому что lock асинхронный?), lock является асинхронным (не будет блокировать поток в ожидании доступности блокировки)
      • даже есть clippy lint для удержания guard через await (https://rust-lang.github.io/rust-clippy/master/index.html#await_holding_lock)
    • Более дорогой, потому что может удерживаться через await
      • используйте std::Mutex, если можете
        • можно использовать try_lock или если мьютекс, как ожидается, не будет в состоянии contention (соревнования)
    • Блокировка не волшебным образом отпускается при yield (в этом и смысл блокировки!)
    • Взаимоблокировка (deadlock) из-за удержания мьютекса через await
      • задачи взаимоблокируются, но другие задачи могут прогрессировать, поэтому это может не выглядеть как взаимоблокировка в статистике процесса/инструментах/ОС
      • обычный совет — ограничивайте область действия, минимизируйте блокировки, упорядочивайте блокировки, предпочитайте альтернативы
    • Нет отравления (poisoning) мьютекса
    • lock_owned
    • blocking_lock
      • нельзя использовать в асинхронном контексте
    • Применяется к другим блокировкам (следует ли перенести вышесказанное до обсуждения мьютекса конкретно? Вероятно, да)
  • RWLock (Read-Write Lock)
  • Семафор (Semaphore)
  • Уступка (yielding)

Асинхронные блокировки, такие как Mutex и RwLock, предназначены для использования в асинхронном коде. Ключевое отличие от их синхронных аналогов в том, что они не блокируют весь поток ОС при ожидании блокировки. Вместо этого, если блокировка недоступна, задача добровольно уступает управление планировщику, позволяя выполняться другим задачам на том же потоке. Это делает их пригодными для удержания через точки await.

Однако асинхронные блокировки обычно дороже своих синхронных аналогов из-за дополнительной сложности, связанной с интеграцией в асинхронную среду выполнения. Поэтому, если вы защищаете данные, которые не требуют удержания блокировки через await (например, короткоживущие критические sections), часто лучше использовать std::sync::Mutex.

Важно: Блокировка не отпускается автоматически при достижении точки await. Она будет удерживаться до тех пор, пока MutexGuard не выйдет из области видимости или не будет явно удален. Удержание блокировки через await может легко привести к взаимоблокировкам, если другая задача попытается получить ту же блокировку.

RWLock (Read-Write Lock) позволяет нескольким читателям одновременно получать доступ к данным, но только одному писателю. Это полезно, когда данные часто читаются, но редко записываются.

Семафор (Semaphore) ограничивает количество задач, которые могут одновременно получить доступ к ресурсу. Он поддерживает счетчик, и задачи должны приобрести "разрешение" (permit) перед доступом к ресурсу.

Уступка (Yielding) в контексте блокировок относится к добровольному освобождению процессорного времени задачей, ожидающей блокировки, чтобы другие задачи могли прогрессировать.

Другие примитивы синхронизации

  • Уведомление (notify), барьер (barrier)
  • OnceCell
  • Атомарные операции (atomics)

Notify — это простой механизм для уведомления одной или нескольких задач о том, что произошло некоторое событие. Это легковесная альтернатива каналам, когда нужно только сигнализировать, без передачи данных.

Barrier заставляет группу задач ждать друг друга в определенной точке. Все задачи, достигающие барьера, блокируются до тех пор, пока не будет достигнуто заданное количество задач, после чего все они продолжают выполнение.

OnceCell (или его асинхронный аналог) используется для однократной инициализации значения, которое затем можно многократно читать. Это полезно для ленивой инициализации глобального состояния.

Атомарные операции предоставляют безопасный для многопоточности доступ к примитивным типам данных без использования блокировок. Они реализуются с помощью аппаратной поддержки процессора и очень эффективны. В асинхронном коде атомарные операции могут использоваться так же, как и в синхронном, поскольку они не блокируют поток.