Skip to content

Commit 7991382

Browse files
committed
Review feedback
1 parent acd2fc7 commit 7991382

File tree

2 files changed

+212
-151
lines changed

2 files changed

+212
-151
lines changed

server/guides/concurrency.md

-151
This file was deleted.
+212
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
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. Those connections need to be
52+
owned by one task. At the same time, clients expose methods to execute requests
53+
on those connections. This requires communication between the request task and
54+
the task that owns the connection. Below is a simplified `HTTPClient` that shows
55+
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. 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 returning 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 type and then cancelled in the `deinit`. Doing this is
147+
very brittle since those unstructured tasks are often also retaining the type
148+
which might also be shared across multiple other tasks. This makes it almost
149+
impossible to tell when those `deinit`s are actually run and when the underlying
150+
resources are cleaned up. The next section provides more details why controlled
151+
resource clean up is important and what patterns exist to model this with
152+
Structured Concurrency.
153+
154+
By adopting the `run()` pattern you can make use of [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 here one might be tempted to use unstructured concurrency to clean up the
195+
file 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

Comments
 (0)