Привет, Хабр!
Сегодня рассмотрим, как реализовать собственный бинарный семафор на основе futex и библиотеки parking_lot_core
.
Зачем семафор, если есть Mutex?
Ошибка, которую часто совершают начинающие разработчики: отождествляют семафор с мьютексом. Это принципиально разные сущности. Мьютекс ассоциирован с конкретным владельцем: поток, захвативший замок, обязан его освободить. У бинарного семафора понятие владельца отсутствует: это просто счётчик в состоянии 0
или 1
, который обнуляется первым, кто успеет. Его дефолтный сценарий — дозирование доступа к внешним или дорогим ресурсам, буферам фиксированного размера, синхронизация продюсер/консюмер при buffer size = 1
.
Так же сам по себе семафор не требует связки Mutex
+ Condvar
, что экономит как на коде, так и на рантайм-переключениях.
Futex — мостик между user space и ядром
Ключ к высокой эффективности семафоров в Linux — системный вызов futex
. В простейшем случае он вообще не заходит в ядро: атомарные операции и спиннинг происходят целиком в user space. Лишь при контеншенах задействуется переход в kernel mode.
Под семафор нам достаточно двух операций:
FUTEX_WAIT
— поток засыпает, если условие не выполнено.FUTEX_WAKE
— пробуждение ровно одного ожидающего.
Эти две примитивные операции — эквивалент POSIX-терминов down
и up
.
Прямой вызов sys_futex
в Rust возможен, но крайне неудобен: потребуется работать через libc::syscall
, следить за таймаутами, ручками кодировать параметры и быть готовым к undefined behavior на каждом углу. И тут включается наш parking_lot_core
.
Parking_lot_core
parking_lot_core
— низкоуровневая библиотека, извлекающая логику парковки из более высокоуровневых примитивов parking_lot
. Автор библиотеки вдохновился архитектурой WebKit, где управление потоками организовано через абстрактные очереди спящих по адресу.
Основные идеи:
каждое «состояние» — это ключ в очереди потоков;
park
,unpark_one
,unpark_all
— API, которое инкапсулирует системные вызовы и MCS-замки.
Так можно строить собственные синхронизационные структуры без необходимости напрямую взаимодействовать с ядром.
Реализация бинарного семафора
Память
Бинарный семафор — это всего один AtomicU32
.
use core::sync::atomic::{AtomicU32, Ordering};
use parking_lot_core::{park, unpark_one, SpinWait, DEFAULT_PARK_TOKEN, DEFAULT_UNPARK_TOKEN};
const AVAILABLE: u32 = 1;
const TAKEN: u32 = 0;
pub struct BinarySemaphore {
state: AtomicU32,
}
Захват
Захват жетона идёт по принципу «быстрый путь / медленный путь»:
pub fn acquire(&self) {
let mut spin = SpinWait::new();
loop {
if self.state
.compare_exchange(AVAILABLE, TAKEN, Ordering::Acquire, Ordering::Relaxed)
.is_ok()
{
return;
}
if spin.spin() {
continue;
}
unsafe {
park(
&self.state as *const _ as usize,
|| self.state.load(Ordering::Relaxed) == TAKEN,
|| {},
|_, _| {},
DEFAULT_PARK_TOKEN,
None,
);
}
spin.reset();
}
}
Сначала идёт спинлок, чтобы не загружать ядро понапрасну. Если не удаётся — поток паркуется.
Освобождение
pub fn release(&self) {
if self.state.swap(AVAILABLE, Ordering::Release) == TAKEN {
unsafe {
unpark_one(
&self.state as *const _ as usize,
|_| DEFAULT_UNPARK_TOKEN,
);
}
}
}
unpark_one
будит ровно одного спящего потока.
RAII guard
Чтобы избежать ошибок управления ресурсом, можно обернуть захват в RAII:
pub struct SemaphoreGuard<'a>(&'a BinarySemaphore);
impl<'a> Drop for SemaphoreGuard<'a> {
fn drop(&mut self) {
self.0.release();
}
}
impl BinarySemaphore {
pub fn lock(&self) -> SemaphoreGuard<'_> {
self.acquire();
SemaphoreGuard(self)
}
}
Нюансы
Память и порядок операций.Acquire
на compare_exchange
гарантирует, что изменения до release
видны захватившему. Release
на swap
экспонирует все данные до отдачи жетона.
Контекстные переключения.
При высоких нагрузках два потока могут бесконечно «пинг-понговать» жетон, создавая бурю context switch
. Решение — увеличивать полезную работу в критической секции или переходить на счётный семафор.
Приоритеты.futex
не учитывает приоритеты. Для realtime-сценариев лучше использовать FUTEX_PI
или RT-mutex.
Спуровые пробуждения.futex
может разбудить поток без WAKE
. Именно поэтому после park
всегда пересчитывается условие.
Переносимость.
Для Windows и macOS есть аналог — крейт atomic-wait
. Но он не даёт коллбэков под замком, что затрудняет составные конструкции.
Заключение
Бинарный семафор на базе futex
и parking_lot_core
— компактный и эффективный примитив: 4 байта памяти, ни одной динамической аллокации и максимум один системный вызов в случае контеншена. Лёгок в реализации и понятен.
Если вам доводилось решать похожие задачи другими способами, или у вас есть соображения, — делитесь опытом и комментариями.
Если вам интересна разработка на Rust, приглашаем на серию открытых уроков курса Rust Developer. Professional:
«Техническое собеседование на Middle Rust разработчика» — 24 июля в 20:00
Разберём типовые вопросы, подходы к решению задач и критерии оценки.«Rust в деле: пишем многопользовательский чат с сервером, клиентом и CLI» — 14 августа в 20:00
Покажем, как собрать рабочий прототип с нуля.«Макросы в Rust: от macro_rules! до процедурных макросов» — 19 августа в 20:00
Поговорим о макросах, механизмах их работы и применении в реальных задачах.Кроме того, можно пройти тестирование по курсу Rust Developer. Professional и оценить, насколько хорошо вы ориентируетесь в основных темах.