Skip to content

Commit ff98c93

Browse files
FranzBuschglbrnttLukasa
authored
Fix EventLoopFuture and EventLoopPromise under strict concurrency checking (#2654)
# Motivation We need to tackle the remaining strict concurrency checking related `Sendable` warnings in NIO. The first place to start is making sure that `EventLoopFuture` and `EventLoopPromise` are properly annotated. # Modification In a previous #2496, @weissi changed the `@unchecked Sendable` conformances of `EventLoopFuture/Promise` to be conditional on the sendability of the generic `Value` type. After having looked at all the APIs on the future and promise types as well as reading the latest Concurrency evolution proposals, specifically the [Region based Isolation](https://github.com/apple/swift-evolution/blob/main/proposals/0414-region-based-isolation.md), I came to the conclusion that the previous `@unchecked Sendable` annotations were correct. The reasoning for this is: 1. An `EventLoopPromise` and `EventLoopFuture` pair are tied to a specific `EventLoop` 2. An `EventLoop` represents an isolation region and values tied to its isolation are not allowed to be shared outside of it unless they are disconnected from the region 3. The `value` used to succeed a promise often come from outside the isolation domain of the `EventLoop` hence they must be transferred into the promise. 4. The isolation region of the event loop is enforced through `@Sendable` annotations on all closures that receive the value in some kind of transformation e.g. `map()` or `whenComplete()` 5. Any method on `EventLoopFuture` that combines itself with another future must require `Sendable` of the other futures `Value` since we cannot statically enforce that futures are bound to the same event loop i.e. to the same isolation domain Due to the above rules, this PR adds back the `@unchecked Sendable` conformances to both types. Furthermore, this PR revisits every single method on `EventLoopPromise/Future` and adds missing `Sendable` and `@Sendable` annotation where necessary to uphold the above rules. A few important things to call out: - Since `transferring` is currently not available this PR requires a `Sendable` conformance for some methods on `EventLoopPromise/Future` that should rather take a `transffering` argument - To enable the common case where a value from the same event loop is used to succeed a promise I added two additional methods that take a `eventLoopBoundResult` and enforce dynamic isolation checking. We might have to do this for more methods once we adopt those changes in other targets/packages. # Result After this PR has landed our lowest level building block should be inline with what the rest of the language enforces in Concurrency. The `EventLoopFuture.swift` produces no more warnings under strict concurrency checking on the latest 5.10 snapshots. --------- Co-authored-by: George Barnett <[email protected]> Co-authored-by: Cory Benfield <[email protected]>
1 parent 19da487 commit ff98c93

16 files changed

+612
-239
lines changed

Sources/NIOCore/AsyncAwaitSupport.swift

+8-3
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,9 @@ extension EventLoopFuture {
1818
/// This function can be used to bridge an `EventLoopFuture` into the `async` world. Ie. if you're in an `async`
1919
/// function and want to get the result of this future.
2020
@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *)
21+
@preconcurrency
2122
@inlinable
22-
public func get() async throws -> Value {
23+
public func get() async throws -> Value where Value: Sendable {
2324
try await withUnsafeThrowingContinuation { (cont: UnsafeContinuation<UnsafeTransfer<Value>, Error>) in
2425
self.whenComplete { result in
2526
switch result {
@@ -62,8 +63,11 @@ extension EventLoopPromise {
6263
/// - returns: A `Task` which was created to `await` the `body`.
6364
@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *)
6465
@discardableResult
66+
@preconcurrency
6567
@inlinable
66-
public func completeWithTask(_ body: @escaping @Sendable () async throws -> Value) -> Task<Void, Never> {
68+
public func completeWithTask(
69+
_ body: @escaping @Sendable () async throws -> Value
70+
) -> Task<Void, Never> where Value: Sendable {
6771
Task {
6872
do {
6973
let value = try await body()
@@ -396,8 +400,9 @@ struct AsyncSequenceFromIterator<AsyncIterator: AsyncIteratorProtocol>: AsyncSeq
396400

397401
@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *)
398402
extension EventLoop {
403+
@preconcurrency
399404
@inlinable
400-
public func makeFutureWithTask<Return>(
405+
public func makeFutureWithTask<Return: Sendable>(
401406
_ body: @Sendable @escaping () async throws -> Return
402407
) -> EventLoopFuture<Return> {
403408
let promise = self.makePromise(of: Return.self)

Sources/NIOCore/ChannelPipeline.swift

+6-6
Original file line numberDiff line numberDiff line change
@@ -457,10 +457,10 @@ public final class ChannelPipeline: ChannelInvoker {
457457
let promise = self.eventLoop.makePromise(of: ChannelHandlerContext.self)
458458

459459
if self.eventLoop.inEventLoop {
460-
promise.completeWith(self.contextSync(handler: handler))
460+
promise.assumeIsolated().completeWith(self.contextSync(handler: handler))
461461
} else {
462462
self.eventLoop.execute {
463-
promise.completeWith(self.contextSync(handler: handler))
463+
promise.assumeIsolated().completeWith(self.contextSync(handler: handler))
464464
}
465465
}
466466

@@ -486,10 +486,10 @@ public final class ChannelPipeline: ChannelInvoker {
486486
let promise = self.eventLoop.makePromise(of: ChannelHandlerContext.self)
487487

488488
if self.eventLoop.inEventLoop {
489-
promise.completeWith(self.contextSync(name: name))
489+
promise.assumeIsolated().completeWith(self.contextSync(name: name))
490490
} else {
491491
self.eventLoop.execute {
492-
promise.completeWith(self.contextSync(name: name))
492+
promise.assumeIsolated().completeWith(self.contextSync(name: name))
493493
}
494494
}
495495

@@ -519,10 +519,10 @@ public final class ChannelPipeline: ChannelInvoker {
519519
let promise = self.eventLoop.makePromise(of: ChannelHandlerContext.self)
520520

521521
if self.eventLoop.inEventLoop {
522-
promise.completeWith(self._contextSync(handlerType: handlerType))
522+
promise.assumeIsolated().completeWith(self._contextSync(handlerType: handlerType))
523523
} else {
524524
self.eventLoop.execute {
525-
promise.completeWith(self._contextSync(handlerType: handlerType))
525+
promise.assumeIsolated().completeWith(self._contextSync(handlerType: handlerType))
526526
}
527527
}
528528

Sources/NIOCore/DispatchQueue+WithFuture.swift

+3-2
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,10 @@ extension DispatchQueue {
2929
/// - callbackMayBlock: The scheduled callback for the IO / task.
3030
/// - returns a new `EventLoopFuture<ReturnType>` with value returned by the `block` parameter.
3131
@inlinable
32-
public func asyncWithFuture<NewValue>(
32+
@preconcurrency
33+
public func asyncWithFuture<NewValue: Sendable>(
3334
eventLoop: EventLoop,
34-
_ callbackMayBlock: @escaping () throws -> NewValue
35+
_ callbackMayBlock: @escaping @Sendable () throws -> NewValue
3536
) -> EventLoopFuture<NewValue> {
3637
let promise = eventLoop.makePromise(of: NewValue.self)
3738

Sources/NIOCore/Docs.docc/index.md

+1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ More specialized modules provide concrete implementations of many of the abstrac
1515

1616
- <doc:swift-concurrency>
1717
- <doc:ByteBuffer-lengthPrefix>
18+
- <doc:loops-futures-concurrency>
1819

1920
### Event Loops and Event Loop Groups
2021

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
# EventLoops, EventLoopFutures, and Swift Concurrency
2+
3+
This article aims to communicate how NIO's ``EventLoop``s and ``EventLoopFuture``s interact with the Swift 6
4+
concurrency model, particularly regarding data-race safety. It aims to be a reference for writing correct
5+
concurrent code in the NIO model.
6+
7+
NIO predates the Swift concurrency model. As a result, several of NIO's concepts are not perfect matches to
8+
the concepts that Swift uses, or have overlapping responsibilities.
9+
10+
## Isolation domains and executors
11+
12+
First, a quick recap. The core of Swift 6's data-race safety protection is the concept of an "isolation
13+
domain". Some valuable reading regarding the concept can be found in
14+
[SE-0414 (Region based isolation)](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0414-region-based-isolation.md)
15+
but at a high level an isolation domain can be understood to be a collection of state and methods within which there cannot be
16+
multiple executors executing code at the same time.
17+
18+
In standard Swift Concurrency, the main boundaries of isolation domains are actors and tasks. Each actor,
19+
including global actors, defines an isolation domain. Additionally, for functions and methods that are
20+
not isolated to an actor, the `Task` within which that code executes defines an isolation domain. Passing
21+
values between these isolation domains requires that these values are either `Sendable` (safe to hold in
22+
multiple domains), or that the `sending` keyword is used to force the value to be passed from one domain
23+
to another.
24+
25+
A related concept to an "isolation domain" is an "executor". Again, useful reading can be found in
26+
[SE-0392 (Custom actor executors)](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0392-custom-actor-executors.md).
27+
At a high level, an executor is simply an object that is capable of executing Swift `Task`s. Executors can be
28+
concurrent, or they can be serial. Serial executors are the most common, as they can be used to back an
29+
actor.
30+
31+
## Event Loops
32+
33+
NIO's core execution primitive is the ``EventLoop``. An ``EventLoop`` is fundamentally nothing more than
34+
a Swift Concurrency Serial Executor that can also perform I/O operations directly. Indeed, NIO's
35+
``EventLoop``s can be exposed as serial executors, using ``EventLoop/executor``. This provides a mechanism
36+
to protect actor-isolated state using a NIO event-loop. With [the introduction of task executors](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0417-task-executor-preference.md),
37+
future versions of SwiftNIO will also be able to offer their event loops for individual `Task`s to execute
38+
on as well.
39+
40+
In a Swift 6 world, it is possible that these would be the API that NIO offered to execute tasks on the
41+
loop. However, as NIO predates Swift 6, it also offers its own set of APIs to enqueue work. This includes
42+
(but is not limited to):
43+
44+
- ``EventLoop/execute(_:)``
45+
- ``EventLoop/submit(_:)``
46+
- ``EventLoop/scheduleTask(in:_:)``
47+
- ``EventLoop/scheduleRepeatedTask(initialDelay:delay:notifying:_:)``
48+
- ``EventLoop/scheduleCallback(at:handler:)-2xm6l``
49+
50+
The existence of these APIs requires us to also ask the question of where the submitted code executes. The
51+
answer is that the submitted code executes on the event loop (or, in Swift Concurrency terms, on the
52+
executor provided by the event loop).
53+
54+
As the event loop only ever executes a single item of work (either an `async` function or one of the
55+
closures above) at a time, it is a _serial_ executor. It also provides an _isolation domain_: code
56+
submitted to a given `EventLoop` never runs in parallel with other code submitted to the same loop.
57+
58+
The result here is that a all closures passed into the event loop to do work must be transferred
59+
in: they may not be kept hold of outside of the event loop. That means they must be sent using
60+
the `sending` keyword.
61+
62+
> Note: As of the current 2.75.0 release, NIO enforces the stricter requirement that these closures
63+
are `@Sendable`. This is not a long-term position, but reflects the need to continue
64+
to support Swift 5 code which requires this stricter standard. In a future release of
65+
SwiftNIO we expect to relax this constraint: if you need this relaxed constraint
66+
then please file an issue.
67+
68+
## Event loop futures
69+
70+
In Swift NIO the most common mechanism to arrange a series of asynchronous work items is
71+
_not_ to queue up a series of ``EventLoop/execute(_:)`` calls. Instead, users typically
72+
use ``EventLoopFuture``.
73+
74+
``EventLoopFuture`` has some extensive semantics documented in its API documentation. The
75+
most important principal for this discussion is that all callbacks added to an
76+
``EventLoopFuture`` will execute on the ``EventLoop`` to which that ``EventLoopFuture`` is
77+
bound. By extension, then, all callbacks added to an ``EventLoopFuture`` execute on the same
78+
executor (the ``EventLoop``) in the same isolation domain.
79+
80+
The analogy to an actor here is hopefully fairly clear. Conceptually, an ``EventLoopFuture``
81+
could be modelled as an actor. That means all the callbacks have the same logical semantics:
82+
the ``EventLoopFuture`` uses the isolation domain of its associated ``EventLoop``, and all
83+
the callbacks are `sent` into the isolation domain. To that end, all the callback-taking APIs
84+
require that the callback is sent using `sending` into the ``EventLoopFuture``.
85+
86+
> Note: As of the current 2.75.0 release, NIO enforces the stricter requirement that these callbacks
87+
are `@Sendable`. This is not a long-term position, but reflects the need to continue
88+
to support Swift 5 code which requires this stricter standard. In a future release of
89+
SwiftNIO we expect to relax this constraint: if you need this relaxed constraint
90+
then please file an issue.
91+
92+
Unlike ``EventLoop``s, however, ``EventLoopFuture``s also have value-receiving and value-taking
93+
APIs. This is because ``EventLoopFuture``s pass a value along to their various callbacks, and
94+
so need to be both given an initial value (via an ``EventLoopPromise``) and in some cases to
95+
extract that value from the ``EventLoopFuture`` wrapper.
96+
97+
This implies that ``EventLoopPromise``'s various success functions
98+
(_and_ ``EventLoop/makeSucceededFuture(_:)``) need to take their value as `sending`. The value
99+
is potentially sent from its current isolation domain into the ``EventLoop``, which will require
100+
that the value is safe to move.
101+
102+
> Note: As of the current 2.75.0 release, NIO enforces the stricter requirement that these values
103+
are `Sendable`. This is not a long-term position, but reflects the need to continue
104+
to support Swift 5 code which requires this stricter standard. In a future release of
105+
SwiftNIO we expect to relax this constraint: if you need this relaxed constraint
106+
then please file an issue.
107+
108+
There are also a few ways to extract a value, such as ``EventLoopFuture/wait(file:line:)``
109+
and ``EventLoopFuture/get()``. These APIs can only safely be called when the ``EventLoopFuture``
110+
is carrying a `Sendable` value. This is because ``EventLoopFuture``s hold on to their value and
111+
can give it to other closures or other callers of `get` and `wait`. Thus, `sending` is not
112+
sufficient.
113+
114+
## Combining Futures
115+
116+
NIO provides a number of APIs for combining futures, such as ``EventLoopFuture/and(_:)``.
117+
This potentially represents an issue, as two futures may not share the same isolation domain.
118+
As a result, we can only safely call these combining functions when the ``EventLoopFuture``
119+
values are `Sendable`.
120+
121+
> Note: We can conceptually relax this constraint somewhat by offering equivalent
122+
functions that can only safely be called when all the combined futures share the
123+
same bound event loop: that is, when they are all within the same isolation domain.
124+
125+
This can be enforced with runtime isolation checks. If you have a need for these
126+
functions, please reach out to the NIO team.
127+
128+
## Interacting with Futures on the Event Loop
129+
130+
In a number of contexts (such as in ``ChannelHandler``s), the programmer has static knowledge
131+
that they are within an isolation domain. That isolation domain may well be shared with the
132+
isolation domain of many futures and promises with which they interact. For example,
133+
futures that are provided from ``ChannelHandlerContext/write(_:promise:)`` will be bound to
134+
the event loop on which the ``ChannelHandler`` resides.
135+
136+
In this context, the `sending` constraint is unnecessarily strict. The future callbacks are
137+
guaranteed to fire on the same isolation domain as the ``ChannelHandlerContext``: no risk
138+
of data race is present. However, Swift Concurrency cannot guarantee this at compile time,
139+
as the specific isolation domain is determined only at runtime.
140+
141+
In these contexts, today users can make their callbacks safe using ``NIOLoopBound`` and
142+
``NIOLoopBoundBox``. These values can only be constructed on the event loop, and only allow
143+
access to their values on the same event loop. These constraints are enforced at runtime,
144+
so at compile time these are unconditionally `Sendable`.
145+
146+
> Warning: ``NIOLoopBound`` and ``NIOLoopBoundBox`` replace compile-time isolation checks
147+
with runtime ones. This makes it possible to introduce crashes in your code. Please
148+
ensure that you are 100% confident that the isolation domains align. If you are not
149+
sure that the ``EventLoopFuture`` you wish to attach a callback to is bound to your
150+
``EventLoop``, use ``EventLoopFuture/hop(to:)`` to move it to your isolation domain
151+
before using these types.
152+
153+
> Note: In a future NIO release we intend to improve the ergonomics of this common problem
154+
by offering a related type that can only be created from an ``EventLoopFuture`` on a
155+
given ``EventLoop``. This minimises the number of runtime checks, and will make it
156+
easier and more pleasant to write this kind of code.
157+
158+
## Interacting with Event Loops on the Event Loop
159+
160+
As with Futures, there are occasionally times where it is necessary to schedule
161+
``EventLoop`` operations on the ``EventLoop`` where your code is currently executing.
162+
163+
Much like with ``EventLoopFuture``, you can use ``NIOLoopBound`` and ``NIOLoopBoundBox``
164+
to make these callbacks safe.
165+
166+
> Warning: ``NIOLoopBound`` and ``NIOLoopBoundBox`` replace compile-time isolation checks
167+
with runtime ones. This makes it possible to introduce crashes in your code. Please
168+
ensure that you are 100% confident that the isolation domains align. If you are not
169+
sure that the ``EventLoopFuture`` you wish to attach a callback to is bound to your
170+
``EventLoop``, use ``EventLoopFuture/hop(to:)`` to move it to your isolation domain
171+
before using these types.
172+
173+
> Note: In a future NIO release we intend to improve the ergonomics of this common problem
174+
by offering a related type that can only be created from an ``EventLoopFuture`` on a
175+
given ``EventLoop``. This minimises the number of runtime checks, and will make it
176+
easier and more pleasant to write this kind of code.

Sources/NIOCore/EventLoop+Deprecated.swift

+2-1
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,10 @@ extension EventLoop {
2323
self.makeFailedFuture(error)
2424
}
2525

26+
@preconcurrency
2627
@inlinable
2728
@available(*, deprecated, message: "Please don't pass file:line:, there's no point.")
28-
public func makeSucceededFuture<Success>(
29+
public func makeSucceededFuture<Success: Sendable>(
2930
_ value: Success,
3031
file: StaticString = #fileID,
3132
line: UInt = #line

0 commit comments

Comments
 (0)