sizeof(Mutex<()>) в Rust 1.61 на Linux был 40 байт. В Rust 1.62 он стал 5 (точнее, 8 с учётом выравнивания, но базовый overhead 5).

За уменьшением размера в восемь раз стоит полная переписка стандартного Mutex с pthread на futex напрямую, ускорение uncontended locks в 2-3 раза, и десятилетие, которое стандартный Mutex провёл в роли «возьми parking_lot, std::sync::Mutex медленный».

Сегодня заглянем под капот всей этой темы, разберём, что лежит внутри std::sync::Mutex после 1.62, какой алгоритм там используется, почему он на самом деле быстрее pthread, как устроен fairness (точнее, его отсутствие), зачем нужен poisoning, и в каких случаях parking_lot всё ещё имеет смысл тащить в зависимости. Заодно вернёмся к моей старой async-статье и поясним конкретнее, почему в async-задачах std::sync::Mutex это проблема, и при чём тут вообще futex.

Что было до 1.62: pthread, история и почему она была раздутой

До Rust 1.62 (вышел в июне 2022) std::sync::Mutex на Linux был просто Rust-обёрткой над pthread_mutex_t. Аналогично на macOS, FreeBSD и других Unix-системах. На Windows std использовал SRW Lock с времён динозавров, и там переписки не понадобилось.

pthread_mutex_t это часть POSIX API, разработанная для C-программ ещё в девяностых. Она поддерживает гору опций, которые в Rust почти никто не использует:

  • Типы мьютекса (PTHREAD_MUTEX_NORMAL, RECURSIVE, ERRORCHECK)

  • Robustness (PTHREAD_MUTEX_ROBUST), это про выживание блокировки после смерти владельца

  • Priority inheritance (PTHREAD_PRIO_INHERIT), наследование приоритета между потоками

  • Поддержка condition variables через pthread_cond_t

  • Различные отладочные поля

Размер pthread_mutex_t на Linux x86_64 равен 40 байтам. На macOS он ещё больше, 64 байта. Это не размер мьютекса как алгоритмической конструкции, это размер C-структуры с местом под все опциональные фичи POSIX. Большая часть этих байт в типовом Rust-коде никогда не используется, но выделяется в каждом Mutex<T> всё равно.

Кроме того, pthread_mutex_t требует динамической инициализации. Можно сделать static-инициализацию через PTHREAD_MUTEX_INITIALIZER, но в Rust std::sync::Mutex::new() это const fn (стабилизировано в 1.63), и для pthread-варианта поддержать const-инициализацию было трудно.

Производительность тоже страдала. pthread_mutex_lock на Linux идёт через glibc, который сам внутри использует futex. То есть от вашего lock() до syscall был стек: Rust-обёртка, libc symbol, реализация в glibc, futex. Каждый слой добавлял ветвления, проверки, косвенные вызовы.

В итоге std таскал за собой все эти 40 байт overhead на каждый Mutex, даже на самый простой Arc<Mutex<u64>> для счётчика. И каждая операция шла через стек обёрток.

Futex напрямую: три состояния и одна атомарная операция

Linux предоставляет syscall futex() (Fast Userspace muTEX), на котором построены все pthread-мьютексы glibc, все мьютексы C++ std::mutex, и вообще большая часть синхронизации в Linux.

futex в основе своей это две операции:

  • FUTEX_WAIT: «если *address == expected, усни до сигнала»

  • FUTEX_WAKE: «разбуди до N потоков, ждущих по этому адресу»

Это всё. Нет понятия мьютекса, нет владельца, нет fairness. futex просто механизм для эффективного засыпания и пробуждения по адресу в памяти.

Mutex поверх futex строится тремя состояниями:

  • 0: свободен

  • 1: заблокирован, без ожидающих потоков

  • 2: заблокирован, есть ожидающие

Fast path для lock(): atomic CAS из 0 в 1. Если успешно, мьютекс наш, никакого syscall не нужно. Это одна инструкция процессора (lock cmpxchg на x86, LDAXR/STLXR пара на ARM), плюс одна ветка.

Slow path: если CAS не прошёл (мьютекс уже захвачен кем-то), увеличиваем состояние до 2 (есть ожидающие), вызываем FUTEX_WAIT. Ядро усыпит поток. Когда мьютекс освободится, кто-то вызовет FUTEX_WAKE, мы проснёмся, попробуем CAS снова.

Fast path для unlock(): store 0. Если до этого было состояние 2 (были ожидающие), дополнительно вызываем FUTEX_WAKE для пробуждения одного потока.

Никакого бойлерплейта от libc, никаких опциональных фич POSIX. Просто AtomicU32 и две операции. В Rust 1.62 это и было реализовано во внутреннем модуле std::sys::sync::mutex::futex::Mutex, причём прямо без посредников.

Размер этой структуры это размер AtomicU32, то есть 4 байта. Плюс 1 байт для флага poisoning. Итого 5 байт overhead на Mutex<()> против 40 в pthread-варианте.

Размер с 40 до 5 байт

Технически sizeof(Mutex<T>) равно sizeof(внутренний sys::Mutex) + sizeof(poison::Flag) + sizeof(UnsafeCell<T>) с учётом выравнивания.

В Rust 1.61 на Linux:

  • sys::Mutex (pthread wrapper) = 40 байт, выровнен на 8

  • poison::Flag = 1 байт

  • UnsafeCell<()> = 0 байт

  • Итого Mutex<()> = 40 байт (выравнивание поглощает poison)

В Rust 1.62 на Linux:

  • sys::Mutex (futex) = 4 байта

  • poison::Flag = 1 байт

  • UnsafeCell<()> = 0 байт

  • Итого Mutex<()> = 8 байт (выравнивание на u32)

То есть Mutex<()> упал с 40 до 8 байт. Если внутри лежит что-то ненулевого размера, скажем u64, то Mutex<u64> упал с 48 до 16 байт.

Если у вас сотни мьютексов (например, по одному на каждое соединение в сетевом сервисе), вы экономите десятки килобайт памяти просто за счёт обновления Rust. На миллион Mutex<()> экономия 32 мегабайта. На реальных серверах с миллионами активных соединений это уже ощутимо.

Кроме памяти, меньший размер даёт лучшее использование кэша. 8-байтный Mutex влезает в одну кэш-линию вместе с другими полями структуры, в которой он лежит. 40-байтный занимал почти полную линию (64 байта) сам по себе, выталкивая всё остальное.

И ещё одно следствие, не очевидное: меньший размер означает меньше работы по аллокации. Box<Mutex<T>> в 1.62 это меньшая аллокация, чем в 1.61. На массовых аллокациях Arc::new(Mutex::new(...)) в горячем пути это экономия в нанасекундах на каждой операции.

Fairness, точнее, её отсутствие

Mutex по определению решает только проблему взаимного исключения. Fairness, то есть порядок выдачи блокировки, это отдельный вопрос, и стандартный Mutex его не решает.

Fair mutex даёт блокировку потокам в порядке очереди, FIFO. То есть если поток A первым попросил блокировку, потом B, потом C, то порядок выдачи будет именно A, B, C. Unfair mutex даёт блокировку любому потоку, который оказался готов её получить. Если разбудили C, он получает блокировку, даже если A ждал дольше всех.

На первый взгляд кажется, что fair это правильно. На практике fair мьютексы имеют хуже throughput из-за convoy effect. Если поток только что отпустил мьютекс и хочет взять его снова (типичный случай в hot loop), fair мьютекс заставит его встать в очередь и ждать. Это означает context switch и проседание производительности.

Unfair мьютекс позволит этому потоку забрать блокировку снова, без context switch, и throughput остаётся высоким.

Linux futex unfair. std::sync::Mutex на Linux после 1.62 unfair (потому что использует futex). Windows SRW unfair. macOS os_unfair_lock unfair (это даже в названии указано). Всё unfair.

Цена unfairness это потенциальное starvation: какой-то поток может никогда не получить блокировку, если её всегда забирают другие. На практике это конечно редко является проблемой, потому что планировщик ОС всё равно даёт всем потокам процессор, и в какой-то момент любой ждущий поток оказывается в нужном состоянии.

parking_lot предлагает eventually fair mutex как опцию. Это unfair большую часть времени (для throughput), но периодически (по таймеру или числу операций) принудительно отдаёт блокировку самому давно ждущему. Это защищает от starvation, не теряя throughput на типовых workload.

В std::sync::Mutex нет fairness API. Это сознательное решение, ведь для абсолютного большинства применений unfair достаточно, а попытка дать выбор только усложнит API. Если вам прям это супер нужно, parking_lot всё ещё есть.

Poisoning, зачем оно есть и почему многие не любят

Poisoning это механизм, который помечает мьютекс как «отравленный», если поток, держащий блокировку, паникнул. После этого все попытки lock() возвращают Err(PoisonError), и вызывающему приходится явно решать, что с этим делать.

Идея в основе разумная. Если поток держал блокировку, паникнул посреди операции, и не успел восстановить инварианты структуры данных под мьютексом, то другой поток, который возьмёт эту блокировку, получит данные в неконсистентном состоянии. Лучше об этом предупредить.

На практике poisoning вызывает какие-то смешанные чувства. Аргументы против:

  • Усложняет код: каждый lock() возвращает Result, который надо unwrap-ать или обрабатывать

  • На практике большинство кода всё равно делает .unwrap() или .expect(), теряя весь смысл poisoning

  • Если код паникнул внутри мьютекса, чаще всего вы хотите чтобы программа упала, а не продолжила с возможно битыми данными

  • Добавляет байт в структуру Mutex и ветвление в lock()/unlock()

Аргументы за:

  • Защита от тихих багов, когда программа продолжает работать с битым состоянием

  • Возможность явно recover через PoisonError::into_inner() в случаях, когда вы знаете, что состояние OK

  • Сигнал о баге в коде, который иначе можно не заметить

parking_lot::Mutex намеренно не имеет poisoning. Мьютекс должен делать одну вещь хорошо, а recovery после паники это задача более высокого уровня абстракции.

В Rust 1.66 появился метод Mutex::clear_poison(), позволяющий снять отравление вручную.

Структурно poisoning в std::sync::Mutex занимает 1 байт, плюс одна проверка при каждом lock() и одна установка флага при unwind в Drop. Это всё. Никакой сложности там нет, кроме API-нагрузки на каждый вызов.

parking_lot в 2026: что осталось

parking_lot долгое время был стандартом «настоящего быстрого мьютекса» в Rust. До 1.62 он действительно был на голову лучше std::sync::Mutex по всем метрикам: быстрее (1.5-2x на uncontended), меньше (1 байт против 40), компактнее API.

После 1.62 ситуация изменилась.

На основных платформах (Linux, macOS, Windows) std::sync::Mutex теперь сопоставим с parking_lot::Mutex по скорости. Размер тоже почти сравнялся (parking_lot::Mutex это 1 байт overhead, std::sync::Mutex это 5 байт на Linux после 1.62).

Где parking_lot всё ещё выигрывает:

Первое, нет poisoning. Если вы знаете, что вам это не нужно (а большинству действительно не нужно), parking_lot::Mutex даёт чище API и чуть меньшую структуру.

Второе, eventually fair option. Если вам важна защита от starvation на specific workload, parking_lot предоставляет lock_fair() метод.

Третье, timed locking. parking_lot::Mutex имеет try_lock_for(Duration) и try_lock_until(Instant). В std::sync::Mutex таких методов нет (на середину 2026 они обсуждаются как дополнение API, но пока не стабилизированы).

Четвёртое, RawMutex API. parking_lot предоставляет low-level API для построения собственных synchronization primitives. В std такого нет.

Пятое, кросс-платформенная консистентность. parking_lot использует одну реализацию на всех платформах (на разных примитивах ОС, но один внутренний алгоритм). std::sync::Mutex на каждой платформе использует свой примитив (futex на Linux, SRW на Windows, os_unfair_lock на macOS). Для большинства это плюс (используется самый быстрый на каждой ОС), но если вам нужна предсказуемая performance characteristic на всех платформах, parking_lot даёт более стабильное поведение.

Когда parking_lot не нужен:

  • Простые случаи Mutex<T> для protect-data, где poisoning не интересует, но и не мешает

  • Если вы хотите минимум зависимостей

  • Если вам нужен const fn Mutex::new() (std получил это в 1.63, parking_lot чуть позже)

  • Если у вас async-код (тут не нужен ни тот, ни другой, см. следующую секцию)

Мой совет: если вы тянете parking_lot в проект просто из привычки, попробуйте заменить на std::sync::Mutex и замерить. С большой вероятностью разницы не увидите. Если разница есть и она в вашу пользу, оставляйте parking_lot и пишите комментарий в коде, почему он нужен (через год вы сами не вспомните). Если разницы нет, удаляйте зависимость, упрощайте Cargo.toml.

Async и std::sync::Mutex, или зачем тогда async-mutex

Я писал отдельную статью про async и .await, где упоминал, что std::sync::Mutex в async-задачах плох. Теперь, когда мы заглянули в futex, можно объяснить конкретнее, почему.

std::sync::Mutex::lock() блокирует поток. Если поток заблокирован, он не может выполнять другие async-задачи на том же executor. То есть один Mutex.lock() выводит из строя весь executor-поток на время удержания блокировки.

Пример. Вы пишете web-сервер на Tokio, у вас стандартный multi-thread executor с N worker-потоками. Где-то в коде запроса есть:

let guard = mutex.lock().unwrap();
do_something(&*guard).await;  // долгая операция

Пока этот .await не resolved, поток заблокирован на локе. Другой запрос, прилетевший на тот же worker-поток, ждёт. Но N-1 других worker-потоков могут быть тоже заняты схожим способом. В худшем случае весь executor мёртв, пока что-нибудь не разблокируется. Tokio даже не успевает понять, что что-то идёт не так, потому что ему никто не отдаёт управление.

Если в этой ситуации использовать async-mutex (tokio::sync::Mutex или async_lock::Mutex), .await на локе не блокирует поток. Executor видит, что задача не может продвинуться, паркует её, берёт другую задачу. Поток продолжает работать и никто не висит.

Технически tokio::sync::Mutex это semaphore поверх futures. Он медленнее std::sync::Mutex для uncontended случаев (потому что добавляет futures-overhead и канал ожидания), но не блокирует исполнителя.

Когда что использовать:

  • В sync-коде: std::sync::Mutex (или parking_lot, если есть конкретное требование)

  • В async-коде, если lock держится коротко и без .await внутри: std::sync::Mutex, он быстрее

  • В async-коде, если lock держится долго или есть .await внутри: tokio::sync::Mutex

  • В async-коде, если по архитектурным причинам важна consistency: tokio::sync::Mutex независимо от длины

Правило простое: между .lock() и drop(guard) не должно быть ни одного .await. Если есть, лок должен быть async. Тут даже clippy умеет ругаться (lint await_holding_lock).

Заглянем в сам код алгоритма

Раз уж разобрали по компонентам, посмотрим на сам алгоритм std::sys::sync::mutex::futex::Mutex в чуть упрощённом виде. Я выкинул некоторые детали обработки memory ordering и spin-логики для ясности, в реальных исходниках std код чуть сложнее.

struct Mutex {
    // 0 = unlocked
    // 1 = locked, no waiters
    // 2 = locked, has waiters
    futex: AtomicU32,
}

impl Mutex {
    pub const fn new() -> Self {
        Self { futex: AtomicU32::new(0) }
    }

    pub fn lock(&self) {
        if self.futex
            .compare_exchange(0, 1, Acquire, Relaxed)
            .is_err()
        {
            self.lock_contended();
        }
    }

    fn lock_contended(&self) {
        // спиннимся, ждём пока освободится или истечёт счётчик
        let mut state = self.spin();

        // если после спина свободен, пробуем взять без contended-маркера
        if state == 0 {
            match self.futex.compare_exchange(0, 1, Acquire, Relaxed) {
                Ok(_) => return,
                Err(s) => state = s,
            }
        }

        loop {
            // помечаем что есть ожидающие
            if state != 2 && self.futex.swap(2, Acquire) == 0 {
                return; // успели до того как кто-то ещё стал ждать
            }
            // спим до пробуждения
            futex_wait(&self.futex, 2);
            state = self.spin();
        }
    }

    pub fn unlock(&self) {
        if self.futex.swap(0, Release) == 2 {
            // были ожидающие, будим одного
            futex_wake(&self.futex);
        }
    }
}

Spin-цикл перед futex_wait (внутри spin) это попытка получить мьютекс несколько раз обычным CAS, прежде чем уходить в kernel. Если мьютекс держится коротко, spin часто успешен, и можно избежать syscall. В текущей версии std счётчик спина около ста итераций, после чего поток сдаётся и идёт в futex_wait.

После спина важный момент. spin возвращает текущее значение futex, но не пытается захватить блокировку. Если значение оказалось 0 (свободен), нужно ещё одним CAS попробовать перейти из 0 в 1. Если успех, lock наш. Если кто-то опередил между нашим spin и нашим CAS, обновляем локальное состояние и заходим в loop, где будем устанавливать флаг «есть ожидающие» и засыпать.

Memory ordering: Acquire на load в lock, Release на store в unlock. Стандартный паттерн для критических секций. Он гарантирует, что операции в критической секции видны другим потокам после unlock, и операции до lock видны коду в критической секции.

swap(2) для выставления флага «есть ожидающие» делается атомарно, чтобы избежать гонок с unlock, который может пройти между нашим load (state==1) и нашим store (state=2). Если бы мы делали отдельный load и store, мог быть пропущен FUTEX_WAKE, и поток уснул бы навсегда.

В целом это классическая реализация futex-based mutex. Алгоритм давно устоялся.

Не только Mutex: RwLock и Condvar тоже переписали

В 1.62 переписали не один Mutex. Под раздачу попали ещё RwLock и Condvar, по тем же причинам и в ту же эпоху.

RwLock до 1.62 был обёрткой над pthread_rwlock_t, размером 56 байт на Linux x86_64. После переписки он использует futex с тремя счётчиками: количество readers, количество waiting writers, флаг writer-locked. Размер упал примерно до 8 байт. Та же история, что с Mutex, только чуть сложнее алгоритмически (потому что RwLock логически сложнее).

Condvar (condition variable, переменная условий) тоже был обёрткой над pthread_cond_t, ~48 байт. После 1.62 это AtomicU32 (4 байта) поверх futex, с особой обработкой signal/broadcast. Хитрость с Condvar в том, что его обычно используют вместе с Mutex (через wait(&mut Mutex)), и futex-based Mutex позволяет Condvar делать атомарный unlock-then-wait через FUTEX_WAIT_REQUEUE_PI или похожий механизм.

В сумме переписка 1.62 затронула все три базовых примитива стандартной библиотеки. У всех одинаковый паттерн: сбрасываем pthread-обёртку, используем платформенный low-level примитив напрямую, режем размер в 5-10 раз, ускоряем fast path в 2-3 раза.

Кстати, аналогичные изменения произошли и для других платформ. На macOS std перешёл на os_unfair_lock для Mutex и dispatch_semaphore_t для некоторых примитивов. На Windows было меньше работы, потому что SRW Lock использовался давно, но Condvar тоже причесали.


Заключение

До Rust 1.62 std::sync::Mutex на Linux был обёрткой вокруг pthread_mutex_t. 40 байт overhead, медленный fast-path, репутация «возьми parking_lot». После 1.62 std::sync::Mutex использует futex напрямую. 5 байт overhead, fast path в одну атомарную операцию, скорость сравнима с parking_lot. На Windows SRW использовался давно, на macOS перешли на os_unfair_lock.

Это сделало parking_lot во многих случаях избыточным. Если вы сейчас тянете parking_lot в проект просто из привычки из 2020-го, имеет смысл попробовать заменить на std::sync::Mutex и замерить. С большой вероятностью разницы не увидите. Если есть специфические требования (нет poisoning, fair lock, timed locking, RawMutex), parking_lot остаётся актуальным.

И про async. std::sync::Mutex быстр и компактен, но в async-коде он блокирует поток. Для самого такого базового async-сервиса с короткими критическими секциями без .await внутри он подходит. Для долгих критических секций или содержащих .await обязательно нужен async-mutex (tokio::sync::Mutex или эквивалент). Не из-за производительности (std::sync::Mutex тут как раз быстрее), а из-за того, что без async-mutex легко повесить весь executor.

Стандартная библиотека Rust в 2026 году сильно лучше, чем была в 2020.


Размещайте облачную инфраструктуру и масштабируйте сервисы с надежным облачным провайдером Beget.
Эксклюзивно для читателей Хабра мы даем бонус 10% при первом пополнении.

Воспользоваться