|
| 1 | +--- |
| 2 | +layout: page |
| 3 | +title: Using Structured Concurrency in server applications |
| 4 | +--- |
| 5 | + |
| 6 | +# Using Structured Concurrency in server applications |
| 7 | + |
| 8 | +Swift Concurrency allows you to write safe concurrent, asynchronous and data |
| 9 | +race free code using native language features. Server systems are often highly |
| 10 | +concurrent to handle many different connections at the same time. This makes |
| 11 | +Swift Concurrency a perfect fit for use in server systems since it reduces the |
| 12 | +cognitive burden to write correct concurrent systems while spreading the work |
| 13 | +across all available cores. |
| 14 | + |
| 15 | +Structured Concurrency allows you to organize your code into high-level tasks |
| 16 | +and their child component tasks. These tasks are the primary unit of concurrency |
| 17 | +and enable the flow of information up and down the task hierarchy. Furthermore, |
| 18 | +child tasks enhance local reasoning since at the end of a method all child tasks |
| 19 | +must have finished. |
| 20 | + |
| 21 | +This guide covers best practices around how Swift Concurrency should be used in |
| 22 | +server side applications and libraries. Importantly, this guide assumes a |
| 23 | +_perfect_ world where all libraries and applications have fully bought into |
| 24 | +Structured Concurrency. The goal of this guide is to define a target for the |
| 25 | +ecosystem which can be referred to when talking about the architecture of |
| 26 | +libraries and applications. In reality, there are a lot of places where one has |
| 27 | +to bridge currently unstructured systems. Depending on how those unstructured |
| 28 | +systems are shaped there are various ways to bridge them. It is outside the |
| 29 | +scope of this guide to explain how to bridge unstructured systems. |
| 30 | + |
| 31 | +## Structuring your application |
| 32 | + |
| 33 | +You can think of Structured Concurrency as a tree of tasks where the initial |
| 34 | +task is rooted at the `main()` entry point of the application. From this entry |
| 35 | +point onwards more and more child tasks are added to the tree to form the |
| 36 | +logical flow of data in the application. Organizing the whole program into a |
| 37 | +single task tree unlocks the full potential of Structured Concurrency such as: |
| 38 | + |
| 39 | +- Automatic task cancellation propagation |
| 40 | +- Propagation of task locals down the task tree |
| 41 | + |
| 42 | +Applications are typically comprised out of multiple smaller components such as |
| 43 | +an HTTP server handling incoming traffic, observability backends sending events |
| 44 | +to external systems, and clients for databases. All of those components probably |
| 45 | +require one or more tasks to run their work and those tasks should be child |
| 46 | +tasks of the application's main task tree for the above-mentioned reasons. In |
| 47 | +general, those components can be split into two kinds - clients and servers. The |
| 48 | +task structure for clients and servers is slightly different but important to |
| 49 | +understand. |
| 50 | + |
| 51 | +Clients often have to manage a pool of connections to external resources. Those |
| 52 | +connections need to be owned by one task. At the same time, clients expose |
| 53 | +methods to execute requests on those connections. This requires communication |
| 54 | +between the request task and the task that owns the connection. Below is a |
| 55 | +simplified `HTTPClient` that shows this pattern: |
| 56 | + |
| 57 | +```swift |
| 58 | +final class HTTPClient: Sendable { |
| 59 | + /// An async sequence containing requests that need to be executed. |
| 60 | + private let requestStream: AsyncStream<(HTTPRequest, CheckedContinuation<HTTPResponse, Error>)> |
| 61 | + /// The continuation of the above async sequence. |
| 62 | + private let requestStreamContinuation: AsyncStream<(HTTPRequest, CheckedContinuation<HTTPResponse, Error>)>.Continuation |
| 63 | + /// A pool of connections. |
| 64 | + private let connectionPool: ConnectionPool |
| 65 | + |
| 66 | + public func run() async throws { |
| 67 | + await withDiscardingTaskGroup { group in |
| 68 | + group.addTask { |
| 69 | + // This runs the connection pool which will own the individual connections. |
| 70 | + await self.connectionPool.run() |
| 71 | + } |
| 72 | + |
| 73 | + // This consumes new incoming requests and dispatches them onto connections from the pool. |
| 74 | + for await (request, continuation) in workStream { |
| 75 | + group.addTask { |
| 76 | + await self.connectionPool.withConnection(for: request) { connection in |
| 77 | + do { |
| 78 | + let response = try await connection.execute(request) |
| 79 | + continuation.resume(returning: response) |
| 80 | + } catch { |
| 81 | + continuation.resume(throwing: error) |
| 82 | + } |
| 83 | + } |
| 84 | + } |
| 85 | + } |
| 86 | + } |
| 87 | + } |
| 88 | + |
| 89 | + public func execute(request: HTTPRequest) async throws -> HTTPResponse { |
| 90 | + try await withCheckedContinuation { continuation in |
| 91 | + self.requestStreamContinuation.yield((request, continuation)) |
| 92 | + } |
| 93 | + } |
| 94 | +} |
| 95 | +``` |
| 96 | + |
| 97 | +On the other hand, server are usually handling new incoming connections or |
| 98 | +requests and are dispatching them to user-defined handlers. In practice, this |
| 99 | +results in servers often just exposing a long lived `run()` that listens for new |
| 100 | +incoming work. Below is a simplified `HTTPServer`. |
| 101 | + |
| 102 | +```swift |
| 103 | +import NIO |
| 104 | + |
| 105 | +final class HTTPServer: Sendable { |
| 106 | + public func run() async throws { |
| 107 | + // This example uses SwiftNIO but simplifies its usage to |
| 108 | + // focus on server pattern and not too much on SwiftNIO itself. |
| 109 | + let serverChannel = ServerBootstrap().bind { ... } |
| 110 | + |
| 111 | + try await withDiscardingTaskGroup { group in |
| 112 | + try await serverChannel.executeThenClose { inbound in |
| 113 | + // This for await loop is handling the incoming connections |
| 114 | + for try await connectionChannel in serverChannel { |
| 115 | + // We are handling each connection in a separate child task so we can concurrently process each |
| 116 | + group.addTask { |
| 117 | + try await connectionChannel.executeThenClose { inbound, outbound in |
| 118 | + // Here we are just handling each incoming HTTP request per connection |
| 119 | + for try await httpRequest in inbound { |
| 120 | + // We are just going to print the request and reply with a status ok |
| 121 | + print("Received request", httpRequest) |
| 122 | + try await outbound.write(HTTPResponse(status: .ok)) |
| 123 | + } |
| 124 | + } |
| 125 | + } |
| 126 | + } |
| 127 | + } |
| 128 | + } |
| 129 | + } |
| 130 | +} |
| 131 | +``` |
| 132 | + |
| 133 | +In the above simplified examples, you can already see that both expose a `func |
| 134 | +run() async throws`. This is the recommended pattern for libraries to expose a |
| 135 | +single entry point where they can add their long-running background tasks. The |
| 136 | +expectation is that `run()` methods don't return until their parent task is |
| 137 | +cancelled or they receive a shutdown signal by some other means. Furthermore, |
| 138 | +throwing from a `run()` method is usually considered an unrecoverable problem |
| 139 | +and should often lead to the termination of the application. |
| 140 | + |
| 141 | +You may be tempted to handle background work by spawning unstructured tasks |
| 142 | +using the `Task.init(_:)` or `Task.detached(_:)`; however, this comes with |
| 143 | +significant downsides such as manual implementation of cancellation of those |
| 144 | +tasks or incorrect propagation of task locals. Moreover, unstructured tasks are |
| 145 | +often used in conjunction with `deinit`-based cleanup, i.e. the unstructured |
| 146 | +tasks are retained by the surrounding type and then cancelled in the `deinit`. |
| 147 | +Doing this is very brittle since those unstructured tasks are often also |
| 148 | +retaining the type which might also be shared across multiple other tasks. This |
| 149 | +makes it almost impossible to tell when those `deinit`s are actually run and |
| 150 | +when the underlying resources are cleaned up. The next section provides more |
| 151 | +details why controlled resource clean up is important and what patterns exist to |
| 152 | +model this with Structured Concurrency. |
| 153 | + |
| 154 | +By adopting the `run()` pattern you can make use of the [swift-service-lifecycle |
| 155 | +package](https://github.com/swift-server/swift-service-lifecycle) to structure |
| 156 | +your applications. It allows you to compose different services under a single |
| 157 | +top-level task while getting out-of-the-box support for signal handling allowing |
| 158 | +you to gracefully terminate services. |
| 159 | + |
| 160 | +The goal of structuring libraries and applications like this is enabling a |
| 161 | +seamless integration between them. Furthermore, since everything is inside the |
| 162 | +same task tree and task locals propagate down the tree we unlock new APIs in |
| 163 | +libraries such as `Swift Log` or `Swift Distributed Tracing`. |
| 164 | + |
| 165 | +After adopting this structure a common question that comes up is how to model |
| 166 | +communication between the various components. This can be achieved by: |
| 167 | +- Using dependency injection to pass one component to the other, or |
| 168 | +- Inverting the control between components using `AsyncSequence`s. |
| 169 | + |
| 170 | +## Resource management |
| 171 | + |
| 172 | +Applications often have to manage some kind of resource. Those could be file |
| 173 | +descriptors, sockets or something like virtual machines. Most resources need |
| 174 | +some kind of cleanup such as making a syscall to close a file descriptor or |
| 175 | +deleting a virtual machine. Resource management ties in closely with Structured |
| 176 | +Concurrency since it allows to express the lifetime of those resources in |
| 177 | +concurrent and asynchronous applications. The recommended pattern to provide |
| 178 | +access to resources is to use `with`-style methods such as the `func |
| 179 | +withTaskGroup(of:returning:body:)` method from the standard library. |
| 180 | +`with`-style methods allow to provide scoped access to a resource while making |
| 181 | +sure the resource is correctly cleaned up at the end of the scope. Even before |
| 182 | +Swift gained native concurrency support `with`-style methods were already used |
| 183 | +in the standard library for synchronous APIs like `withUnsafeBytes`. Below is an |
| 184 | +example method that provides scoped access to a file descriptor: |
| 185 | + |
| 186 | +```swift |
| 187 | +func withFileDescriptor<R>(_ body: (FileDescriptor) async throws -> R) async throws -> R { ... } |
| 188 | +``` |
| 189 | + |
| 190 | +Importantly, the file descriptor is only valid inside the `body` closure; hence, |
| 191 | +escaping the file descriptor in an unstructured task or into a background thread |
| 192 | +is not allowed. |
| 193 | + |
| 194 | +Again, one might be tempted to use unstructured concurrency to clean up the file |
| 195 | +descriptor in it's deinit like this: |
| 196 | + |
| 197 | +``` |
| 198 | +struct FileDescriptor: ~Copyable { |
| 199 | + private let fd: CInt |
| 200 | +
|
| 201 | + deinit { |
| 202 | + Task { |
| 203 | + await close(self.fd) |
| 204 | + } |
| 205 | + } |
| 206 | +} |
| 207 | +``` |
| 208 | + |
| 209 | + However, this will make your application incredibly hard to reason about and in |
| 210 | + the end might result in security issues since the closure of the file |
| 211 | + descriptor might be slightly delayed and it is impossible to know how many file |
| 212 | + descriptor are open at any given point of your application. |
0 commit comments