Skip to content

Commit acd2fc7

Browse files
committed
Structured concurrency for server applications
1 parent 7e5d477 commit acd2fc7

File tree

1 file changed

+151
-0
lines changed

1 file changed

+151
-0
lines changed

server/guides/concurrency.md

+151
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
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 enables writing safe concurrent, asynchronous and data race
9+
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 the developer to organize their code into
16+
high-level tasks and their child component tasks. These tasks are the primary
17+
unit of concurrency and enable the flow of information up and down the task
18+
hierarchy.
19+
20+
This guide covers best practices around how Swift Concurrency should be used in
21+
server side applications and libraries. Importantly, this guide assumes a
22+
_perfect_ world where all libraries and applications are fully bought into
23+
Structured Concurrency. In reality, there are a lot of places where one has to
24+
bridge currently unstructured systems. Depending on how those unstructured
25+
systems are shaped there are various ways to bridge them (maybe include a
26+
section in the with common patterns). The goal of this guide is to define a
27+
target for the ecosystem which can be referred to when talking about the
28+
architecture of libraries and applications.
29+
30+
## Structuring your application
31+
32+
One can think of Structured Concurrency as a tree of task where the initial task
33+
is rooted at the `main()` entry point of the application. From this entry point
34+
onwards more and more child tasks are added to the tree to form the logical flow
35+
of data in the application. Organizing the whole program into a single task tree
36+
unlocks the full potential of Structured Concurrency such as:
37+
38+
- Automatic task cancellation propagation
39+
- Propagation of task locals down the task tree
40+
41+
When looking at a typical application it is often comprised out of multiple
42+
smaller components such as an HTTP server handling incoming traffic,
43+
observability backends sending events to external systems, and clients to
44+
databases. All of those components probably require one or more tasks to run
45+
their work and those tasks should be child tasks of the application's main task
46+
tree for the above-mentioned reasons. Broadly speaking libraries expose two
47+
kinds of APIs short lived almost request response like APIs e.g.
48+
`HTTPClient.get("http://example.com)` and long-lived APIs such as an HTTP server
49+
that accepts inbound connections. In reality, libraries often expose both since
50+
they need to have some long-lived connections and then request-response like
51+
behavior to interact with those connections, e.g. an `HTTPClient` will have a
52+
pool of connections and then dispatch requests onto them.
53+
54+
The recommended pattern for those components and libraries is to expose a `func
55+
run() async throws` method on their types, such as an `HTTPClient`, inside this
56+
`run()` method libraries can schedule their long running work and spawn as many
57+
child tasks as they need. It is expected that those `run()` methods are often
58+
not returning until their parent task is cancelled or they receive a shutdown
59+
signal through some other mean. The other way that libraries could handle their
60+
long running work is by spawning unstructured tasks using the `Task.init(_:)` or
61+
`Task.detached(_:)`; however, this comes with significant downsides such as
62+
manual implementation of cancellation of those tasks or incorrect propagation of
63+
task locals. Moreover, unstructured tasks are often used in conjunction with
64+
`deinit`-based cleanup, i.e. the unstructured tasks are retained by the type and
65+
then cancelled in the `deinit`. Since `deinit`s of classes and actors are run at
66+
arbitrary times it becomes impossible to tell when resources created by those
67+
unstructured tasks are released. Since, the `run()` method pattern has come up a
68+
lot while migrating more libraries to take advantage of Structured Concurrency
69+
which lead the SSWG to update the [swift-service-lifecycle
70+
package](https://github.com/swift-server/swift-service-lifecycle) to embrace
71+
this pattern. In general, the SSWG recommends to adopt `ServiceLifecycle` and
72+
conform types of libraries to the `Service` protocol which makes it easier for
73+
application developers to orchestrate the various components that form the
74+
application's business logic. `ServiceLifecycle` provides the `ServiceGroup`
75+
which allows developers to orchestrate a number of `Service`s. Additionally, the
76+
`ServiceGroup` has built-in support for signal handling to implement a graceful
77+
shutdown of applications. Gracefully shutting down applications is often
78+
required in modern cloud environments during roll out of new application
79+
versions or infrastructure reformations.
80+
81+
The goal of structuring libraries and applications like this is enabling a
82+
seamless integration between them and have a coherent interface across the
83+
ecosystem which makes adding new components to applications as easy as possible.
84+
Furthermore, since everything is inside the same task tree and task locals
85+
propagate down the tree we unlock new APIs in libraries such as `swift-log` or
86+
`swift-tracing`.
87+
88+
After adopting this structure a common question question that comes up is how to
89+
model communication between the various components. This can be achieved in a
90+
couple of ways. Either by using dependency injection to pass one component to
91+
the other or by inverting the control between components using `AsyncSequence`s.
92+
93+
## Resource management
94+
95+
Applications often have to manage some kind of resource. Those could be file
96+
descriptors, sockets or something like virtual machines. Most resources need
97+
some kind of cleanup such as making a syscall to close a file descriptor or
98+
deleting a virtual machine. Resource management ties in closely with Structured
99+
Concurrency since it allows to express the lifetime of those resources in
100+
concurrent and asynchronous applications. The recommended pattern to provide
101+
access to resources is to use `with`-style methods such as the `func
102+
withTaskGroup(of:returning:body:)` method from the standard library.
103+
`with`-style methods allow to provide scoped access to a resource while making
104+
sure the resource is currently cleaned up at the end of the scope. For example a
105+
method providing scoped access to a file descriptor might look like this:
106+
107+
```swift
108+
func withFileDescriptor<R>(_ body: (FileDescriptor) async throws -> R) async throws -> R { ... }
109+
```
110+
111+
Importantly, the file descriptor is only valid inside the `body` closure; hence,
112+
escaping the file descriptor in an unstructured task or into a background thread
113+
is not allowed.
114+
115+
> Note: With future language features such as `~Escapable` types it might be
116+
possible to encode this constraint in the language itself.
117+
118+
## Task executors in Swift on Server applications
119+
120+
Most of the Swift on Server ecosystem is build on top of
121+
[swift-nio](https://github.com/apple/swift-nio) - a high performant event-driven
122+
networking library. `NIO` has its own concurrency model that predates Swift
123+
Concurrency; however, `NIO` offers the `NIOAsyncChannel` abstraction to bridge a
124+
`Channel` into a construct that can be interacted with from Swift Concurrency.
125+
One of the goals of the `NIOAsyncChannel`, is to enable developers to implement
126+
their business logic using Swift Concurrency instead of using `NIO`s lower level
127+
`Channel` and `ChannelHandler` types, hence, making `NIO` an implementation
128+
detail. You can read more about the `NIOAsyncChannel` in [NIO's Concurrency
129+
documentation](https://swiftpackageindex.com/apple/swift-nio/2.61.1/documentation/niocore/swift-concurrency).
130+
131+
Highly performant server application often rely on handling incoming connections
132+
and requests almost synchronously without incurring unnecessary allocations or
133+
context switches. Swift Concurrency is by default executing any non-isolated
134+
method on the global concurrent executor. On the other hand, `NIO` has its own
135+
thread pool in the shape of an `EventLoopGroup`. `NIO` picks an `EventLoop` out
136+
of the `EventLoopGroup` for any `Channel` and executes all of the `Channel`s IO
137+
on that `EventLoop`. When bridging from `NIO` into Swift Concurrency by default
138+
the execution has to context switch between the `Channel`s `EventLoop` and one
139+
of the threads of the global concurrent executor. To avoid this context switch
140+
Swift Concurrency introduced the concept of preferred task executors in
141+
[SE-XXX](). When interacting with the `NIOAsyncChannel` the preferred task
142+
executor can be set to the `Channel`s `EventLoop`. If this is beneficial or
143+
disadvantageous for the performance of the application depends on a couple of
144+
factors:
145+
146+
- How computationally intensive is the logic executed in Swift Concurrency?
147+
- Does the logic make any asynchronous out calls?
148+
- How many cores does that application have available?
149+
150+
In the end, each application needs to measure its performance and understand if
151+
setting a preferred task executor is beneficial.

0 commit comments

Comments
 (0)