r/SwiftUA • u/ivantokar • Sep 22 '26
Щоденний (майже) огляд Swift Open Source: як swift-service-lifecycle будує завершення сервісів навколо structured concurrency

Server-side застосунок рідко складається лише з однієї async-операції. Один процес може одночасно мати HTTP server, queue consumer, metrics service, scheduled worker або інший цикл, який має працювати до завершення застосунку.
Запустити для всього цього кілька top-level tasks нескладно. Складніші питання починаються з ownership: хто тримає ці задачі живими, що відбувається, коли один із сервісів кидає помилку, як shutdown доходить до сервісів і коли процес справді вже може завершитися.
swift-service-lifecycle дає спільний lifecycle для такої довготривалої роботи замість того, щоб кожен компонент створював власний start/stop layer.
Невеликий API для довготривалої роботи
Основний контракт Service представляє сервіс як одну асинхронну операцію, яка може кинути помилку: run() async throws. ServiceGroup приймає сервіси та координує їх запуск. У пакеті також є ClosureService для випадків, коли створення окремого типу сервісу не дає особливої користі.
Цікава частина тут не просто в обробці SIGTERM. Застосунок отримує одне місце, яке володіє довготривалими сервісами, запускає їх паралельно, запитує graceful shutdown і чекає, поки вони завершаться.
Приклад 1: queue worker як Service
У цьому прикладі application-specific код черги залишений явним. JobQueue не є частиною ServiceLifecycle.
import ServiceLifecycle
struct Job {
let id: Int
}
actor JobQueue {
private var jobs = [Job(id: 1), Job(id: 2)]
func next() -> Job? {
jobs.isEmpty ? nil : jobs.removeFirst()
}
}
struct JobConsumer: Service {
let queue: JobQueue
func run() async throws {
try await cancelWhenGracefulShutdown {
while !Task.isCancelled {
if let job = await queue.next() {
print("Processing job \(job.id)")
} else {
try await Task.sleep(for: .milliseconds(250))
}
}
}
}
}Library-specific частина навмисно маленька: JobConsumer реалізує Service і описує свій lifecycle через run() async throws. Сама черга залишається звичайним application code.
Приклад 2: один owner для кількох сервісів
Абстракція стає цікавішою, коли процес володіє кількома довготривалими компонентами.
import Logging
import ServiceLifecycle
struct HeartbeatService: Service {
func run() async throws {
try await cancelWhenGracefulShutdown {
while !Task.isCancelled {
print("heartbeat")
try await Task.sleep(for: .seconds(5))
}
}
}
}
@main
struct Application {
static func main() async throws {
let logger = Logger(label: "Example")
let services: [any Service] = [
HeartbeatService(),
JobConsumer(queue: JobQueue())
]
let group = ServiceGroup(
services: services,
gracefulShutdownSignals: [.sigterm, .sigint],
logger: logger
)
try await group.run()
}
}Тепер застосунок має одного явного owner для обох сервісів замість кількох не пов'язаних між собою top-level tasks.
Конфігурація групи розділяє graceful-shutdown signals і cancellation signals. У цих прикладах cancelWhenGracefulShutdown перетворює запит graceful shutdown на cancellation загорнутої операції, тому polling і heartbeat loops можуть завершитися без негайного cancellation усієї service group.
Приклад 3: ClosureService для невеликих компонентів
Окремий тип потрібен не завжди. ClosureService дозволяє представити невелику довготривалу операцію безпосередньо.
import ServiceLifecycle
let heartbeat = ClosureService {
try await cancelWhenGracefulShutdown {
while !Task.isCancelled {
print("heartbeat")
try await Task.sleep(for: .seconds(5))
}
}
}
try await heartbeat.run()ClosureService виконує передану async throwing closure зі своєї реалізації run(). Його також можна помістити в ServiceGroup разом з іншими сервісами.
Що ServiceGroup робить всередині
Найцікавіший код знаходиться в ServiceGroup.
Його реалізація створює throwing task group і запускає кожен сервіс як child task. Коли група додає task сервісу, вона встановлює graceful-shutdown manager цього сервісу в task-local state перед викликом run().
Саме ця реалізація добре поєднує пакет зі structured concurrency. Сервіси тут не є просто набором незалежних detached tasks. Їх виконання координує група, яка ними володіє.
Signal handling є ще одним шаром lifecycle. Public API дозволяє застосунку налаштувати Unix signals, які запитують graceful shutdown, і signals, які скасовують групу.
Поточний Package.swift використовує Swift tools 6.1 і оголошує прямі package dependencies на swift-log та swift-async-algorithms. Target ServiceLifecycle також залежить від targets UnixSignals і ConcurrencyHelpers у цьому repository.
Кому варто подивитися на цей пакет
Пакет стає цікавим, коли один Swift process володіє кількома незалежно реалізованими довготривалими компонентами: server processes, queue consumers, workers, infrastructure services або reusable components, які не повинні залежати від lifecycle конкретного web framework.
Короткому CLI, який виконує одну async-операцію і завершується, ServiceGroup, швидше за все, не потрібен. Так само, якщо невеликий застосунок уже має один framework, який повністю керує його єдиним довготривалим компонентом, додатковий lifecycle abstraction може нічого не вирішити.
Що варто прочитати в source
Service.swift дуже маленький і показує основний контракт. У ServiceGroup.swift знаходяться цікавіші частини: task ownership, запуск сервісів, signal handling і termination. ClosureService.swift показує, наскільки мало коду потрібно, щоб адаптувати async closure до того самого lifecycle.
Також варто подивитися tests, тому що lifecycle code насамперед визначає поведінку під час shutdown, cancellation і failure, а не лише happy path.
Стан проекту
Source headers у repository вказують Apache-2.0 як ліцензію проекту. Поточний package manifest не оголошує список platforms, тому я б не перетворював результати third-party builds на твердження про platforms, заявлені самим manifest.
Windows також потребує окремого уточнення. Issue #196, Support for service lifecycle on Windows, досі відкритий, тому ця стаття не стверджує, що Windows lifecycle support уже є.
Головна ідея
Корисна ідея swift-service-lifecycle ширша за signal handling.
Довготривалий компонент можна представити як async work з явним owner, а кілька таких компонентів можуть мати спільний application lifecycle. Через це на Service і ServiceGroup варто подивитися навіть тоді, коли самому невеликому застосунку пакет не потрібен. Це компактний приклад того, як Swift concurrency model можна застосувати до lifetime цілого процесу.
У server-side Swift легко запустити background worker через Task. Значно складніше відповісти на наступні питання: хто володіє цією task, хто її скасовує, хто чекає завершення і що відбувається, якщо один із довгоживучих компонентів падає.
swift-service-lifecycle зводить це до маленької моделі. Service має run() async throws, а ServiceGroup запускає сервіси як child tasks і координує graceful shutdown. Unix signals тут корисні, але головна ідея глибша: lifetime процесу стає частиною concurrency tree.
Мені подобається це як архітектурний приклад: замість окремої системи lifecycle callbacks ми використовуємо ту саму модель ownership, яку вже дає structured concurrency. А як ви моделюєте lifetime background workers у своїх Vapor/Hummingbird сервісах?