Zum Hauptinhalt springen
IngenieurwesenMar 28, 2026

Deep EVM #29: Semaphoren in async Rust — Deadlock-Jagd und Fire-and-Forget-Muster

OS
Open Soft Team

Engineering Team

Das Problem: Ressourcen-Obergrenzen

Wenn Sie eine Hochdurchsatz-Pipeline betreiben — einen MEV-Bot, der 180.000 Arbitrage-Ketten pro Block verarbeitet, einen API-Server mit 10.000 gleichzeitigen Anfragen oder einen ETL-Job, der Millionen von Zeilen schreibt — stossen Sie unweigerlich an eine Ressourcen-Obergrenze. Datenbank-Verbindungspools erschoepfen sich. RPC-Provider begrenzen Ihre Rate. Der Speicher blaetzt sich auf, weil Sie 50.000 tokio-Tasks gespawnt haben, die jeweils Daten einer Kette halten.

Die Antwort: Semaphoren — ein Primitiv fuer die Kontrolle der Nebenlaeufigkeit, das die Anzahl gleichzeitiger Operationen auf eine feste Obergrenze begrenzt.

tokio::sync::Semaphore Grundlagen

use tokio::sync::Semaphore;
use std::sync::Arc;

let sem = Arc::new(Semaphore::new(10)); // Maximal 10 gleichzeitig

for item in items {
    let sem = sem.clone();
    tokio::spawn(async move {
        // Permit anfordern — blockiert wenn alle 10 vergeben
        let _permit = sem.acquire().await.unwrap();
        
        // Arbeit ausfuehren (maximal 10 gleichzeitig)
        process(item).await;
        
        // Permit wird automatisch freigegeben (RAII)
    });
}

Das Schluesselprinzip: acquire() wartet, bis ein Permit verfuegbar ist. Der RAII-Guard _permit gibt das Permit automatisch frei, wenn er den Scope verlaesst. Dies garantiert, dass nie mehr als N Operationen gleichzeitig laufen.

Fire-and-Forget-Schreibmuster

In vielen Systemen muessen Sie Ergebnisse in eine Datenbank schreiben, wollen aber nicht auf den Abschluss warten. Das klassische Fire-and-Forget-Muster:

// SCHLECHT: Unbegrenztes Spawning
for result in results {
    tokio::spawn(async move {
        db.insert(result).await; // Wenn db langsam -> unbegrenzte Tasks
    });
}

// GUT: Semaphore-begrenztes Fire-and-Forget
let write_sem = Arc::new(Semaphore::new(50)); // Max 50 gleichzeitige Schreibvorgaenge

for result in results {
    let sem = write_sem.clone();
    let db = db.clone();
    tokio::spawn(async move {
        let _permit = sem.acquire().await.unwrap();
        db.insert(result).await;
    });
}

Ohne Semaphore: Wenn die Datenbank langsam ist, werden Tausende von Tasks gespawnt, die alle auf die Datenbank warten, Speicher belegen und die Verbindungspools ueberlasten.

Mit Semaphore: Maximal 50 Tasks warten gleichzeitig auf die Datenbank. Der Rest wartet auf ein Permit — kostenlos im Speicher.

Deadlock-Szenarien

Szenario 1: Verschachtelte acquire()-Aufrufe

let sem = Arc::new(Semaphore::new(1));

let permit1 = sem.acquire().await.unwrap(); // OK
// DEADLOCK: Wartet auf ein Permit, das wir selbst halten
let permit2 = sem.acquire().await.unwrap(); // Haengt fuer immer

Szenario 2: Permit ueber await-Punkt halten

let sem = Arc::new(Semaphore::new(2));

async fn process(sem: Arc<Semaphore>) {
    let permit = sem.acquire().await.unwrap();
    
    // Externer Aufruf, der laengeertete Minuten dauern kann
    let result = slow_rpc_call().await; // Permit bleibt gehalten!
    
    // Andere Tasks warten auf dieses Permit
    db.insert(result).await;
}

Loesung: Acquire-Timeout

use tokio::time::{timeout, Duration};

let permit = timeout(
    Duration::from_secs(5),
    sem.acquire()
).await;

match permit {
    Ok(Ok(permit)) => {
        // Permit erhalten, Arbeit ausfuehren
    }
    Ok(Err(_)) => {
        // Semaphore geschlossen
    }
    Err(_) => {
        // Timeout! Moglicher Deadlock.
        tracing::warn!("Semaphore acquire timed out — possible deadlock");
    }
}

Deadlock-Diagnose mit Tracing

use tracing::{instrument, warn};

#[instrument(skip(sem, db))]
async fn write_results(
    sem: Arc<Semaphore>,
    db: Arc<Database>,
    results: Vec<Result>,
) {
    let available = sem.available_permits();
    tracing::info!(available_permits = available, total_results = results.len());
    
    for (i, result) in results.into_iter().enumerate() {
        let sem = sem.clone();
        let db = db.clone();
        
        tokio::spawn(async move {
            let start = Instant::now();
            
            let permit = timeout(
                Duration::from_secs(30),
                sem.acquire()
            ).await;
            
            let wait_time = start.elapsed();
            if wait_time > Duration::from_secs(5) {
                warn!(
                    wait_ms = wait_time.as_millis(),
                    "Semaphore wait exceeded 5 seconds"
                );
            }
            
            if let Ok(Ok(_permit)) = permit {
                db.insert(result).await;
            }
        });
    }
}

tokio-console fuer Live-Debugging

tokio-console ist ein Diagnose-Werkzeug, das Tasks, Ressourcen und Wartezeiten in Echtzeit visualisiert:

// In main.rs:
console_subscriber::init();

// Dann in einem separaten Terminal:
tokio-console

tokio-console zeigt:

  • Alle laufenden Tasks mit ihrer Laufzeit
  • Semaphore-Wartezeiten pro Task
  • Tasks, die seit langem auf ein acquire() warten (Deadlock-Verdacht)

Strukturierte Nebenlaeufigkeit

Anstatt unbegrenzt Tasks zu spawnen, verwenden Sie strukturierte Nebenlaeufigkeit:

use futures::stream::{self, StreamExt};

stream::iter(items)
    .map(|item| {
        let sem = sem.clone();
        async move {
            let _permit = sem.acquire().await.unwrap();
            process(item).await
        }
    })
    .buffer_unordered(100) // Maximal 100 gleichzeitige Futures
    .collect::<Vec<_>>()
    .await;

Vorteil: Alle Tasks werden innerhalb eines einzigen Scopes verwaltet. Wenn der aeussere Future abgebrochen wird, werden alle inneren Tasks ebenfalls abgebrochen — kein Ressourcen-Leak.

Produktions-Checkliste fuer Semaphoren

  1. Timeouts auf acquire() — Immer. Deadlocks sind in Produktion unvermeidlich.
  2. Monitoring der verfuegbaren Permits — Prometheus-Metriken fuer sem.available_permits().
  3. Richtige Semaphore-Groesse — Zu klein: kuenstlicher Engpass. Zu gross: Ressourcen-Ueberlastung.
  4. RAII fuer Permit-Freigabe — Niemals manuell forget() auf einem Permit aufrufen.
  5. Kein verschachteltes acquire() — Wenn unvermeidlich, try_acquire() fuer den inneren Aufruf verwenden.
  6. tracing-Integration — Jedes acquire() und release() loggen, um Deadlocks zu diagnostizieren.

Fazit

Semaphoren sind das fundamentale Primitiv fuer Backpressure-Kontrolle in async Rust. Sie verhindern Ressourcen-Erschoepfung, begrenzen Nebenlaeufigkeit und ermoeglichen sicheres Fire-and-Forget. Aber sie bringen Deadlock-Risiken mit sich, die Timeouts, Monitoring und strukturierte Nebenlaeufigkeit erfordern. Behandeln Sie jeden Semaphore-Acquire als potenziellen Haltepunkt und instrumentieren Sie ihn entsprechend.