-
Notifications
You must be signed in to change notification settings - Fork 258
Structured concurrency for server applications #447
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,151 @@ | ||||||||||||||||||||||||||
| --- | ||||||||||||||||||||||||||
| layout: page | ||||||||||||||||||||||||||
| title: Using Structured Concurrency in server applications | ||||||||||||||||||||||||||
| --- | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| # Using Structured Concurrency in server applications | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| Swift Concurrency enables writing safe concurrent, asynchronous and data race | ||||||||||||||||||||||||||
| free code using native language features. Server systems are often highly | ||||||||||||||||||||||||||
| concurrent to handle many different connections at the same time. This makes | ||||||||||||||||||||||||||
| Swift Concurrency a perfect fit for use in server systems since it reduces the | ||||||||||||||||||||||||||
| cognitive burden to write correct concurrent systems while spreading the work | ||||||||||||||||||||||||||
| across all available cores. | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| Structured Concurrency allows the developer to organize their code into | ||||||||||||||||||||||||||
FranzBusch marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||||||||||||||||||||||
| high-level tasks and their child component tasks. These tasks are the primary | ||||||||||||||||||||||||||
| unit of concurrency and enable the flow of information up and down the task | ||||||||||||||||||||||||||
| hierarchy. | ||||||||||||||||||||||||||
FranzBusch marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| This guide covers best practices around how Swift Concurrency should be used in | ||||||||||||||||||||||||||
| server side applications and libraries. Importantly, this guide assumes a | ||||||||||||||||||||||||||
| _perfect_ world where all libraries and applications are fully bought into | ||||||||||||||||||||||||||
| Structured Concurrency. In reality, there are a lot of places where one has to | ||||||||||||||||||||||||||
| bridge currently unstructured systems. Depending on how those unstructured | ||||||||||||||||||||||||||
| systems are shaped there are various ways to bridge them (maybe include a | ||||||||||||||||||||||||||
| section in the with common patterns). The goal of this guide is to define a | ||||||||||||||||||||||||||
| target for the ecosystem which can be referred to when talking about the | ||||||||||||||||||||||||||
| architecture of libraries and applications. | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| ## Structuring your application | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| One can think of Structured Concurrency as a tree of task where the initial task | ||||||||||||||||||||||||||
FranzBusch marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||||||||||||||||||||||
| is rooted at the `main()` entry point of the application. From this entry point | ||||||||||||||||||||||||||
| onwards more and more child tasks are added to the tree to form the logical flow | ||||||||||||||||||||||||||
| of data in the application. Organizing the whole program into a single task tree | ||||||||||||||||||||||||||
| unlocks the full potential of Structured Concurrency such as: | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| - Automatic task cancellation propagation | ||||||||||||||||||||||||||
| - Propagation of task locals down the task tree | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| When looking at a typical application it is often comprised out of multiple | ||||||||||||||||||||||||||
| smaller components such as an HTTP server handling incoming traffic, | ||||||||||||||||||||||||||
FranzBusch marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||||||||||||||||||||||
| observability backends sending events to external systems, and clients to | ||||||||||||||||||||||||||
| databases. All of those components probably require one or more tasks to run | ||||||||||||||||||||||||||
| their work and those tasks should be child tasks of the application's main task | ||||||||||||||||||||||||||
| tree for the above-mentioned reasons. Broadly speaking libraries expose two | ||||||||||||||||||||||||||
| kinds of APIs short lived almost request response like APIs e.g. | ||||||||||||||||||||||||||
| `HTTPClient.get("http://example.com)` and long-lived APIs such as an HTTP server | ||||||||||||||||||||||||||
| that accepts inbound connections. In reality, libraries often expose both since | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
| tree for the above-mentioned reasons. Broadly speaking libraries expose two | |
| kinds of APIs short lived almost request response like APIs e.g. | |
| `HTTPClient.get("http://example.com)` and long-lived APIs such as an HTTP server | |
| that accepts inbound connections. In reality, libraries often expose both since | |
| tree for the above-mentioned reasons. Broadly speaking there are two | |
| kinds of tasks: | |
| 1. Short lived tasks, for example request-response like APIs, and | |
| 2. Long lived background tasks, for example an HTTP server | |
| that accepts inbound connections. | |
| Most libraries will use both since |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure if the more general approach leads to clear communication of our intend here:
For client requests, we have clear rules:
Everything that we not consider part of a client request we dispatch into the background task via AsyncSequence, whereas the original client request remains within whatever task the user scheduled the work in. for the background work we need a task root. This is the reason why we need a run() for clients.
For server tasks:
We need a task root to schedule the request-response handling in. This is a behavior that is contrary to clients.
--
This distinction is extremely important to ensure that cancellation works correctly in both cases.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Okay I have rewritten this part and actually added some code examples for a simplified HTTPClient and HTTPServer
FranzBusch marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
FranzBusch marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
FranzBusch marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
FranzBusch marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
FranzBusch marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
Outdated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since
deinits of classes and actors are run at
arbitrary times it becomes impossible to tell when resources created by those
unstructured tasks are released.
This makes it sound like a garbage collector, but ARC is deterministic, so maybe tweak the wording here to instead focus on the fact that it can be difficult to enforce cleanup at a specific time when a reference is shared between tasks. That said, this might not be important, for example if you have 5 identical worker tasks sharing a resource, and they're all shutting down, you might not care which one is the last one to shut down (and thus allow the resource to be deinited), so maybe add an example of where this is important.
FranzBusch marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
Outdated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure if in-scope for this guide, or if it should be a separate one, but once I have these structured tasks nicely set up, how do I communicate between them? More guidance on that topic would be great, especially how to wire up the async sequences (I presume) at task creation time, some patterns around that.
FranzBusch marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
FranzBusch marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
FranzBusch marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
FranzBusch marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
FranzBusch marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
FranzBusch marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
Outdated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Even more importantly, a non-copyable type can represent a resource like this, where you do have full control of when it's cleaned up, making the scope-based API unnecessary. If you're linking to non-escaping, might be worth linking to non-copyable as well.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Even with ~Copyable and ~Escapable types we still can't express deinit based resource clean up in all cases. Closing FDs or deleting VMs is an asynchronous action and deinits cannot be async at this time.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Understood, but "in all cases" is a very high bar. Non-copyable types help substantially over the status quo, by allowing a single owner known at compile time, who's responsible for managing the resource. When and how that owner chooses to free resources is orthogonal, but the important part is that you can achieve deterministic resource management without a with style API, which was my original point.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You still cannot. Anything that requires an asynchronous deinit cannot achieve deterministic resource management. Deterministic means at any point in your program you can tell when the resource is freed which works for simple things with ~Copyable but won't work for stuff like file descriptors, sockets or virtual machines.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure I follow. If I have a non-copyable type with a func close() async on it which must be called before deinit (otherwise a precondition fails), by looking at the code you know exactly when it's being freed, and when the freeing finished.
I'm not talking about deinit doing the work, I'm saying that the lack of non-copyable types meant that the only way to enforce a single known entity to free the resource was a with API. With non-copyable types, there is always exactly one owner of the value, who's responsible for freeing it (in any way that makes sense for the type, for sync types, it can be using deinit, for async types it can be using an explicit close, whatever).
I don't see any non-determinism here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Of course, async deinit would make this even more flexible, but it's not a blocker for using non-copyable types correctly even without with style APIs. (You still can, of course, it's just that now there's a second way.)
Outdated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
before we go into NIO land here, we should probably create something like how Server applications in structured concurrency:
withDiscardingTaskGroup { taskGroup in
for try await connection in server.newConnections {
taskGroup.addTask {
try await handleConnection(connection) // local reasoning... yay!
}
}
}There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
then you should probably explain how handConnection should consume the connections incoming messages as an AsyncSequence. Again. Local reasoning! All code is right there.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Once you have established this pattern, you can explain that a NIOAsyncChannel works exactly like your connection here. Then link to the docs.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have added an example above for the server which I am going to pick up here again
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actually just moved the whole section around task executors out. Let's keep it focused on structured concurrency.
Outdated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| Most of the Swift on Server ecosystem is build on top of | |
| Most of the Swift on Server ecosystem is built on top of |
Outdated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| [swift-nio](https://github.com/apple/swift-nio) - a high performant event-driven | |
| networking library. `NIO` has its own concurrency model that predates Swift | |
| [swift-nio](https://github.com/apple/swift-nio) - a high performance event-driven | |
| networking library. `NIO` has its own concurrency model that predates Swift |
Outdated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
NIO is a module, I think we should be calling it SwiftNIO here
Outdated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| context switches. Swift Concurrency is by default executing any non-isolated | |
| context switches. By default, Swift Concurrency executes any non-isolated |
Outdated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure I would mention this in this document even.
Uh oh!
There was an error while loading. Please reload this page.