[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"article-deep-evm-29-semafory-async-rust-dedloki-fire-and-forget":3},{"article":4,"author":51},{"id":5,"category_id":6,"title":7,"slug":8,"excerpt":9,"content_md":10,"content_html":11,"locale":12,"author_id":13,"published":14,"published_at":15,"meta_title":16,"meta_description":17,"focus_keyword":18,"og_image":19,"canonical_url":19,"robots_meta":20,"created_at":15,"updated_at":15,"tags":21,"category_name":31,"related_articles":32},"d0000000-0000-0000-0000-000000000302","a0000000-0000-0000-0000-000000000016","Deep EVM #29: Семафоры в async Rust — охота на дедлоки и fire-and-forget паттерны","deep-evm-29-semafory-async-rust-dedloki-fire-and-forget","Глубокое погружение в tokio::sync::Semaphore: управление обратным давлением, fire-and-forget паттерн записи, диагностика дедлоков через tracing и tokio-console, продакшн-решения с RAII-пермитами и таймаутами.","## Зачем семафоры в асинхронном Rust\n\nКогда вы запускаете высоконагруженный конвейер — MEV-бот, обрабатывающий 180 000 арбитражных цепочек за блок, API-сервер с 10 000 одновременных запросов или ETL-процесс, записывающий миллионы строк — вы неизбежно упираетесь в потолок ресурсов. Пулы подключений к базе исчерпываются. RPC-провайдеры отвечают rate-limit ошибками. Память раздувается, потому что вы заспаунили 50 000 tokio-задач, каждая из которых удерживает данные цепочки.\n\nНаивный подход — неограниченная конкурентность: `tokio::spawn` на каждую единицу работы в надежде, что рантайм разберётся. Не разберётся. В продакшне мы наблюдали потребление 15,4 ГБ памяти от 2,7 миллиона порождённых задач, каждая из которых держала `Vec\u003CHop>` плюс контекст симуляции. Решением стала пакетная конкурентность с backpressure на семафорах, что снизило потребление до 0,8 ГБ.\n\nСемафор — правильный примитив, когда нужно ограничить число одновременных операций, не сериализуя их полностью. В отличие от мьютекса (который допускает ровно одного), семафор допускает N одновременных обращений. Это идеально для:\n\n- **Конкурентность записи в БД**: ограничение до размера пула соединений (например, 20 одновременных записей)\n- **Rate limiting RPC**: ограничение исходящих запросов, чтобы избежать 429-ответов от провайдеров\n- **Backpressure по памяти**: предотвращение неограниченного порождения задач через гейтинг по доступным пермитам\n- **Пакетная обработка**: контроль числа параллельных батчей симуляции против общего узла\n\n## Основы tokio::sync::Semaphore\n\n`tokio::sync::Semaphore` — это счётный семафор, спроектированный для асинхронного кода. Он поддерживает внутренний счётчик доступных пермитов. Задачи захватывают пермиты перед выполнением и освобождают по завершении.\n\n```rust\nuse std::sync::Arc;\nuse tokio::sync::Semaphore;\n\n\u002F\u002F Разрешаем до 20 одновременных записей в БД\nlet semaphore = Arc::new(Semaphore::new(20));\n\nfor chain in chains_to_persist {\n    let sem = semaphore.clone();\n    let db_pool = db_pool.clone();\n\n    tokio::spawn(async move {\n        \u002F\u002F Захватываем пермит — блокируемся, если все 20 заняты\n        let _permit = sem.acquire().await.unwrap();\n\n        \u002F\u002F Выполняем запись — пермит удерживается в этой области\n        sqlx::query(\"UPDATE chains SET profit = $1 WHERE id = $2\")\n            .bind(chain.profit)\n            .bind(chain.id)\n            .execute(&db_pool)\n            .await\n            .ok();\n\n        \u002F\u002F _permit дропается здесь — автоматически освобождается\n    });\n}\n```\n\nОсновной API:\n\n- `Semaphore::new(permits)` — создание с N пермитами\n- `acquire(&self)` — асинхронный, ждёт доступного пермита, возвращает `SemaphorePermit` (RAII-гард)\n- `try_acquire(&self)` — синхронный, немедленно возвращает `Err(TryAcquireError)` при отсутствии пермитов\n- `acquire_owned(self: Arc\u003CSelf>)` — возвращает `OwnedSemaphorePermit`, владеющий `Arc`, полезен когда пермит должен пережить заимствование\n- `add_permits(&self, n)` — динамическое увеличение счётчика\n- `close(&self)` — закрытие семафора; все ожидающие `acquire` получают `Err(AcquireError)`\n- `available_permits(&self)` — инспекция текущего счётчика (полезно для метрик)\n\nКлючевое проектное решение: `acquire()` возвращает RAII-гард. При дропе гарда пермит освобождается. Это означает автоматическую очистку даже при паниках, ранних возвратах и операторе `?` — пока гард живёт на стеке.\n\n## Fire-and-Forget паттерн записи\n\nВ высоконагруженных системах часто нужно персистить данные, не блокируя горячий путь. Паттерн: порождаем фоновую задачу, которая захватывает пермит семафора, выполняет запись и освобождает. Вызывающий код не ждёт результата.\n\nВ хорошо спроектированной системе это реализуется как декоратор, оборачивающий внутренний сервис хранения:\n\n```rust\nuse std::sync::Arc;\nuse tokio::sync::Semaphore;\n\npub struct AsyncChainStore\u003CS: ChainStore> {\n    inner: Arc\u003CS>,\n    semaphore: Arc\u003CSemaphore>,\n}\n\nimpl\u003CS: ChainStore + Send + Sync + 'static> AsyncChainStore\u003CS> {\n    pub fn new(inner: Arc\u003CS>, max_concurrent_writes: usize) -> Self {\n        Self {\n            inner,\n            semaphore: Arc::new(Semaphore::new(max_concurrent_writes)),\n        }\n    }\n\n    \u002F\u002F\u002F Fire-and-forget: персистим прибыль цепочек, не блокируя вызывающий код.\n    \u002F\u002F\u002F Backpressure обеспечивается семафором — если все пермиты заняты,\n    \u002F\u002F\u002F порождённая задача ждёт, но вызывающий код возвращается немедленно.\n    pub fn save_profits_async(&self, chains: Vec\u003CChainProfit>) {\n        let inner = self.inner.clone();\n        let sem = self.semaphore.clone();\n\n        tokio::spawn(async move {\n            \u002F\u002F Захватываем пермит — здесь срабатывает backpressure\n            let _permit = match sem.acquire().await {\n                Ok(p) => p,\n                Err(_) => {\n                    tracing::warn!(\"семафор закрыт, дропаем запись\");\n                    return;\n                }\n            };\n\n            if let Err(e) = inner.batch_update_profits(&chains).await {\n                tracing::error!(\n                    error = %e,\n                    count = chains.len(),\n                    \"fire-and-forget запись не удалась\"\n                );\n            }\n            \u002F\u002F _permit дропается здесь — следующая задача в очереди продолжает\n        });\n    }\n}\n```\n\nЭтот паттерн декоратора (R-004 в наших соглашениях кодовой базы) сохраняет внутренний `ChainStore` чистым — он ничего не знает об управлении конкурентностью. Декоратор занимается управлением семафором, логированием ошибок и порождением задач. ServiceLocator связывает их:\n\n```rust\n\u002F\u002F В locator\u002Fmod.rs\nlet chain_store = Arc::new(PgChainStore::new(db_pool.clone()));\nlet async_chain_store = AsyncChainStore::new(chain_store, 20);\n```\n\nПочему бы просто не использовать встроенное ограничение пула БД? Потому что семафор даёт отдельную ручку настройки. Пул может иметь 50 соединений, но вы хотите, чтобы fire-and-forget записи использовали максимум 20, оставив 30 для критичных по задержке чтений. Семафор обеспечивает это разделение на уровне приложения.\n\n## Сценарии дедлоков\n\nДедлоки на семафорах коварны: они не вызывают паники или ошибки — программа просто перестаёт продвигаться. Вот паттерны, с которыми мы столкнулись в продакшне.\n\n### Сценарий 1: Пермит не освобождается при раннем возврате\n\n```rust\nasync fn process_batch(sem: &Semaphore, batch: &[Chain]) -> Result\u003C()> {\n    let permit = sem.acquire().await?;\n\n    \u002F\u002F Ранний возврат если батч пуст — НО пермит всё ещё удерживается!\n    if batch.is_empty() {\n        return Ok(()); \u002F\u002F пермит дропается здесь — на самом деле ок в этом случае\n    }\n\n    \u002F\u002F Реальная опасность: сохранение пермита в структуре, которая переживает скоуп\n    let ctx = ProcessingContext {\n        permit: Some(permit), \u002F\u002F пермит перемещён в структуру\n        batch,\n    };\n\n    \u002F\u002F Если process() сохраняет ctx куда-то долгоживущее, пермит утекает\n    process(ctx).await?;\n    Ok(())\n}\n```\n\nRAII-гард защищает от большинства ранних возвратов. Опасность возникает, когда вы перемещаете пермит в структуру, которая покидает ожидаемое время жизни — хранится в `HashMap`, отправляется через канал или удерживается в `static`.\n\n### Сценарий 2: Вложенный Acquire (Самодедлок)\n\n```rust\nasync fn simulate_and_persist(\n    sem: &Semaphore,\n    chain: &Chain,\n) -> Result\u003C()> {\n    let _outer = sem.acquire().await?; \u002F\u002F Берём 1 из N пермитов\n\n    let result = simulate(chain).await?;\n\n    \u002F\u002F БАГ: захватываем ТОТ ЖЕ семафор внутри удерживаемого пермита\n    let _inner = sem.acquire().await?; \u002F\u002F При N=1 — дедлок. При N>1 — снижение пропускной способности\n    persist(result).await?;\n\n    Ok(())\n}\n```\n\nПри `N=1` это мгновенный дедлок. При `N>1` это работает, пока нагрузка не вырастет и все пермиты не окажутся заняты задачами, ждущими внутреннего acquire. Решение: используйте отдельные семафоры для разных задач или реструктурируйте код, чтобы избежать вложенного захвата.\n\n### Сценарий 3: Пермит удерживается через await-точки в Select\n\n```rust\nasync fn process_with_timeout(sem: &Semaphore) -> Result\u003C()> {\n    let permit = sem.acquire().await?;\n\n    tokio::select! {\n        result = do_work() => {\n            result?;\n        }\n        _ = tokio::time::sleep(Duration::from_secs(30)) => {\n            tracing::warn!(\"таймаут, но пермит всё ещё удерживается до дропа\");\n        }\n    }\n    \u002F\u002F пермит дропается здесь — это корректно\n    \u002F\u002F НО: если do_work() порождает подзадачу, захватившую пермит,\n    \u002F\u002F отмена do_work() НЕ отменяет подзадачу\n    Ok(())\n}\n```\n\nМакрос `select!` отменяет проигравшую ветку, дропая её future, но любые задачи, порождённые внутри этого future, продолжают работать. Если эти задачи захватили ссылку на пермит (или клон `OwnedSemaphorePermit`), пермит не освобождается когда ожидалось.\n\n## Диагностика дедлоков на семафорах\n\nКогда система перестаёт продвигаться, как отличить дедлок на семафоре от медленной зависимости?\n\n### Диагностика через трейсинг\n\nИнструментируйте acquire\u002Frelease структурированным логированием:\n\n```rust\nlet permits_before = semaphore.available_permits();\ntracing::debug!(\n    available = permits_before,\n    \"захватываем пермит семафора\"\n);\n\nlet _permit = semaphore.acquire().await?;\n\ntracing::debug!(\n    available = semaphore.available_permits(),\n    \"пермит семафора захвачен\"\n);\n```\n\nЕсли логи показывают «захватываем», но никогда «захвачен» — все пермиты удерживаются где-то. Сопоставьте с последними N записями «захвачен», чтобы найти виновника.\n\n### Метрики Prometheus\n\nЭкспортируйте gauge-метрику доступных пермитов:\n\n```rust\nuse metrics::gauge;\n\n\u002F\u002F В декораторе или middleware\ngauge!(\"semaphore_available_permits\", semaphore.available_permits() as f64);\n```\n\nGauge, упавший до нуля и оставшийся там — это дедлок. Gauge, колеблющийся около нуля — это contention (высокая нагрузка, возможно нужно больше пермитов).\n\n### tokio-console\n\n`tokio-console` — диагностический инструмент, подключающийся к работающему tokio-приложению и показывающий состояние задач в реальном времени:\n\n```bash\ncargo add --dev console-subscriber\n```\n\n```rust\n\u002F\u002F В main.rs (только debug-сборки)\n#[cfg(debug_assertions)]\nconsole_subscriber::init();\n```\n\nЗапустите `tokio-console` в другом терминале и ищите задачи, застрявшие в состоянии \"Idle\" на acquire семафора. Инструмент показывает, какая именно задача ждёт и как долго.\n\n## Закалённые продакшн-решения\n\n### Решение 1: Всегда используйте OwnedSemaphorePermit с Arc\n\nПри порождении задач предпочитайте `acquire_owned()` вместо `acquire()`. Owned-вариант принимает `Arc\u003CSemaphore>` и возвращает пермит, который не заимствует семафор — он владеет клоном `Arc`. Это полностью устраняет проблемы с временами жизни:\n\n```rust\nlet semaphore = Arc::new(Semaphore::new(20));\n\nfor item in items {\n    let permit = semaphore.clone().acquire_owned().await?;\n\n    tokio::spawn(async move {\n        \u002F\u002F пермит перемещён в задачу — никаких проблем с заимствованиями\n        do_work(item).await;\n        drop(permit); \u002F\u002F явный drop для ясности, или пусть скоуп обработает\n    });\n}\n```\n\n### Решение 2: Acquire с таймаутом\n\nНикогда не ждите пермит бесконечно в продакшне. Используйте `tokio::time::timeout` для раннего обнаружения дедлоков:\n\n```rust\nuse tokio::time::{timeout, Duration};\n\nlet permit = timeout(\n    Duration::from_secs(30),\n    semaphore.acquire(),\n).await\n    .map_err(|_| anyhow!(\"таймаут acquire семафора — возможен дедлок\"))?\n    .map_err(|_| anyhow!(\"семафор закрыт\"))?;\n```\n\nКогда таймаут срабатывает, логируйте доступные пермиты, число ожидающих задач и стек вызовов. Это даёт немедленную видимость дедлока без падения процесса.\n\n### Решение 3: Структурированная конкурентность с JoinSet\n\nВместо неограниченного `tokio::spawn` используйте `tokio::task::JoinSet` для сохранения владения порождёнными задачами в паре с семафором:\n\n```rust\nuse tokio::task::JoinSet;\n\nlet semaphore = Arc::new(Semaphore::new(20));\nlet mut join_set = JoinSet::new();\n\nfor batch in batches {\n    let permit = semaphore.clone().acquire_owned().await?;\n    let db_pool = db_pool.clone();\n\n    join_set.spawn(async move {\n        let result = process_batch(&db_pool, &batch).await;\n        drop(permit); \u002F\u002F освобождаем перед тем как JoinSet соберёт результат\n        result\n    });\n}\n\n\u002F\u002F Дренируем все задачи — собираем ошибки вместо тихой потери\nwhile let Some(result) = join_set.join_next().await {\n    match result {\n        Ok(Ok(())) => {}\n        Ok(Err(e)) => tracing::error!(error = %e, \"обработка батча не удалась\"),\n        Err(e) => tracing::error!(error = %e, \"задача запаниковала\"),\n    }\n}\n```\n\nЭтот паттерн гарантирует: (1) ограниченную конкурентность через семафор, (2) отсутствие тихих падений задач, (3) родительская задача знает, когда все дочерние завершились, (4) пермиты всегда освобождаются, даже если задача паникует (потому что `JoinSet::join_next` возвращает `JoinError` для запаниковавших задач, а пермит дропается вместе с состоянием задачи).\n\n### Решение 4: Отдельные семафоры для отдельных задач\n\nНикогда не используйте один семафор для несвязанных операций. В нашем MEV-конвейере мы используем отдельные семафоры:\n\n```rust\npub struct ConcurrencyLimits {\n    \u002F\u002F\u002F Ограничивает конкурентные RPC-вызовы симуляции к узлу\n    pub simulation: Arc\u003CSemaphore>,\n    \u002F\u002F\u002F Ограничивает конкурентные записи в БД для персистенции цепочек\n    pub db_writes: Arc\u003CSemaphore>,\n    \u002F\u002F\u002F Ограничивает конкурентные обработчики подписки на мемпул\n    pub mempool: Arc\u003CSemaphore>,\n}\n\nimpl ConcurrencyLimits {\n    pub fn new() -> Self {\n        Self {\n            simulation: Arc::new(Semaphore::new(4)),   \u002F\u002F узел выдерживает 4 параллельных батча\n            db_writes: Arc::new(Semaphore::new(20)),   \u002F\u002F 20 из 50 соединений пула\n            mempool: Arc::new(Semaphore::new(100)),    \u002F\u002F 100 конкурентных обработчиков tx\n        }\n    }\n}\n```\n\nЭто полностью устраняет дедлок вложенного acquire — задача захватывает пермит `simulation`, затем пермит `db_writes`, и это независимые пулы.\n\n## Производительность: накладные расходы семафора\n\nЯвляется ли сам семафор узким местом? На практике — нет. `tokio::sync::Semaphore` реализован на атомарном счётчике и интрузивном связном списке ожидающих. Захват неоспариваемого пермита — один `fetch_sub`, наносекунды. Даже при contention накладные расходы — это уведомление waker'а (тоже наносекунды) по сравнению с миллисекундами вашего реального I\u002FO.\n\nМы замерили накладные расходы семафора в нашем конвейере:\n\n| Операция | Без семафора | С семафором (20 пермитов) |\n|----------|-------------|---------------------------|\n| 10 000 записей в БД | 1 340 мс (все конкурентно, ошибки исчерпания пула) | 1 580 мс (контролируемо, ноль ошибок) |\n| 500 RPC-симуляций | 8 200 мс (узел перегружен, таймауты) | 9 100 мс (по 4 за раз, ноль таймаутов) |\n| Память (2,7М задач) | 15,4 ГБ | 0,8 ГБ (батчами с JoinSet) |\n\n10-15% накладных расходов по wall-clock времени пренебрежимо малы по сравнению с устранением ошибок, таймаутов и OOM-крешей.\n\n## Заключение\n\nСемафоры в асинхронном Rust обманчиво просты — `acquire`, выполняем работу, дропаем пермит. Сложность проявляется в продакшне: пермиты, утёкшие в долгоживущие структуры, вложенные acquire через стек вызовов, пермиты, удерживаемые через границы отмены `select!`.\n\nЗащитный плейбук:\n\n1. **Всегда** используйте `acquire_owned()` при порождении задач\n2. **Всегда** оборачивайте acquire в таймаут\n3. **Никогда** не делайте вложенный acquire на одном семафоре\n4. **Разделяйте** семафоры для разных пулов ресурсов\n5. **Инструментируйте** доступные пермиты метриками и трейсингом\n6. **Используйте JoinSet** вместо неограниченного `tokio::spawn` для структурированной конкурентности\n\nЭти паттерны выдержали миллионы обработанных блоков, сотни тысяч конкурентных задач и ноль дедлоков в продакшне с момента их внедрения.","\u003Ch2 id=\"rust\">Зачем семафоры в асинхронном Rust\u003C\u002Fh2>\n\u003Cp>Когда вы запускаете высоконагруженный конвейер — MEV-бот, обрабатывающий 180 000 арбитражных цепочек за блок, API-сервер с 10 000 одновременных запросов или ETL-процесс, записывающий миллионы строк — вы неизбежно упираетесь в потолок ресурсов. Пулы подключений к базе исчерпываются. RPC-провайдеры отвечают rate-limit ошибками. Память раздувается, потому что вы заспаунили 50 000 tokio-задач, каждая из которых удерживает данные цепочки.\u003C\u002Fp>\n\u003Cp>Наивный подход — неограниченная конкурентность: \u003Ccode>tokio::spawn\u003C\u002Fcode> на каждую единицу работы в надежде, что рантайм разберётся. Не разберётся. В продакшне мы наблюдали потребление 15,4 ГБ памяти от 2,7 миллиона порождённых задач, каждая из которых держала \u003Ccode>Vec&lt;Hop&gt;\u003C\u002Fcode> плюс контекст симуляции. Решением стала пакетная конкурентность с backpressure на семафорах, что снизило потребление до 0,8 ГБ.\u003C\u002Fp>\n\u003Cp>Семафор — правильный примитив, когда нужно ограничить число одновременных операций, не сериализуя их полностью. В отличие от мьютекса (который допускает ровно одного), семафор допускает N одновременных обращений. Это идеально для:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Конкурентность записи в БД\u003C\u002Fstrong>: ограничение до размера пула соединений (например, 20 одновременных записей)\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Rate limiting RPC\u003C\u002Fstrong>: ограничение исходящих запросов, чтобы избежать 429-ответов от провайдеров\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Backpressure по памяти\u003C\u002Fstrong>: предотвращение неограниченного порождения задач через гейтинг по доступным пермитам\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Пакетная обработка\u003C\u002Fstrong>: контроль числа параллельных батчей симуляции против общего узла\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch2 id=\"tokio-sync-semaphore\">Основы tokio::sync::Semaphore\u003C\u002Fh2>\n\u003Cp>\u003Ccode>tokio::sync::Semaphore\u003C\u002Fcode> — это счётный семафор, спроектированный для асинхронного кода. Он поддерживает внутренний счётчик доступных пермитов. Задачи захватывают пермиты перед выполнением и освобождают по завершении.\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-rust\">use std::sync::Arc;\nuse tokio::sync::Semaphore;\n\n\u002F\u002F Разрешаем до 20 одновременных записей в БД\nlet semaphore = Arc::new(Semaphore::new(20));\n\nfor chain in chains_to_persist {\n    let sem = semaphore.clone();\n    let db_pool = db_pool.clone();\n\n    tokio::spawn(async move {\n        \u002F\u002F Захватываем пермит — блокируемся, если все 20 заняты\n        let _permit = sem.acquire().await.unwrap();\n\n        \u002F\u002F Выполняем запись — пермит удерживается в этой области\n        sqlx::query(\"UPDATE chains SET profit = $1 WHERE id = $2\")\n            .bind(chain.profit)\n            .bind(chain.id)\n            .execute(&amp;db_pool)\n            .await\n            .ok();\n\n        \u002F\u002F _permit дропается здесь — автоматически освобождается\n    });\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Основной API:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Ccode>Semaphore::new(permits)\u003C\u002Fcode> — создание с N пермитами\u003C\u002Fli>\n\u003Cli>\u003Ccode>acquire(&amp;self)\u003C\u002Fcode> — асинхронный, ждёт доступного пермита, возвращает \u003Ccode>SemaphorePermit\u003C\u002Fcode> (RAII-гард)\u003C\u002Fli>\n\u003Cli>\u003Ccode>try_acquire(&amp;self)\u003C\u002Fcode> — синхронный, немедленно возвращает \u003Ccode>Err(TryAcquireError)\u003C\u002Fcode> при отсутствии пермитов\u003C\u002Fli>\n\u003Cli>\u003Ccode>acquire_owned(self: Arc&lt;Self&gt;)\u003C\u002Fcode> — возвращает \u003Ccode>OwnedSemaphorePermit\u003C\u002Fcode>, владеющий \u003Ccode>Arc\u003C\u002Fcode>, полезен когда пермит должен пережить заимствование\u003C\u002Fli>\n\u003Cli>\u003Ccode>add_permits(&amp;self, n)\u003C\u002Fcode> — динамическое увеличение счётчика\u003C\u002Fli>\n\u003Cli>\u003Ccode>close(&amp;self)\u003C\u002Fcode> — закрытие семафора; все ожидающие \u003Ccode>acquire\u003C\u002Fcode> получают \u003Ccode>Err(AcquireError)\u003C\u002Fcode>\u003C\u002Fli>\n\u003Cli>\u003Ccode>available_permits(&amp;self)\u003C\u002Fcode> — инспекция текущего счётчика (полезно для метрик)\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>Ключевое проектное решение: \u003Ccode>acquire()\u003C\u002Fcode> возвращает RAII-гард. При дропе гарда пермит освобождается. Это означает автоматическую очистку даже при паниках, ранних возвратах и операторе \u003Ccode>?\u003C\u002Fcode> — пока гард живёт на стеке.\u003C\u002Fp>\n\u003Ch2 id=\"fire-and-forget\">Fire-and-Forget паттерн записи\u003C\u002Fh2>\n\u003Cp>В высоконагруженных системах часто нужно персистить данные, не блокируя горячий путь. Паттерн: порождаем фоновую задачу, которая захватывает пермит семафора, выполняет запись и освобождает. Вызывающий код не ждёт результата.\u003C\u002Fp>\n\u003Cp>В хорошо спроектированной системе это реализуется как декоратор, оборачивающий внутренний сервис хранения:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-rust\">use std::sync::Arc;\nuse tokio::sync::Semaphore;\n\npub struct AsyncChainStore&lt;S: ChainStore&gt; {\n    inner: Arc&lt;S&gt;,\n    semaphore: Arc&lt;Semaphore&gt;,\n}\n\nimpl&lt;S: ChainStore + Send + Sync + 'static&gt; AsyncChainStore&lt;S&gt; {\n    pub fn new(inner: Arc&lt;S&gt;, max_concurrent_writes: usize) -&gt; Self {\n        Self {\n            inner,\n            semaphore: Arc::new(Semaphore::new(max_concurrent_writes)),\n        }\n    }\n\n    \u002F\u002F\u002F Fire-and-forget: персистим прибыль цепочек, не блокируя вызывающий код.\n    \u002F\u002F\u002F Backpressure обеспечивается семафором — если все пермиты заняты,\n    \u002F\u002F\u002F порождённая задача ждёт, но вызывающий код возвращается немедленно.\n    pub fn save_profits_async(&amp;self, chains: Vec&lt;ChainProfit&gt;) {\n        let inner = self.inner.clone();\n        let sem = self.semaphore.clone();\n\n        tokio::spawn(async move {\n            \u002F\u002F Захватываем пермит — здесь срабатывает backpressure\n            let _permit = match sem.acquire().await {\n                Ok(p) =&gt; p,\n                Err(_) =&gt; {\n                    tracing::warn!(\"семафор закрыт, дропаем запись\");\n                    return;\n                }\n            };\n\n            if let Err(e) = inner.batch_update_profits(&amp;chains).await {\n                tracing::error!(\n                    error = %e,\n                    count = chains.len(),\n                    \"fire-and-forget запись не удалась\"\n                );\n            }\n            \u002F\u002F _permit дропается здесь — следующая задача в очереди продолжает\n        });\n    }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Этот паттерн декоратора (R-004 в наших соглашениях кодовой базы) сохраняет внутренний \u003Ccode>ChainStore\u003C\u002Fcode> чистым — он ничего не знает об управлении конкурентностью. Декоратор занимается управлением семафором, логированием ошибок и порождением задач. ServiceLocator связывает их:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-rust\">\u002F\u002F В locator\u002Fmod.rs\nlet chain_store = Arc::new(PgChainStore::new(db_pool.clone()));\nlet async_chain_store = AsyncChainStore::new(chain_store, 20);\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Почему бы просто не использовать встроенное ограничение пула БД? Потому что семафор даёт отдельную ручку настройки. Пул может иметь 50 соединений, но вы хотите, чтобы fire-and-forget записи использовали максимум 20, оставив 30 для критичных по задержке чтений. Семафор обеспечивает это разделение на уровне приложения.\u003C\u002Fp>\n\u003Ch2 id=\"\">Сценарии дедлоков\u003C\u002Fh2>\n\u003Cp>Дедлоки на семафорах коварны: они не вызывают паники или ошибки — программа просто перестаёт продвигаться. Вот паттерны, с которыми мы столкнулись в продакшне.\u003C\u002Fp>\n\u003Ch3>Сценарий 1: Пермит не освобождается при раннем возврате\u003C\u002Fh3>\n\u003Cpre>\u003Ccode class=\"language-rust\">async fn process_batch(sem: &amp;Semaphore, batch: &amp;[Chain]) -&gt; Result&lt;()&gt; {\n    let permit = sem.acquire().await?;\n\n    \u002F\u002F Ранний возврат если батч пуст — НО пермит всё ещё удерживается!\n    if batch.is_empty() {\n        return Ok(()); \u002F\u002F пермит дропается здесь — на самом деле ок в этом случае\n    }\n\n    \u002F\u002F Реальная опасность: сохранение пермита в структуре, которая переживает скоуп\n    let ctx = ProcessingContext {\n        permit: Some(permit), \u002F\u002F пермит перемещён в структуру\n        batch,\n    };\n\n    \u002F\u002F Если process() сохраняет ctx куда-то долгоживущее, пермит утекает\n    process(ctx).await?;\n    Ok(())\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>RAII-гард защищает от большинства ранних возвратов. Опасность возникает, когда вы перемещаете пермит в структуру, которая покидает ожидаемое время жизни — хранится в \u003Ccode>HashMap\u003C\u002Fcode>, отправляется через канал или удерживается в \u003Ccode>static\u003C\u002Fcode>.\u003C\u002Fp>\n\u003Ch3>Сценарий 2: Вложенный Acquire (Самодедлок)\u003C\u002Fh3>\n\u003Cpre>\u003Ccode class=\"language-rust\">async fn simulate_and_persist(\n    sem: &amp;Semaphore,\n    chain: &amp;Chain,\n) -&gt; Result&lt;()&gt; {\n    let _outer = sem.acquire().await?; \u002F\u002F Берём 1 из N пермитов\n\n    let result = simulate(chain).await?;\n\n    \u002F\u002F БАГ: захватываем ТОТ ЖЕ семафор внутри удерживаемого пермита\n    let _inner = sem.acquire().await?; \u002F\u002F При N=1 — дедлок. При N&gt;1 — снижение пропускной способности\n    persist(result).await?;\n\n    Ok(())\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>При \u003Ccode>N=1\u003C\u002Fcode> это мгновенный дедлок. При \u003Ccode>N&gt;1\u003C\u002Fcode> это работает, пока нагрузка не вырастет и все пермиты не окажутся заняты задачами, ждущими внутреннего acquire. Решение: используйте отдельные семафоры для разных задач или реструктурируйте код, чтобы избежать вложенного захвата.\u003C\u002Fp>\n\u003Ch3>Сценарий 3: Пермит удерживается через await-точки в Select\u003C\u002Fh3>\n\u003Cpre>\u003Ccode class=\"language-rust\">async fn process_with_timeout(sem: &amp;Semaphore) -&gt; Result&lt;()&gt; {\n    let permit = sem.acquire().await?;\n\n    tokio::select! {\n        result = do_work() =&gt; {\n            result?;\n        }\n        _ = tokio::time::sleep(Duration::from_secs(30)) =&gt; {\n            tracing::warn!(\"таймаут, но пермит всё ещё удерживается до дропа\");\n        }\n    }\n    \u002F\u002F пермит дропается здесь — это корректно\n    \u002F\u002F НО: если do_work() порождает подзадачу, захватившую пермит,\n    \u002F\u002F отмена do_work() НЕ отменяет подзадачу\n    Ok(())\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Макрос \u003Ccode>select!\u003C\u002Fcode> отменяет проигравшую ветку, дропая её future, но любые задачи, порождённые внутри этого future, продолжают работать. Если эти задачи захватили ссылку на пермит (или клон \u003Ccode>OwnedSemaphorePermit\u003C\u002Fcode>), пермит не освобождается когда ожидалось.\u003C\u002Fp>\n\u003Ch2 id=\"\">Диагностика дедлоков на семафорах\u003C\u002Fh2>\n\u003Cp>Когда система перестаёт продвигаться, как отличить дедлок на семафоре от медленной зависимости?\u003C\u002Fp>\n\u003Ch3>Диагностика через трейсинг\u003C\u002Fh3>\n\u003Cp>Инструментируйте acquire\u002Frelease структурированным логированием:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-rust\">let permits_before = semaphore.available_permits();\ntracing::debug!(\n    available = permits_before,\n    \"захватываем пермит семафора\"\n);\n\nlet _permit = semaphore.acquire().await?;\n\ntracing::debug!(\n    available = semaphore.available_permits(),\n    \"пермит семафора захвачен\"\n);\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Если логи показывают «захватываем», но никогда «захвачен» — все пермиты удерживаются где-то. Сопоставьте с последними N записями «захвачен», чтобы найти виновника.\u003C\u002Fp>\n\u003Ch3>Метрики Prometheus\u003C\u002Fh3>\n\u003Cp>Экспортируйте gauge-метрику доступных пермитов:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-rust\">use metrics::gauge;\n\n\u002F\u002F В декораторе или middleware\ngauge!(\"semaphore_available_permits\", semaphore.available_permits() as f64);\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Gauge, упавший до нуля и оставшийся там — это дедлок. Gauge, колеблющийся около нуля — это contention (высокая нагрузка, возможно нужно больше пермитов).\u003C\u002Fp>\n\u003Ch3>tokio-console\u003C\u002Fh3>\n\u003Cp>\u003Ccode>tokio-console\u003C\u002Fcode> — диагностический инструмент, подключающийся к работающему tokio-приложению и показывающий состояние задач в реальном времени:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-bash\">cargo add --dev console-subscriber\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cpre>\u003Ccode class=\"language-rust\">\u002F\u002F В main.rs (только debug-сборки)\n#[cfg(debug_assertions)]\nconsole_subscriber::init();\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Запустите \u003Ccode>tokio-console\u003C\u002Fcode> в другом терминале и ищите задачи, застрявшие в состоянии “Idle” на acquire семафора. Инструмент показывает, какая именно задача ждёт и как долго.\u003C\u002Fp>\n\u003Ch2 id=\"\">Закалённые продакшн-решения\u003C\u002Fh2>\n\u003Ch3>Решение 1: Всегда используйте OwnedSemaphorePermit с Arc\u003C\u002Fh3>\n\u003Cp>При порождении задач предпочитайте \u003Ccode>acquire_owned()\u003C\u002Fcode> вместо \u003Ccode>acquire()\u003C\u002Fcode>. Owned-вариант принимает \u003Ccode>Arc&lt;Semaphore&gt;\u003C\u002Fcode> и возвращает пермит, который не заимствует семафор — он владеет клоном \u003Ccode>Arc\u003C\u002Fcode>. Это полностью устраняет проблемы с временами жизни:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-rust\">let semaphore = Arc::new(Semaphore::new(20));\n\nfor item in items {\n    let permit = semaphore.clone().acquire_owned().await?;\n\n    tokio::spawn(async move {\n        \u002F\u002F пермит перемещён в задачу — никаких проблем с заимствованиями\n        do_work(item).await;\n        drop(permit); \u002F\u002F явный drop для ясности, или пусть скоуп обработает\n    });\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch3>Решение 2: Acquire с таймаутом\u003C\u002Fh3>\n\u003Cp>Никогда не ждите пермит бесконечно в продакшне. Используйте \u003Ccode>tokio::time::timeout\u003C\u002Fcode> для раннего обнаружения дедлоков:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-rust\">use tokio::time::{timeout, Duration};\n\nlet permit = timeout(\n    Duration::from_secs(30),\n    semaphore.acquire(),\n).await\n    .map_err(|_| anyhow!(\"таймаут acquire семафора — возможен дедлок\"))?\n    .map_err(|_| anyhow!(\"семафор закрыт\"))?;\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Когда таймаут срабатывает, логируйте доступные пермиты, число ожидающих задач и стек вызовов. Это даёт немедленную видимость дедлока без падения процесса.\u003C\u002Fp>\n\u003Ch3>Решение 3: Структурированная конкурентность с JoinSet\u003C\u002Fh3>\n\u003Cp>Вместо неограниченного \u003Ccode>tokio::spawn\u003C\u002Fcode> используйте \u003Ccode>tokio::task::JoinSet\u003C\u002Fcode> для сохранения владения порождёнными задачами в паре с семафором:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-rust\">use tokio::task::JoinSet;\n\nlet semaphore = Arc::new(Semaphore::new(20));\nlet mut join_set = JoinSet::new();\n\nfor batch in batches {\n    let permit = semaphore.clone().acquire_owned().await?;\n    let db_pool = db_pool.clone();\n\n    join_set.spawn(async move {\n        let result = process_batch(&amp;db_pool, &amp;batch).await;\n        drop(permit); \u002F\u002F освобождаем перед тем как JoinSet соберёт результат\n        result\n    });\n}\n\n\u002F\u002F Дренируем все задачи — собираем ошибки вместо тихой потери\nwhile let Some(result) = join_set.join_next().await {\n    match result {\n        Ok(Ok(())) =&gt; {}\n        Ok(Err(e)) =&gt; tracing::error!(error = %e, \"обработка батча не удалась\"),\n        Err(e) =&gt; tracing::error!(error = %e, \"задача запаниковала\"),\n    }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Этот паттерн гарантирует: (1) ограниченную конкурентность через семафор, (2) отсутствие тихих падений задач, (3) родительская задача знает, когда все дочерние завершились, (4) пермиты всегда освобождаются, даже если задача паникует (потому что \u003Ccode>JoinSet::join_next\u003C\u002Fcode> возвращает \u003Ccode>JoinError\u003C\u002Fcode> для запаниковавших задач, а пермит дропается вместе с состоянием задачи).\u003C\u002Fp>\n\u003Ch3>Решение 4: Отдельные семафоры для отдельных задач\u003C\u002Fh3>\n\u003Cp>Никогда не используйте один семафор для несвязанных операций. В нашем MEV-конвейере мы используем отдельные семафоры:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-rust\">pub struct ConcurrencyLimits {\n    \u002F\u002F\u002F Ограничивает конкурентные RPC-вызовы симуляции к узлу\n    pub simulation: Arc&lt;Semaphore&gt;,\n    \u002F\u002F\u002F Ограничивает конкурентные записи в БД для персистенции цепочек\n    pub db_writes: Arc&lt;Semaphore&gt;,\n    \u002F\u002F\u002F Ограничивает конкурентные обработчики подписки на мемпул\n    pub mempool: Arc&lt;Semaphore&gt;,\n}\n\nimpl ConcurrencyLimits {\n    pub fn new() -&gt; Self {\n        Self {\n            simulation: Arc::new(Semaphore::new(4)),   \u002F\u002F узел выдерживает 4 параллельных батча\n            db_writes: Arc::new(Semaphore::new(20)),   \u002F\u002F 20 из 50 соединений пула\n            mempool: Arc::new(Semaphore::new(100)),    \u002F\u002F 100 конкурентных обработчиков tx\n        }\n    }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Это полностью устраняет дедлок вложенного acquire — задача захватывает пермит \u003Ccode>simulation\u003C\u002Fcode>, затем пермит \u003Ccode>db_writes\u003C\u002Fcode>, и это независимые пулы.\u003C\u002Fp>\n\u003Ch2 id=\"\">Производительность: накладные расходы семафора\u003C\u002Fh2>\n\u003Cp>Является ли сам семафор узким местом? На практике — нет. \u003Ccode>tokio::sync::Semaphore\u003C\u002Fcode> реализован на атомарном счётчике и интрузивном связном списке ожидающих. Захват неоспариваемого пермита — один \u003Ccode>fetch_sub\u003C\u002Fcode>, наносекунды. Даже при contention накладные расходы — это уведомление waker’а (тоже наносекунды) по сравнению с миллисекундами вашего реального I\u002FO.\u003C\u002Fp>\n\u003Cp>Мы замерили накладные расходы семафора в нашем конвейере:\u003C\u002Fp>\n\u003Ctable>\u003Cthead>\u003Ctr>\u003Cth>Операция\u003C\u002Fth>\u003Cth>Без семафора\u003C\u002Fth>\u003Cth>С семафором (20 пермитов)\u003C\u002Fth>\u003C\u002Ftr>\u003C\u002Fthead>\u003Ctbody>\n\u003Ctr>\u003Ctd>10 000 записей в БД\u003C\u002Ftd>\u003Ctd>1 340 мс (все конкурентно, ошибки исчерпания пула)\u003C\u002Ftd>\u003Ctd>1 580 мс (контролируемо, ноль ошибок)\u003C\u002Ftd>\u003C\u002Ftr>\n\u003Ctr>\u003Ctd>500 RPC-симуляций\u003C\u002Ftd>\u003Ctd>8 200 мс (узел перегружен, таймауты)\u003C\u002Ftd>\u003Ctd>9 100 мс (по 4 за раз, ноль таймаутов)\u003C\u002Ftd>\u003C\u002Ftr>\n\u003Ctr>\u003Ctd>Память (2,7М задач)\u003C\u002Ftd>\u003Ctd>15,4 ГБ\u003C\u002Ftd>\u003Ctd>0,8 ГБ (батчами с JoinSet)\u003C\u002Ftd>\u003C\u002Ftr>\n\u003C\u002Ftbody>\u003C\u002Ftable>\n\u003Cp>10-15% накладных расходов по wall-clock времени пренебрежимо малы по сравнению с устранением ошибок, таймаутов и OOM-крешей.\u003C\u002Fp>\n\u003Ch2 id=\"\">Заключение\u003C\u002Fh2>\n\u003Cp>Семафоры в асинхронном Rust обманчиво просты — \u003Ccode>acquire\u003C\u002Fcode>, выполняем работу, дропаем пермит. Сложность проявляется в продакшне: пермиты, утёкшие в долгоживущие структуры, вложенные acquire через стек вызовов, пермиты, удерживаемые через границы отмены \u003Ccode>select!\u003C\u002Fcode>.\u003C\u002Fp>\n\u003Cp>Защитный плейбук:\u003C\u002Fp>\n\u003Col>\n\u003Cli>\u003Cstrong>Всегда\u003C\u002Fstrong> используйте \u003Ccode>acquire_owned()\u003C\u002Fcode> при порождении задач\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Всегда\u003C\u002Fstrong> оборачивайте acquire в таймаут\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Никогда\u003C\u002Fstrong> не делайте вложенный acquire на одном семафоре\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Разделяйте\u003C\u002Fstrong> семафоры для разных пулов ресурсов\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Инструментируйте\u003C\u002Fstrong> доступные пермиты метриками и трейсингом\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Используйте JoinSet\u003C\u002Fstrong> вместо неограниченного \u003Ccode>tokio::spawn\u003C\u002Fcode> для структурированной конкурентности\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Cp>Эти паттерны выдержали миллионы обработанных блоков, сотни тысяч конкурентных задач и ноль дедлоков в продакшне с момента их внедрения.\u003C\u002Fp>\n","ru","b0000000-0000-0000-0000-000000000001",true,"2026-03-28T10:44:24.217019Z","Семафоры в async Rust — охота на дедлоки и fire-and-forget паттерны","Глубокое погружение в tokio::sync::Semaphore: backpressure, fire-and-forget записи, диагностика дедлоков, продакшн-решения с RAII-пермитами и структурированной конкурентностью.","rust semaphore async",null,"index, follow",[22,27],{"id":23,"name":24,"slug":25,"created_at":26},"c0000000-0000-0000-0000-000000000022","Performance","performance","2026-03-28T10:44:21.513630Z",{"id":28,"name":29,"slug":30,"created_at":26},"c0000000-0000-0000-0000-000000000001","Rust","rust","Инженерия",[33,39,45],{"id":34,"title":35,"slug":36,"excerpt":37,"locale":12,"category_name":31,"published_at":38},"d0200000-0000-0000-0000-000000000013","Почему Бали становится хабом импакт-технологий Юго-Восточной Азии в 2026 году","pochemu-bali-stanovitsya-khabom-impakt-tekhnologiy-2026","Бали занимает 16-е место среди стартап-экосистем Юго-Восточной Азии. Растущая концентрация Web3-разработчиков, ИИ-стартапов в области устойчивого развития и компаний в сфере эко-тревел-технологий формирует нишу столицы импакт-технологий региона.","2026-03-28T10:44:37.953039Z",{"id":40,"title":41,"slug":42,"excerpt":43,"locale":12,"category_name":31,"published_at":44},"d0200000-0000-0000-0000-000000000012","Защита данных в ASEAN: чек-лист разработчика для мультистранового комплаенса","zashchita-dannykh-asean-chek-list-razrabotchika-komplaens","Семь стран ASEAN имеют собственные законы о защите данных с разными моделями согласия, требованиями к локализации и штрафами. Практический чек-лист для разработчиков мультистрановых приложений.","2026-03-28T10:44:37.944001Z",{"id":46,"title":47,"slug":48,"excerpt":49,"locale":12,"category_name":31,"published_at":50},"d0200000-0000-0000-0000-000000000011","Цифровая трансформация Индонезии на $29 миллиардов: возможности для софтверных компаний","tsifrovaya-transformatsiya-indonezii-29-milliardov-vozmozhnosti-dlya-kompaniy","Рынок IT-услуг Индонезии вырастет с $24,37 млрд в 2025 году до $29,03 млрд в 2026 году. Облачная инфраструктура, искусственный интеллект, электронная коммерция и дата-центры обеспечивают самый быстрый рост в Юго-Восточной Азии.","2026-03-28T10:44:37.917095Z",{"id":13,"name":52,"slug":53,"bio":54,"photo_url":19,"linkedin":19,"role":55,"created_at":56,"updated_at":56},"Open Soft Team","open-soft-team","The engineering team at Open Soft, building premium software solutions from Bali, Indonesia.","Engineering Team","2026-03-28T08:31:22.226811Z"]