Deep EVM #29: Async Rust에서의 세마포어 — 데드락 추적과 Fire-and-Forget 패턴
Engineering Team
Async Rust에서 세마포어가 필요한 이유
고처리량 파이프라인을 운영할 때 — 블록당 180,000개의 아비트라지 체인을 처리하는 MEV 봇, 10,000개의 동시 요청을 처리하는 API 서버, 수백만 행을 기록하는 ETL 작업 — 반드시 리소스 한계에 도달합니다. 데이터베이스 커넥션 풀이 고갈되고, RPC 프로바이더가 속도 제한을 적용하며, 50,000개의 tokio 태스크를 생성하여 각각 체인 데이터를 보유하면서 메모리가 급증합니다.
단순한 접근 방식은 무제한 동시성입니다: 모든 작업 단위에 tokio::spawn을 사용하고 런타임이 알아서 처리하기를 기대합니다. 하지만 그렇게 되지 않습니다. 프로덕션 환경에서 우리는 270만 개의 생성된 태스크로 인해 15.4GB의 메모리 사용량을 관찰했으며, 각 태스크는 Vec<Hop>과 시뮬레이션 컨텍스트를 보유하고 있었습니다. 해결책은 세마포어 기반 백프레셔를 통한 배치 동시성이었으며, 이를 통해 메모리를 0.8GB로 줄였습니다.
세마포어는 작업을 완전히 직렬화하지 않으면서 동시 작업 수를 제한해야 할 때 적합한 프리미티브입니다. 뮤텍스(정확히 하나만 허용)와 달리 세마포어는 N개의 동시 접근자를 허용합니다. 이는 다음과 같은 용도에 적합합니다:
- 데이터베이스 쓰기 동시성: 커넥션 풀 크기로 제한 (예: 20개 동시 쓰기)
- RPC 속도 제한: 프로바이더로부터의 429 응답을 피하기 위해 발신 요청 제한
- 메모리 백프레셔: 사용 가능한 퍼밋으로 게이트하여 무제한 태스크 생성 방지
- 배치 처리: 공유 노드에 대해 병렬 실행되는 시뮬레이션 배치 수 제어
tokio::sync::Semaphore 기초
tokio::sync::Semaphore는 비동기 코드를 위한 카운팅 세마포어입니다. 사용 가능한 퍼밋의 내부 카운터를 유지합니다. 태스크는 진행하기 전에 퍼밋을 획득하고 완료 시 해제합니다.
use std::sync::Arc;
use tokio::sync::Semaphore;
// 최대 20개의 동시 데이터베이스 쓰기 허용
let semaphore = Arc::new(Semaphore::new(20));
for chain in chains_to_persist {
let sem = semaphore.clone();
let db_pool = db_pool.clone();
tokio::spawn(async move {
// 퍼밋 획득 — 20개 모두 사용 중이면 대기
let _permit = sem.acquire().await.unwrap();
// 쓰기 수행 — 이 스코프 동안 퍼밋 보유
sqlx::query("UPDATE chains SET profit = $1 WHERE id = $2")
.bind(chain.profit)
.bind(chain.id)
.execute(&db_pool)
.await
.ok();
// _permit이 여기서 드롭 — 자동으로 해제
});
}
주요 API:
Semaphore::new(permits)— N개의 퍼밋으로 생성acquire(&self)— 비동기, 퍼밋 사용 가능할 때까지 대기,SemaphorePermit(RAII 가드) 반환try_acquire(&self)— 비동기 아님, 퍼밋이 없으면 즉시Err(TryAcquireError)반환acquire_owned(self: Arc<Self>)—Arc를 소유하는OwnedSemaphorePermit반환, 퍼밋이 빌림보다 오래 지속되어야 할 때 유용add_permits(&self, n)— 동적으로 퍼밋 수 증가close(&self)— 세마포어 닫기; 모든 대기 중인acquire호출이Err(AcquireError)반환available_permits(&self)— 현재 카운트 확인 (메트릭에 유용)
핵심 설계 선택: acquire()는 RAII 가드를 반환합니다. 가드가 드롭되면 퍼밋이 해제됩니다. 이는 패닉, 조기 반환, ? 연산자 중단 시에도 가드가 스택에 있는 한 자동 정리를 보장합니다.
Fire-and-Forget 쓰기 패턴
고처리량 시스템에서는 핫 패스를 차단하지 않고 데이터를 영속화하고 싶을 때가 많습니다. 패턴은 다음과 같습니다: 백그라운드 태스크를 생성하여 세마포어 퍼밋을 획득하고, 쓰기를 수행한 다음 해제합니다. 호출자는 결과를 기다리지 않습니다.
잘 설계된 시스템에서는 내부 스토리지 서비스를 래핑하는 데코레이터로 구현됩니다:
pub struct AsyncChainStore<S: ChainStore> {
inner: Arc<S>,
semaphore: Arc<Semaphore>,
}
impl<S: ChainStore + Send + Sync + 'static> AsyncChainStore<S> {
pub fn save_profits_async(&self, chains: Vec<ChainProfit>) {
let inner = self.inner.clone();
let sem = self.semaphore.clone();
tokio::spawn(async move {
let _permit = match sem.acquire().await {
Ok(p) => p,
Err(_) => {
tracing::warn!("세마포어 닫힘, 쓰기 드롭");
return;
}
};
if let Err(e) = inner.batch_update_profits(&chains).await {
tracing::error!(
error = %e,
count = chains.len(),
"fire-and-forget 쓰기 실패"
);
}
});
}
}
이 데코레이터 패턴은 내부 ChainStore를 순수하게 유지합니다 — 동시성 제어에 대해 전혀 알 필요가 없습니다. 데코레이터가 세마포어 관리, 에러 로깅, 태스크 생성을 처리합니다.
왜 데이터베이스 풀의 내장 커넥션 제한을 사용하지 않을까요? 세마포어가 별도의 조절 장치를 제공하기 때문입니다. 풀에 50개의 커넥션이 있더라도 fire-and-forget 쓰기에는 최대 20개를 사용하고 나머지 30개는 지연 시간에 민감한 읽기를 위해 예약할 수 있습니다.
데드락 시나리오
세마포어 데드락은 교묘합니다. 패닉이나 에러가 발생하지 않고 프로그램이 단순히 진행을 멈춥니다. 프로덕션에서 겪은 패턴들을 소개합니다.
시나리오 1: 조기 반환 시 퍼밋 미해제
RAII 가드는 대부분의 조기 반환으로부터 보호합니다. 위험한 것은 퍼밋을 예상 수명을 초과하는 구조체로 이동하는 경우입니다 — HashMap에 저장, 채널을 통해 전송, static에 보유하는 경우.
시나리오 2: 중첩된 Acquire (자기 데드락)
async fn simulate_and_persist(
sem: &Semaphore,
chain: &Chain,
) -> Result<()> {
let _outer = sem.acquire().await?; // N개 퍼밋 중 1개 사용
let result = simulate(chain).await?;
// 버그: 보유 중인 퍼밋 안에서 동일한 세마포어 획득
let _inner = sem.acquire().await?; // N=1이면 데드락
persist(result).await?;
Ok(())
}
N=1이면 즉시 데드락입니다. N>1이면 부하가 증가하여 모든 퍼밋이 내부 acquire를 기다리는 태스크에 의해 보유될 때까지 작동합니다. 해결책: 별도의 관심사에 대해 별도의 세마포어를 사용하거나, 중첩 획득을 피하도록 구조를 변경합니다.
시나리오 3: Select 내 await 포인트에서 퍼밋 보유
select! 매크로는 패배한 브랜치의 future를 드롭하여 취소하지만, 해당 future 내에서 생성된 태스크는 계속 실행됩니다. 해당 태스크가 퍼밋에 대한 참조를 캡처했다면 퍼밋이 예상대로 해제되지 않습니다.
세마포어 데드락 진단
시스템이 진행을 멈출 때, 세마포어 데드락인지 느린 의존성인지 어떻게 식별할까요?
트레이싱 기반 진단
acquire/release를 구조화된 로깅으로 계측합니다:
let permits_before = semaphore.available_permits();
tracing::debug!(
available = permits_before,
"세마포어 퍼밋 획득 중"
);
let _permit = semaphore.acquire().await?;
tracing::debug!(
available = semaphore.available_permits(),
"세마포어 퍼밋 획득 완료"
);
로그에 “획득 중“은 나오지만 “획득 완료“가 나오지 않으면 모든 퍼밋이 어딘가에 보유되어 있는 것입니다. 마지막 N개의 “획득 완료” 로그 항목과 상관관계를 분석하여 누가 보유하고 있는지 찾습니다.
Prometheus 메트릭
사용 가능한 퍼밋에 대한 게이지 메트릭을 노출합니다:
use metrics::gauge;
gauge!("semaphore_available_permits", semaphore.available_permits() as f64);
0으로 떨어져 그대로 유지되는 게이지는 데드락입니다. 0 근처에서 변동하는 게이지는 경합(높은 부하, 더 많은 퍼밋 필요 가능성)입니다.
tokio-console
tokio-console은 실행 중인 tokio 애플리케이션에 연결하여 실시간으로 태스크 상태를 보여주는 진단 도구입니다:
cargo add --dev console-subscriber
#[cfg(debug_assertions)]
console_subscriber::init();
다른 터미널에서 tokio-console을 실행하고 세마포어 acquire에서 “Idle” 상태로 멈춘 태스크를 찾습니다.
프로덕션 강화 솔루션
솔루션 1: OwnedSemaphorePermit과 Arc 사용
태스크를 생성할 때 acquire() 대신 acquire_owned()를 사용합니다. 소유 변형은 Arc<Semaphore>를 받아 세마포어를 빌리지 않는 퍼밋을 반환합니다:
let semaphore = Arc::new(Semaphore::new(20));
for item in items {
let permit = semaphore.clone().acquire_owned().await?;
tokio::spawn(async move {
do_work(item).await;
drop(permit); // 명시적 드롭 또는 스코프가 처리
});
}
솔루션 2: 타임아웃 포함 Acquire
프로덕션에서 퍼밋을 무한정 기다리지 마십시오. tokio::time::timeout을 사용하여 데드락을 조기에 감지합니다:
use tokio::time::{timeout, Duration};
let permit = timeout(
Duration::from_secs(30),
semaphore.acquire(),
).await
.map_err(|_| anyhow!("세마포어 acquire 타임아웃 — 데드락 가능성"))?
.map_err(|_| anyhow!("세마포어 닫힘"))?;
타임아웃이 발생하면 사용 가능한 퍼밋 수, 대기 중인 태스크 수, 스택 트레이스를 로깅합니다. 이를 통해 프로세스를 중단하지 않고도 데드락에 대한 즉각적인 가시성을 확보할 수 있습니다.
솔루션 3: JoinSet을 활용한 구조화된 동시성
무제한 tokio::spawn 대신 tokio::task::JoinSet을 사용하여 생성된 태스크의 소유권을 유지하고 세마포어와 결합합니다:
use tokio::task::JoinSet;
let semaphore = Arc::new(Semaphore::new(20));
let mut join_set = JoinSet::new();
for batch in batches {
let permit = semaphore.clone().acquire_owned().await?;
let db_pool = db_pool.clone();
join_set.spawn(async move {
let result = process_batch(&db_pool, &batch).await;
drop(permit);
result
});
}
while let Some(result) = join_set.join_next().await {
match result {
Ok(Ok(())) => {}
Ok(Err(e)) => tracing::error!(error = %e, "배치 처리 실패"),
Err(e) => tracing::error!(error = %e, "태스크 패닉"),
}
}
이 패턴은 다음을 보장합니다: (1) 세마포어를 통한 제한된 동시성, (2) 무음 태스크 실패 없음, (3) 부모 태스크가 모든 자식 완료를 인지, (4) 태스크가 패닉해도 퍼밋은 항상 해제.
솔루션 4: 관심사별 별도 세마포어
관련 없는 작업 간에 단일 세마포어를 공유하지 마십시오:
pub struct ConcurrencyLimits {
/// 노드에 대한 동시 RPC 시뮬레이션 호출 제한
pub simulation: Arc<Semaphore>,
/// 체인 영속화를 위한 동시 데이터베이스 쓰기 제한
pub db_writes: Arc<Semaphore>,
/// 동시 멤풀 구독 핸들러 제한
pub mempool: Arc<Semaphore>,
}
impl ConcurrencyLimits {
pub fn new() -> Self {
Self {
simulation: Arc::new(Semaphore::new(4)),
db_writes: Arc::new(Semaphore::new(20)),
mempool: Arc::new(Semaphore::new(100)),
}
}
}
이를 통해 중첩된 acquire 데드락을 완전히 제거합니다 — 태스크가 simulation 퍼밋을 획득한 후 db_writes 퍼밋을 획득하며, 이들은 독립적인 풀입니다.
성능: 세마포어 오버헤드
세마포어 자체가 병목이 될까요? 실제로는 아닙니다. tokio::sync::Semaphore는 원자적 카운터와 침입형 연결 리스트로 구현됩니다. 비경합 퍼밋 획득은 단일 fetch_sub — 나노초입니다. 경합 상태에서도 오버헤드는 웨이커 알림(역시 나노초)이며, 실제 I/O가 걸리는 밀리초와 비교됩니다.
우리 파이프라인에서 세마포어 오버헤드를 벤치마킹했습니다:
| 작업 | 세마포어 없음 | 세마포어 사용 (20 퍼밋) |
|---|---|---|
| 10,000 DB 쓰기 | 1,340ms (전체 동시, 풀 고갈 에러) | 1,580ms (제어됨, 에러 제로) |
| 500 RPC 시뮬레이션 | 8,200ms (노드 과부하, 타임아웃) | 9,100ms (4개 동시, 타임아웃 제로) |
| 메모리 (270만 태스크) | 15.4 GB | 0.8 GB (JoinSet 배치) |
10-15%의 벽시계 오버헤드는 에러, 타임아웃, OOM 크래시 제거와 비교하면 무시할 수 있는 수준입니다.
결론
Async Rust에서의 세마포어는 겉보기에 단순합니다 — acquire, 작업 수행, 퍼밋 드롭. 복잡성은 프로덕션에서 나타납니다: 장기 보유 구조체에 누출된 퍼밋, 콜 스택 간 중첩된 acquire, select! 취소 경계에서 보유된 퍼밋.
방어적 플레이북:
- 태스크 생성 시 항상
acquire_owned()사용 - acquire에 항상 타임아웃 래핑
- 동일 세마포어에서 acquire를 절대 중첩하지 않기
- 별도의 리소스 풀에 대해 세마포어 분리
- 메트릭과 트레이싱으로 사용 가능한 퍼밋 계측
- 무제한
tokio::spawn대신 JoinSet 사용으로 구조화된 동시성 유지
이 패턴들은 수백만 블록 처리, 수십만 동시 태스크, 그리고 채택 이후 프로덕션에서 데드락 제로를 달성하며 검증되었습니다.