# Async-background: Ein leichtgewichtiges Hintergrund-Job-System für Ruby ohne externe Abhängigkeiten
Entwickler von Ruby-Anwendungen müssen häufig Hintergrundjobs ausführen, doch fertige Lösungen wie Sidekiq erfordern zusätzliche Infrastruktur. Das neue Gem async-background bietet eine Alternative, die auf Async läuft – ohne Redis oder Postgres – und eine feingranulare Kontrolle über Ressourcen ermöglicht.
Warum bestehende Lösungen zu kurz greifen
Sidekiq wird typischerweise für Hintergrundjobs in Ruby verwendet, erfordert aber Redis. Für kleine Projekte mit nur wenigen Jobs ist das Overkill: ein zusätzlicher Container in docker-compose, ein weiterer Ausfallpunkt und Infrastrukturkosten. Zudem erlaubt Sidekiq keine flexible Lastverteilung unter Workers – ein schwerer nächtlicher Bericht kann die Verarbeitung von Benutzer-E-Mails verlangsamen.
Andere Optionen haben ebenfalls Nachteile:
whenever— erzeugt eine System-Crondatei, ohne Queue oder App-Integration.rufus-scheduler— läuft in separaten Threads, was Kontextwechsel und Duplikationsprobleme in Async-Apps mit mehreren Prozessen verursacht.good_jobundsolid_queue— gebunden an ActiveRecord und Postgres. Das birgt Risiken: Hintergrundjobs konkurrieren mit transaktionaler Last um Datenbankressourcen und können die Hauptanwendung verlangsamen.
Es bestand daher Bedarf an einer Lösung, die Cron-Zeitpläne, Intervalle und eine dynamische Queue ohne externe Abhängigkeiten kombiniert, ergänzt um Kontrolle über die Ressourcennutzung.
Wichtige Designprinzipien
Der Autor des Gems hat sechs Kernprinzipien formuliert:
- Einzelne Event-Loop. Der Scheduler läuft in derselben reaktiven Schleife wie die App, ohne separate Threads.
- Keine Infrastruktur. Die Queue wird auf Disk gespeichert (SQLite im WAL-Modus oder eine Datei in
tmp/), kein Redis oder Postgres. - Optionale Abhängigkeiten. Zusätzliche Features (z. B. Metriken) werden über optionale Gems hinzugefügt.
- Multi-Process-Sicherheit ohne Koordinator. Stellt sicher, dass ein Job bei Verwendung von Falcon (Forks) nur in einem Prozess läuft.
- Persistenz als Versicherung, Geschwindigkeit als separater Mechanismus. Zuverlässigkeit durch SQLite-Speicherung, Geschwindigkeit durch UNIX-Socket-Wakeups.
- Gesteuerte Worker-Nutzung. Entwickler können Jobs über Umgebungsvariablen und YAML-Konfig spezifischen Workers zuweisen.
Diese Prinzipien ermöglichen ein Gem, das nahtlos in bestehende Async-Apps integriert wird, ohne zusätzliche Infrastruktur.
Architekturmerkmale
MinHeap statt Polling-Ticks
Statt einer Schleife mit sleep 1 (die jede Sekunde aufwacht, um alle Jobs zu prüfen), wird ein binärer Min-Heap (MinHeap) verwendet. Das ermöglicht:
- Abruf des nächsten Jobs in O(1)-Zeit mit
peek. - Aktualisierung des Heaps nach Job-Ausführung in O(log n)-Zeit mit
replace_top. - Präzises Schlafen bis zum nächsten Event, was CPU-Ressourcen spart.
Die Implementierung umfasst nur 74 Zeilen und hat keine externen Abhängigkeiten.
Zwei Zeitskalen
Für intervallbasierte Jobs (z. B. every: 60) werden monotonische Uhren (CLOCK_MONOTONIC) verwendet, um Probleme mit Zeitsynchronisation (NTP) zu vermeiden. Für Cron-Jobs (z. B. cron: "0 3 *") wird Wanduhrenzeit (Time.now) genutzt, da der Zeitplan an die Kalenderzeit gebunden ist.
Im Code sieht das so aus:
next_run_at = if task_config[:interval]
now + jitter + task_config[:interval]
else
now_wall = Time.now
wall_wait = task_config[:cron].next_time(now_wall).to_f - now_wall.to_f
now + jitter + [wall_wait, MIN_SLEEP_TIME].max
end
Sharding via CRC32
Bei Verwendung von Falcon (das forkt) darf ein Job nur in einem Prozess laufen. Deterministisches Sharding erreicht das: Zlib.crc32(name) % total_workers + 1 wird für den Job-Namen berechnet. Passt es zum Index des aktuellen Workers, wird der Job geladen.
assigned = config['worker']&.to_i || ((Zlib.crc32(name) % total_workers) + 1)
next unless assigned == worker_index
Das macht verteilte Locks überflüssig.
Skip-on-Overlap
Läuft ein Job länger als sein Intervall (z. B. 90 Sekunden bei 60-Sekunden-Intervall), wird die neue Instanz übersprungen. Stattdessen wird der aktuelle Job protokolliert, übersprungen und für den nächsten Slot neu geplant:
if entry.running
logger.warn('Async::Background') { "#{entry.name}: skipped, previous run still active" }
metrics.job_skipped(entry)
entry.reschedule(monotonic_now)
heap.replace_top(entry)
next
end
Queue: SQLite + UNIX Socket
Die dynamische Queue nutzt zwei Mechanismen:
- SQLite — Quelle der Wahrheit. Jobs werden für Zuverlässigkeit auf Disk persistiert.
- UNIX Socket — für sofortige Benachrichtigungen. Nach Hinzufügen eines Jobs zur DB wird ein Signal über den Socket gesendet, damit der Worker ihn unverzüglich verarbeitet.
Polling alle 5 Sekunden dient als Backup bei Signalverlust.
Fehlerwiederherstellung
Stürzt ein Worker mitten in einem Job ab, bleibt der Status „running“. Beim Neustart findet Queue::Store#recover solche Einträge und gibt sie zurück in die Queue.
Wichtige Erkenntnisse
- Keine Abhängigkeiten von externen Diensten. Async-background basiert nur auf dem Dateisystem und vereinfacht den Deployment-Prozess.
- Ressourcenkontrolle. Isolierung von Workers für verschiedene Job-Typen (Cron, Queue) verhindert Ressourcenkonflikte.
- Zuverlässigkeit. Jobs gehen nicht verloren dank SQLite-Persistenz und Wiederherstellungsmechanismen.
- Flexibilität. YAML- und Umgebungsvariablen-Konfig erleichtern die Anpassung der Job-Verteilung.
- Performance. Min-Heap und UNIX-Sockets sorgen für niedrige Latenz und effiziente CPU-Nutzung.
— Editorial Team
Noch keine Kommentare.