Сервис перестал отвечать. 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 между ними может оказаться сетевой запрос, отмена задачи или очередь из чужих писателей. Длительность удержания перестаёт быть свойством вашего кода. О

Порядок разбора:

  1. Грепом по проекту найти все .await внутри областей, где жив какой-нибудь гард. Это одно движение, и оно закрывает первые три пункта.

  2. Посмотреть, что под замком лежит. Данные — значит std::sync::Mutex и синхронные методы-обёртки. Ресурс ввода-вывода — значит либо tokio::sync::Mutex, либо задача-владелец с каналом.

  3. Пройти по всем RwLock и проверить, не берётся ли чтение под живым чтением.

  4. Пройти по всем местам, куда может прилететь отмена: select, timeout, abort. Убедиться, что изменение состояния там атомарно относительно точек .await.

К тому же подозреваю, что под конкуренцией разрыв между std и tokio ведёт себя нелинейно и в какой-то точке разворачивается в пользу tokio за счёт очереди

Разобраться с конкурентностью в Rust проще, когда хорошо понимаешь базовые механизмы языка: владение, заимствование и работу со ссылками. А дальше можно посмотреть, как эти принципы применяются уже в прикладных задачах — например, при разработке кроссплатформенных приложений.

На ближайших бесплатных уроках можно разобрать эти темы вместе с преподавателями, задать вопросы по своим кейсам и заодно посмотреть, как устроено обучение на практике.

  • 9 сентября в 20:00. «Владение, заимствование и ссылки в Rust: как компилятор делает ваш код безопасным». Записаться

  • 23 сентября в 20:00. «Создание кроссплатформенного приложения с GUI на Rust: от идеи до реальности». Записаться

Полный список бесплатных уроков сентября смотрите в дайджесте.

Комментарии (4)


  1. EgorSharin
    07.09.2026 23:12

    Большое спасибо!

    Я понимаю, почему у этой замечательной статьи до сих пор нет комментариев: попробуй хоть что-то правдивое вякнуть на эту тему, и сектанты заминусуют до полного отлучения от их церк... э-э-э... ну да.


    1. Boneyan
      07.09.2026 23:12

      Эти сектанты сейчас с вами в одной комнате?


  1. si_12345
    07.09.2026 23:12

    У дипсика проблемы с асинхронностью в расте - или у раста проблемы с асинхронностью, я не знаю как правильно сказать
    Я прошу дипсик реализовать на питоне вычислительный математический алгоритм с длинной арифметикой, затем беру эту работающую питоновскую версию и прошу его переписать на раст
    Затем прошу переписать растовскую однопотьочную версию на многопоточную растовскую версию, и тут начинаются проблемы
    Сначала он спотыкается о многочисленные синтаксические ловушки растовской многопоточности
    В конце концов он исправляет все ошибки, распараллеливает однопоточный код на 24 ядра, после чего выясняется, что код стал еще медленнее однопоточного, либо вообще не работает
    Это многократно подтвержденный факт
    После этого я зарекся распараллеливать на расте


    1. Boneyan
      07.09.2026 23:12

      я зарекся

      Вы ведь даже и не пытались :) То что тупая железка не умеет программировать и то что вы не знаете язык - это не проблема языка