|
| 1 | +# Adopting ServiceLifecycle in libraries |
| 2 | + |
| 3 | +Adopt the Service protocol for your service to allow a Service Group to coordinate it's operation with other services. |
| 4 | + |
| 5 | +## Overview |
| 6 | + |
| 7 | +Service Lifecycle provides a unified API to represent a long running task: the ``Service`` protocol. |
| 8 | + |
| 9 | +Before diving into how to adopt this protocol in your library, let's take a step back and talk about why we need to have this unified API. |
| 10 | +Services often need to schedule long-running tasks, such as sending keep-alive pings in the background, or the handling of incoming work like new TCP connections. |
| 11 | +Before Swift Concurrency was introduced, services put their work into separate threads using a `DispatchQueue` or an NIO `EventLoop`. |
| 12 | +Services often required explicit lifetime management to make sure their resources, such as threads, were shut down correctly. |
| 13 | + |
| 14 | +With the introduction of Swift Concurrency, specifically by using Structured Concurrency, we have better tools to structure our programs and model our work as a tree of tasks. |
| 15 | +The `Service` protocol provides a common interface, a single `run()` method, for services to use when they run their long-running work. |
| 16 | +If all services in an application conform to this protocol, then orchestrating them becomes trivial. |
| 17 | + |
| 18 | +## Adopting the Service protocol in your service |
| 19 | + |
| 20 | +Adopting the `Service` protocol is quite easy. |
| 21 | +The protocol's single requirement is the ``Service/run()`` method. |
| 22 | +Make sure that your service adheres to the important caveats addressed in the following sections. |
| 23 | + |
| 24 | +### Use Structured Concurrency |
| 25 | + |
| 26 | +Swift offers multiple ways to use Concurrency. |
| 27 | +The primary primitives are the `async` and `await` keywords which enable straight-line code to make asynchronous calls. |
| 28 | +The language also provides the concept of task groups; they allow the creation of concurrent work, while staying tied to the parent task. |
| 29 | +At the same time, Swift also provides `Task(priority:operation:)` and `Task.detached(priority:operation:)` which create new unstructured Tasks. |
| 30 | + |
| 31 | +Imagine our library wants to offer a simple `TCPEchoClient`. |
| 32 | +To make it Interesting, let's assume we need to send keep-alive pings on every open connection every second. |
| 33 | +Below you can see how we could implement this using unstructured concurrency. |
| 34 | + |
| 35 | +```swift |
| 36 | +public actor TCPEchoClient { |
| 37 | + public init() { |
| 38 | + Task { |
| 39 | + for await _ in AsyncTimerSequence(interval: .seconds(1), clock: .continuous) { |
| 40 | + self.sendKeepAlivePings() |
| 41 | + } |
| 42 | + } |
| 43 | + } |
| 44 | + |
| 45 | + private func sendKeepAlivePings() async { ... } |
| 46 | +} |
| 47 | +``` |
| 48 | + |
| 49 | +The above code has a few problems. |
| 50 | +First, the code never cancels the `Task` that runs the keep-alive pings. |
| 51 | +To do this, it would need to store the `Task` in the actor and cancel it at the appropriate time. |
| 52 | +Second, it would also need to expose a `cancel()` method on the actor to cancel the `Task`. |
| 53 | +If it were to do all of this, it would have reinvented Structured Concurrency. |
| 54 | + |
| 55 | +To avoid all of these problems, the code can conform to the ``Service`` protocol. |
| 56 | +Its requirement guides us to implement the long-running work inside the `run()` method. |
| 57 | +It allows the user of the client to decide in which task to schedule the keep-alive pings — using an unstructured `Task` is an option as well. |
| 58 | +Furthermore, we now benefit from the automatic cancellation propagation by the task that called our `run()` method. |
| 59 | +The code below illustrates an overhauled implementation that exposes such a `run()` method: |
| 60 | + |
| 61 | +```swift |
| 62 | +public actor TCPEchoClient: Service { |
| 63 | + public init() { } |
| 64 | + |
| 65 | + public func run() async throws { |
| 66 | + for await _ in AsyncTimerSequence(interval: .seconds(1), clock: .continuous) { |
| 67 | + self.sendKeepAlivePings() |
| 68 | + } |
| 69 | + } |
| 70 | + |
| 71 | + private func sendKeepAlivePings() async { ... } |
| 72 | +} |
| 73 | +``` |
| 74 | + |
| 75 | +### Returning from your `run()` method |
| 76 | + |
| 77 | +Since the `run()` method contains long-running work, returning from it is interpreted as a failure and will lead to the ``ServiceGroup`` canceling all other services. |
| 78 | +Unless specified otherwise in |
| 79 | +``ServiceGroupConfiguration/ServiceConfiguration/successTerminationBehavior`` |
| 80 | +and |
| 81 | +``ServiceGroupConfiguration/ServiceConfiguration/failureTerminationBehavior``, |
| 82 | +each task started in its respective `run()` method will be canceled. |
| 83 | + |
| 84 | +### Cancellation |
| 85 | + |
| 86 | +Structured Concurrency propagates task cancellation down the task tree. |
| 87 | +Every task in the tree can check for cancellation or react to it with cancellation handlers. |
| 88 | +``ServiceGroup`` uses task cancellation to tear down everything when a service’s `run()` method returns early or throws an error. |
| 89 | +Hence, it is important that each service properly implements task cancellation in their `run()` methods. |
| 90 | + |
| 91 | +Note: If your `run()` method calls other async methods that support cancellation, or consumes an `AsyncSequence`, then you don't have to do anything explicitly. |
| 92 | +The latter is shown in the `TCPEchoClient` example above. |
| 93 | + |
| 94 | +### Graceful shutdown |
| 95 | + |
| 96 | +Applications are often required to be shut down gracefully when run in a real production environment. |
| 97 | + |
| 98 | +For example, the application might be deployed on Kubernetes and a new version got released. |
| 99 | +During a rollout of that new version, Kubernetes sends a `SIGTERM` signal to the application, expecting it to terminate within a grace period. |
| 100 | +If the application does not stop in time, then Kubernetes sends the `SIGKILL` signal and forcefully terminates the process. |
| 101 | +For this reason, ``ServiceLifecycle`` introduces the _shutdown gracefully_ concept that allows terminating an application’s work in a structured and graceful manner. |
| 102 | +This behavior is similar to task cancellation, but due to its opt-in nature, it is up to the business logic of the application to decide what to do. |
| 103 | + |
| 104 | +``ServiceLifecycle`` exposes one free function called ``withGracefulShutdownHandler(operation:onGracefulShutdown:)`` that works similarly to the `withTaskCancellationHandler` function from the Concurrency library. |
| 105 | +Library authors are expected to make sure that any work they spawn from the `run()` method properly supports graceful shutdown. |
| 106 | +For example, a server might close its listening socket to stop accepting new connections. |
| 107 | +It is of upmost importance that the server does not force the closure of any currently open connections. |
| 108 | +It is expected that the business logic behind these connections handles the graceful shutdown. |
| 109 | + |
| 110 | +An example high-level implementation of a `TCPEchoServer` with graceful shutdown |
| 111 | +support might look like this: |
| 112 | + |
| 113 | +```swift |
| 114 | +public actor TCPEchoServer: Service { |
| 115 | + public init() { } |
| 116 | + |
| 117 | + public func run() async throws { |
| 118 | + await withGracefulShutdownHandler { |
| 119 | + for connection in self.listeningSocket.connections { |
| 120 | + // Handle incoming connections |
| 121 | + } |
| 122 | + } onGracefulShutdown: { |
| 123 | + self.listeningSocket.close() |
| 124 | + } |
| 125 | + } |
| 126 | +} |
| 127 | +``` |
| 128 | + |
| 129 | +When the above code receives the graceful shutdown sequence, the only reasonable thing for `TCPEchoClient` to do is cancel the iteration of the timer sequence. |
| 130 | +``ServiceLifecycle`` provides a convenience on `AsyncSequence` to cancel on graceful shutdown. |
| 131 | +Let's take a look at how this works. |
| 132 | + |
| 133 | +```swift |
| 134 | +public actor TCPEchoClient: Service { |
| 135 | + public init() { } |
| 136 | + |
| 137 | + public func run() async throws { |
| 138 | + for await _ in AsyncTimerSequence(interval: .seconds(1), clock: .continuous).cancelOnGracefulShutdown() { |
| 139 | + self.sendKeepAlivePings() |
| 140 | + } |
| 141 | + } |
| 142 | + |
| 143 | + private func sendKeepAlivePings() async { ... } |
| 144 | +} |
| 145 | +``` |
| 146 | + |
| 147 | +As you can see in the code above, the additional `cancelOnGracefulShutdown()` call takes care of any downstream cancellation. |
0 commit comments