Строим примитивы синхронизации
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Строим примитивы синхронизации
Прошлый урок дал атомики и порядки памяти как теорию:
compare_exchange, пара release-acquire, happens-before. Сегодня превратим теорию в инструмент и соберём то, чем мы пользовались как чёрным ящиком, своими руками. Сначала спинлок на одномAtomicBool, потом поймём, почему крутиться в пустоту это расточительно, и дойдём до того, как настоящийMutexусыпляет поток через futex и будит обратно. К концу урока вMutex::lock()для тебя не останется магии: это атомарный флаг плюс протокол, что делать, когда флаг занят.
Идея
Замок это очень простая вещь: один бит «занято или свободно» плюс договор, как его захватывать и отпускать. Весь вопрос в том, что делать потоку, который пришёл, а замок занят. Есть два ответа, и оба мы построим.
Первый: крутиться в цикле и проверять флаг снова и снова, пока он не освободится. Это спинлок. Он мгновенно реагирует, но жжёт целое ядро вхолостую, пока ждёт. Второй: уснуть и попросить операционную систему разбудить, когда замок отпустят. Это то, что делает настоящий Mutex, и для этого есть системный примитив futex. Спинлок хорош, когда критическая секция крошечная и ждать заведомо считанные наносекунды; усыпление хорошо, когда ждать можно долго. Промышленный замок совмещает оба: сначала немного крутится, потом, если не повезло, засыпает.
Спинлок на одном AtomicBool
Соберём SpinLock<T>. Внутри атомарный флаг и сами данные. Данные придётся положить в UnsafeCell, потому что мы даём изменять их через общую ссылку &self, а это та самая внутренняя мутабельность, только теперь её безопасность мы доказываем сами, как учил урок про unsafe.
use std::cell::UnsafeCell;
use std::ops::{Deref, DerefMut};
use std::sync::atomic::{AtomicBool, Ordering};
pub struct SpinLock<T> {
locked: AtomicBool,
value: UnsafeCell<T>,
}
// Обещание компилятору: к данным внутри обращается ровно один поток за раз,
// потому что мы это гарантируем флагом. Без этого SpinLock не был бы Sync.
unsafe impl<T: Send> Sync for SpinLock<T> {}
impl<T> SpinLock<T> {
pub const fn new(value: T) -> Self {
Self {
locked: AtomicBool::new(false),
value: UnsafeCell::new(value),
}
}
pub fn lock(&self) -> SpinGuard<'_, T> {
// Пытаемся одним CAS перевести флаг из false в true.
while self
.locked
.compare_exchange_weak(false, true, Ordering::Acquire, Ordering::Relaxed)
.is_err()
{
// Не вышло: крутимся на чтении, пока замок не освободится.
while self.locked.load(Ordering::Relaxed) {
std::hint::spin_loop();
}
}
SpinGuard { lock: self }
}
}
Главное тут это порядки памяти, и они прямо из прошлого урока. Захват флага идёт через Acquire, отпускание (мы напишем его ниже) через Release. Это та самая пара: всё, что поток изменил внутри критической секции, запечатывается release-отпусканием и распечатывается acquire-захватом у следующего владельца. Поэтому данные под замком передаются между потоками согласованно, без гонок. Если бы мы поставили Relaxed, замок «работал» бы как взаимное исключение, но изменения данных могли бы не дойти до следующего потока: ровно тот баг, что мы разбирали на флаге READY.
Доступ к данным отдаём через guard с Deref, а отпускание вешаем на Drop, как в настоящем MutexGuard:
pub struct SpinGuard<'a, T> {
lock: &'a SpinLock<T>,
}
impl<T> Deref for SpinGuard<'_, T> {
type Target = T;
fn deref(&self) -> &T {
// SAFETY: пока guard жив, флаг захвачен, мы единственный владелец.
unsafe { &*self.lock.value.get() }
}
}
impl<T> DerefMut for SpinGuard<'_, T> {
fn deref_mut(&mut self) -> &mut T {
// SAFETY: эксклюзивность гарантирует флаг, а не &mut.
unsafe { &mut *self.lock.value.get() }
}
}
impl<T> Drop for SpinGuard<'_, T> {
fn drop(&mut self) {
// Release: публикуем всё, что сделали под замком, и отпускаем флаг.
self.lock.locked.store(false, Ordering::Release);
}
}
Это полноценный рабочий замок меньше чем на сорок строк. unsafe тут изолирован в три места, и каждое снабжено доказательством: эксклюзивность держит флаг, поэтому сырой доступ к UnsafeCell безопасен. Снаружи SpinLock ничем не опаснее Mutex.
Почему busy-wait это расточительство
Внутренний цикл lock не зря читает флаг через простой load и зовёт std::hint::spin_loop, а не долбит compare_exchange. CAS это запись, а запись в атомарную ячейку дёргает всю систему когерентности кэшей: каждая попытка гоняет строку кэша между ядрами. Чтение дешевле и не мешает владельцу. Поэтому идиома такая: один CAS на попытку захвата, а ждём на дешёвом чтении.
Но даже вежливый спин это сжигание ядра впустую. Поток ничего полезного не делает, просто крутится. Пока критическая секция занимает наносекунды, это выгодно: усыпить и разбудить поток через ядро ОС стоит микросекунды, дольше самого ожидания. Но если владелец задержался, скажем, ушёл в системный вызов, крутящийся поток жжёт целое ядро на пустоту. Промежуточный приём это экспоненциальный backoff: первые попытки крутимся плотно, дальше паузы между ними растут. Но честное решение для долгого ожидания одно: усыпить поток.
Усыпить и разбудить: futex
Чтобы поток не крутился, а спал, нужна помощь операционной системы. На Linux это один системный вызов, futex. У него две операции. wait(адрес, ожидаемое) говорит ядру: «если по этому адресу всё ещё лежит ожидаемое значение, усыпи меня». wake(адрес, n) будит до n потоков, спящих на этом адресе. Проверка значения в wait неделима с засыпанием, и это решает гонку: между «увидел, что замок занят» и «уснул» никто не сможет проскочить с пробуждением.
На futex замок выглядит так: быстрый путь, когда замок свободен, проходит целиком в пользовательском коде одним атомарным CAS, без всякого ядра. В ядро через wait идём только когда замок реально занят и надо спать. Когда владелец отпускает занятый замок, он зовёт wake, чтобы поднять одного из спящих. Платим за поход в ядро только при настоящей конкуренции, а в неконкурентном случае замок почти бесплатен. В Rust сырой доступ к futex даёт крейт atomic-wait (wait и wake поверх атомика), а внутри стандартного Mutex это уже зашито: на Linux он построен ровно на futex.
// идея futex-замка, схематично (реальный код прячет это в std):
// захват
if locked.compare_exchange(false, true, Acquire, Relaxed).is_err() {
// занято: помечаем «есть ждущие» и засыпаем на адресе флага
while locked.swap(true, Acquire) {
atomic_wait::wait(&locked_as_u32, /* ожидаемое: занято */);
}
}
// отпускание
locked.store(false, Release);
atomic_wait::wake_one(&locked_as_u32); // будим одного спящего
Condvar: ждать события, а не замок
Спинлок и futex решают «жди, пока замок освободится». Но часто нужно другое: ждать, пока выполнится условие, например «в очереди появился элемент». Для этого есть Condvar. Её ключевая операция wait берёт захваченный guard, атомарно отпускает замок и усыпляет поток, а проснувшись, снова берёт замок. Эта атомарность важна: она закрывает гонку, в которой уведомление проскакивает между «проверил условие» и «уснул».
Так строится ограниченная очередь с backpressure, та самая, что ты собирал в задаче прошлого урока. Получатель ждёт на непустоту, отправитель ждёт на неполноту:
use std::sync::{Condvar, Mutex};
use std::collections::VecDeque;
pub struct Channel<T> {
queue: Mutex<VecDeque<T>>,
not_empty: Condvar,
}
impl<T> Channel<T> {
pub fn send(&self, item: T) {
let mut queue = self.queue.lock().unwrap();
queue.push_back(item);
drop(queue); // отпускаем замок ДО пробуждения, чтобы разбуженный сразу вошёл
self.not_empty.notify_one();
}
pub fn recv(&self) -> T {
let mut queue = self.queue.lock().unwrap();
// условие проверяем в цикле: пробуждение бывает ложным
while queue.is_empty() {
queue = self.not_empty.wait(queue).unwrap();
}
queue.pop_front().unwrap()
}
}
Обрати внимание на цикл while queue.is_empty(), а не if. Ложные пробуждения разрешены: wait может вернуться без всякого notify. Поэтому условие перепроверяют циклом: проснулся, проверил, рано, уснул дальше. Это не перестраховка, а обязательная часть протокола.
Что внутри настоящего Mutex
Сложим картину. Промышленный Mutex это не «или спин, или futex», а их комбинация. Быстрый путь: один атомарный CAS захватывает свободный замок без захода в ядро, и в неконкурентном случае это весь расход. Если замок занят, замок может чуть покрутиться (вдруг освободится мгновенно), а потом уснуть на futex, чтобы не жечь ядро. При отпускании, если есть спящие, зовётся пробуждение. Стандартный Mutex на Linux построен на futex именно так.
Поэтому в реальном коде свой спинлок пишут редко: стандартный Mutex почти всегда правильный выбор, а спинлок оправдан лишь для крошечных секций в особых местах вроде ядра ОС или драйвера. А крейт parking_lot, который мы упоминали, даёт замки компактнее и быстрее стандартных как раз за счёт аккуратно настроенной смеси спина и парковки. Теперь, когда ты собрал обе половины руками, понятно, что именно он оптимизирует.
Что унести из урока
Замок это атомарный флаг плюс ответ на вопрос «что делать, когда занято». Спинлок отвечает «крутиться»: сорок строк на AtomicBool, где захват через Acquire, отпускание через Release, а вся эксклюзивность данных доказана флагом вокруг UnsafeCell. Крутиться дёшево для наносекундных секций и разорительно для долгих, поэтому busy-wait делают вежливым через spin_loop и backoff, но честный ответ для долгого ожидания это усыпить поток. Усыпление даёт futex: атомарная пара «проверь значение и усни» плюс «разбуди», с быстрым путём целиком в пользовательском коде. Condvar поднимает это до ожидания условия, перепроверяемого в цикле из-за ложных пробуждений. А настоящий Mutex совмещает быстрый CAS и futex, поэтому свой замок почти никогда не нужен.
Дальше уберём замок совсем. В следующем уроке compare_exchange понесёт целые структуры данных: соберём кольцевой буфер, по которому два потока обмениваются без единой блокировки.