Сервис перестал отвечать. CPU — ноль, память ровная, паник нет, последняя строка в логе: «беру блокировку». Таймаут, которым эта блокировка была обёрнута, тоже не сработал: таймер — задача, а исполнять её некому. Один рабочий поток встал в ожидании мьютекса, отпустить который должна задача, которой для продолжения нужен ровно этот поток.
В синхронном коде такое кончилось бы иначе. Поток, повисший на мьютексе, мешает себе и тому, кто его ждёт, а остальные потоки живут дальше. В async всё иначе, потому что поток здесь не единица работы, а исполнитель для сотен задач. Заблокировали поток — заморозили всех, кто на нём стоял в очереди, включая таймеры, ввод-вывод и ту самую задачу, чьего завершения вы ждёте.
В статье рассмотрим пять мест, где блокировки в async Rust ведут себя не так, как подсказывает опыт синхронного кода.
Компилятор ловит гард через .await только у Send-футур
Правило «не держи std::sync::MutexGuard через await» знают многие, и обычно добавляют: компилятор всё равно не даст. Вообще-то даст, просто не там, где вы ждёте.
Вот кэш, который лезет в сеть, не отпуская блокировку:
async fn broken(cache: &Mutex<HashMap<String, String>>, k: &str) -> String { let mut guard = cache.lock().unwrap(); if let Some(v) = guard.get(k) { return v.clone(); } let v = fetch(k).await; // гард жив через await guard.insert(k.to_string(), v.clone()); v }
Отдаём в tokio::spawn — компилятор ругается:
error: future cannot be sent between threads safely = help: within `{async block@src/main.rs:19:18}`, the trait `Send` is not implemented for `std::sync::MutexGuard<'_, HashMap<...>>` note: future is not `Send` as this value is used across an await
Требование пришло из границы F: Future + Send + 'static у tokio::spawn. Не из языка, не из проверки на «правильный async». Убираем spawn и просто ждём эту функцию в main — всё собирается и работает:
#[tokio::main] async fn main() { let cache = Mutex::new(HashMap::new()); println!("{}", broken(&cache, "a").await); println!("скомпилировалось и отработало"); }
То же самое с spawn_local под LocalSet, любой функцией на однопоточном рантайме — везде, где задача не переезжает между потоками. Компилятор молчит не потому, что код корректен, а потому, что его никто не просил проверять.
Молчание обходится дорого.
Однопоточный рантайм, две задачи: первая берёт std-мьютекс и уходит в sleep, вторая через 50 мс пробует взять тот же мьютекс.
local.spawn_local(async move { let mut g = a.lock().unwrap(); tokio::time::sleep(Duration::from_millis(200)).await; *g += 1; println!("задача A завершилась"); }); local.spawn_local(async move { tokio::time::sleep(Duration::from_millis(50)).await; println!("задача B пробует взять блокировку"); let _g = b.lock().unwrap(); // блокирует единственный поток println!("задача B взяла блокировку"); }); let res = tokio::time::timeout(Duration::from_secs(3), local).await;
Вывод:
задача B пробует взять блокировку
И всё. Задача A не завершилась, задача B блокировку не взяла, трёхсекундный таймаут не сработал — процесс пришлось закрывать снаружи. Поток застрял внутри lock(), задача A ждёт очереди на этом же потоке, таймер ждёт там же. Компилятор не помешает держать std-гард через .await там, где задача не переезжает между потоками. К адекватному конкурентному коду это практически никогда не приводит.
Насколько уверенно вы ориентируетесь в Rust, можно проверить на вступительном тесте.
tokio::sync::Mutex по умолчанию: 50 наносекунд вместо 16
После предыдущего раздела хочется заменить все std::sync::Mutex на tokio::sync::Mutex и забыть. Понятная реакция, но в большинстве мест неверная. Обычный мьютекс из стандартной библиотеки в асинхронном коде использовать нормально и часто предпочтительнее.
Асинхронный мьютекс умеет только одно сверх обычного — жить через точку .await. За это он берёт с каждого захвата. Миллион захватов и отпусканий без конкуренции, один поток, релизная сборка:
let s = StdMutex::new(0u64); let t0 = Instant::now(); for _ in 0..N { *s.lock().unwrap() += 1; } let std_el = t0.elapsed(); let t = TokioMutex::new(0u64); let t0 = Instant::now(); for _ in 0..N { *t.lock().await += 1; } let tok_el = t0.elapsed();
std::sync::Mutex 15.6ms -> 16 нс на операцию tokio::sync::Mutex 50.1ms -> 50 нс на операцию отношение 3.2x
Тридцать четыре наносекунды разницы на захват. На горячем пути, который дёргают миллион раз в секунду, — это 34 мс CPU в секунду на ровном месте. Направление от железа не зависит. Асинхронный мьютекс устроен через семафор с очередью пробуждений, а std-мьютекс на неконкурентном пути укладывается в атомарную операцию.
К тому же мьютекс tokio гарантирует строгий FIFO: порядок захвата совпадает с порядком вызовов lock.
Рабочее правило в том, что если под замком данные — счётчик, карта, структура состояния — берите std::sync::Mutex или parking_lot::Mutex и не удерживайте его через .await. Если под замком ресурс, работа с которым сама асинхронна — соединение с базой, сокет, — тогда tokio::sync::Mutex.
Десять задач по сто миллисекунд занимают секунду
Допустим, асинхронный мьютекс взят осознанно, и гард живёт через .await. Тогда всё между захватом и отпусканием исполняется строго по одному. Само по себе это смысл мьютекса, но в async под замок легко заезжает ввод-вывод, который никакой защиты не требует.
Десять задач, каждая делает стомиллисекундный запрос и увеличивает счётчик. Разница — только в том, где стоит захват:
// гард живёт через await let mut g = m.lock().await; io().await; *g += 1; // блокировка только вокруг мутации io().await; *m.lock().await += 1;
гард через await: 1.013060726s, счётчик 10 блокировка после: 101.38357ms, счётчик 10
Секунда против ста миллисекунд. Десятикратная разница взялась из того, что в первом варианте сетевые запросы выстроились в очередь. Конкурентность упала до единицы, хотя защищать надо было четыре байта счётчика.
Разбирается по одному вопросу на каждый .await внутри критической секции: что сломается, если два таких вызова пойдут одновременно? Если ответ «ничего» — вызову место снаружи.
Если ответ есть, скорее всего вам нужен не мьютекс, а отдельная задача-владелец, которая принимает запросы каналом и исполняет их по очереди.
Второй read() не берётся, потому что впереди писатель
RwLock в async приносит ловушку, которой в синхронном мире у многих не было. Политика tokio — write-preferring, она же честная FIFO. Новый читатель не получит блокировку, пока не отработают все писатели, вставшие в очередь раньше него. Сделано, чтобы читатели не заморили писателей голодом.
Следствие на трёх шагах: берём чтение, ставим писателя в очередь, пробуем взять второе чтение, не отпустив первое:
let r1 = lock.read().await; println!("read #1 взят"); let l = lock.clone(); tokio::spawn(async move { let _w = l.write().await; println!("писатель вошёл"); }); tokio::time::sleep(Duration::from_millis(50)).await; let r2 = tokio::time::timeout(Duration::from_millis(500), lock.read()).await; match r2 { Ok(_) => println!("read #2 взят — очередь читательская"), Err(_) => println!("read #2 НЕ взят за 500 мс — писатель впереди"), }
read #1 взят писатель в очереди read #2 НЕ взят за 500 мс — писатель впереди писатель вошёл
Второе чтение не проходит. Первое чтение держит блокировку и ждёт второго, второе ждёт писателя, писатель ждёт первого — круг замкнулся. В реальном сервисе два read() разбросаны по разным функциям, и между ними десяток кадров стека.
Проверка одна: пройти по всем местам, где под живым read-гардом вызывается что-то, что тоже может взять этот же RwLock.
Отмена посреди критической секции и отравление, которого нет
Это специфично именно для async. Задачу в tokio можно отменить: abort, проигравшая ветка select, истёкший timeout, брошенный JoinHandle. Отмена наступает в точке .await, и если такая точка оказалась внутри критической секции, задача умирает посередине изменения данных.
Счёт, с которого списывают деньги, и журнал, куда пишут списание. Между ними — асинхронная проверка:
let task = tokio::spawn(async move { let mut g = a.lock().await; g.balance -= 50; // деньги сняли slow_audit().await; // точка отмены g.log.push("списание 50".into()); // запись в журнал }); tokio::time::sleep(Duration::from_millis(50)).await; task.abort();
после отмены: Account { balance: 50, log: [] }
Баланс уменьшился, журнал пуст. Гард освободился при разворачивании задачи, мьютекс исправен, следующий, кто его возьмёт, увидит структуру, согласованную по типам и бессмысленную по смыслу. Никакого сигнала об этом он не получит: у tokio::sync::Mutex нет понятия отравления. У std::sync::Mutex оно есть — после паники под замком is_poisoned() вернёт true. Но отмена задачи паникой не считается, так что даже будь отравление у tokio, здесь бы оно не сработало.
Это можно исправить формой кода. Все изменения, которые обязаны произойти вместе, собираются в один синхронный кусок без .await внутри: посчитали заранее, взяли замок, применили целиком, отпустили. Асинхронный аудит уезжает до захвата или после отпускания.
Когда разнести не получается — явный откат. Обёртка, которая в Drop возвращает баланс на место, если до отметки об успехе дело не дошло:
struct Rollback<'a> { acc: &'a mut Account, amount: i64, done: bool } impl Drop for Rollback<'_> { fn drop(&mut self) { if !self.done { self.acc.balance += self.amount; } } }
Операция идёт через эту обёртку, done выставляется последней строкой, когда состояние снова согласовано:
let mut rb = Rollback { acc: &mut *g, amount: 50, done: false }; rb.acc.balance -= 50; slow_audit().await; // сняли задачу здесь — сработает Drop rb.acc.log.push("списание 50".into()); rb.done = true;
Тот же прогон с abort через 50 мс теперь даёт Account { balance: 100, log: [] }. Работает потому, что при отмене задачи её кадр разворачивается штатно. Деструкторы локальных переменных вызываются в обычном порядке — тот же механизм, что освобождает и сам гард.
Чем заменить замок там, где он не нужен
Про blocking_lock стоит сказать отдельно, потому что его находят как решение проблемы из первого раздела. Метод существует и ждёт блокировку синхронно, но внутри рантайма он падает:
thread 'main' panicked at src/main.rs:6:16: Cannot block the current thread from within a runtime.
Паника вместо тихого зависания — предпочтительнее, её видно сразу. Место blocking_lock — внутри spawn_blocking или в обычном потоке, который забирает данные из асинхронного мира.
Оберните Arc<Mutex<...>> в структуру, которая наружу отдаёт обычные синхронные методы, а замок берёт внутри:
#[derive(Clone, Default)] struct Cache(Arc<Mutex<HashMap<String, String>>>); impl Cache { fn get(&self, k: &str) -> Option<String> { self.0.lock().unwrap().get(k).cloned() } fn put(&self, k: String, v: String) { self.0.lock().unwrap().insert(k, v); } } async fn lookup(cache: &Cache, k: &str) -> String { if let Some(v) = cache.get(k) { // гард умирает внутри get return v; } let v = fetch(k).await; cache.put(k.to_string(), v.clone()); v }
Это тот же кэш из первого раздела, но он спокойно уходит в tokio::spawn и не требует асинхронного мьютекса. Гард физически не может пережить .await: в синхронном методе таких точек нет.
Когда нужен разделяемый доступ к ресурсу ввода-вывода, часто лучше завести отдельную задачу-владельца и общаться с ней сообщениями.
Что со всем этим делать
Общее у всех пяти мест: мьютекс в async защищает данные, но ничего не говорит про время. В синхронном коде эти две вещи почти совпадают: важная секция коротка, потому что между захватом и отпусканием — несколько инструкций. В async между ними может оказаться сетевой запрос, отмена задачи или очередь из чужих писателей. Длительность удержания перестаёт быть свойством вашего кода. О
Порядок разбора:
Грепом по проекту найти все
.awaitвнутри областей, где жив какой-нибудь гард. Это одно движение, и оно закрывает первые три пункта.Посмотреть, что под замком лежит. Данные — значит
std::sync::Mutexи синхронные методы-обёртки. Ресурс ввода-вывода — значит либоtokio::sync::Mutex, либо задача-владелец с каналом.Пройти по всем
RwLockи проверить, не берётся ли чтение под живым чтением.Пройти по всем местам, куда может прилететь отмена:
select,timeout,abort. Убедиться, что изменение состояния там атомарно относительно точек.await.
К тому же подозреваю, что под конкуренцией разрыв между std и tokio ведёт себя нелинейно и в какой-то точке разворачивается в пользу tokio за счёт очереди

Разобраться с конкурентностью в Rust проще, когда хорошо понимаешь базовые механизмы языка: владение, заимствование и работу со ссылками. А дальше можно посмотреть, как эти принципы применяются уже в прикладных задачах — например, при разработке кроссплатформенных приложений.
На ближайших бесплатных уроках можно разобрать эти темы вместе с преподавателями, задать вопросы по своим кейсам и заодно посмотреть, как устроено обучение на практике.
9 сентября в 20:00. «Владение, заимствование и ссылки в Rust: как компилятор делает ваш код безопасным». Записаться
23 сентября в 20:00. «Создание кроссплатформенного приложения с GUI на Rust: от идеи до реальности». Записаться
Полный список бесплатных уроков сентября смотрите в дайджесте.