В async Rust одна неудачно взятая блокировка может остановить весь рантайм — вместе с таймерами и задачами, которые должны были её отпустить. Разберём пять типичных ловушек с Mutex и RwLock и посмотрим, почему привычки из синхронного кода здесь дают сбой. Читать разбор
Уровень сложностиСредний
Время на прочтение8 мин
Охват и читатели8.6K
Туториал
Сервис перестал отвечать. 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: от идеи до реальности». Записаться
Полный список бесплатных уроков сентября смотрите в дайджесте.
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | «Лучшие» практики Rust, которые вас подведут. Часть 2 | 0 | 7.98 | 17-09-2026 |
| 2 | Чудеса nightly, часть 1: на чём тайком держится stable Rust | 0 | 8.55 | 29-09-2026 |
| 3 | Rust на вырост. Почему мы изучаем язык, который не входит в наш основной стек | -1 | 7.24 | 10-09-2026 |
| 4 | Rust для микроконтроллеров: что он даёт по сравнению с С | 1 | 6.6 | 29-09-2026 |
| 5 | Поменяли object на Lock, и два потока встретились в критической секции | 0 | 8.67 | 30-09-2026 |
| 6 | [Перевод] Rewrite It in Rust: когда переписывание действительно оправдано | 0 | 7.99 | 19-09-2026 |
| 7 | Как я автоматизировал работу врачей на Rust | 0 | 7.03 | 27-09-2026 |
| 8 | Ваш future весит два килобайта, и виновата одна фигурная скобка | 0 | 10.2 | 23-09-2026 |
| 9 | [Перевод] Rust 1.99.0: функции с переменным числом аргументов, информация о схеме размещения типа | 0 | 11.43 | 02-10-2026 |