Привет, Харб!
Сегодня рассмотрим, как реализовать собственный бинарный семафор на основе 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 и оценить, насколько хорошо вы ориентируетесь в основных темах.
ссылка на оригинал статьи https://habr.com/ru/articles/929146/
Добавить комментарий