Skip to content
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

Fix EventLoopFuture and EventLoopPromise under strict concurrency checking #2654

Merged
merged 18 commits into from
Oct 18, 2024
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions Sources/NIOCore/AsyncAwaitSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ extension EventLoopFuture {
/// This function can be used to bridge an `EventLoopFuture` into the `async` world. Ie. if you're in an `async`
/// function and want to get the result of this future.
@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *)
@preconcurrency
@inlinable
public func get() async throws -> Value {
public func get() async throws -> Value where Value: Sendable {
try await withUnsafeThrowingContinuation { (cont: UnsafeContinuation<UnsafeTransfer<Value>, Error>) in
self.whenComplete { result in
switch result {
Expand Down Expand Up @@ -62,8 +63,11 @@ extension EventLoopPromise {
/// - returns: A `Task` which was created to `await` the `body`.
@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *)
@discardableResult
@preconcurrency
@inlinable
public func completeWithTask(_ body: @escaping @Sendable () async throws -> Value) -> Task<Void, Never> {
public func completeWithTask(
_ body: @escaping @Sendable () async throws -> Value
weissi marked this conversation as resolved.
Show resolved Hide resolved
) -> Task<Void, Never> where Value: Sendable {
Task {
do {
let value = try await body()
Expand Down Expand Up @@ -396,8 +400,9 @@ struct AsyncSequenceFromIterator<AsyncIterator: AsyncIteratorProtocol>: AsyncSeq

@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *)
extension EventLoop {
@preconcurrency
@inlinable
public func makeFutureWithTask<Return>(
public func makeFutureWithTask<Return: Sendable>(
_ body: @Sendable @escaping () async throws -> Return
) -> EventLoopFuture<Return> {
let promise = self.makePromise(of: Return.self)
Expand Down
12 changes: 6 additions & 6 deletions Sources/NIOCore/ChannelPipeline.swift
Original file line number Diff line number Diff line change
Expand Up @@ -457,10 +457,10 @@ public final class ChannelPipeline: ChannelInvoker {
let promise = self.eventLoop.makePromise(of: ChannelHandlerContext.self)

if self.eventLoop.inEventLoop {
promise.completeWith(self.contextSync(handler: handler))
promise.assumeIsolated().completeWith(self.contextSync(handler: handler))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

assumeIsolated() is a noop in release, right?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right now it is but it needs to change. It uses asserts to check but we need to upgrade this preconditions since check at creation time is not enough similar to loop bound. Here is an example where just a precondition on creation of the assumeIsolated would fail

// On the EL
let assumeIsolated = someFuture.assumeIsolated()
assumeIsolated.wait() // Fine without a compiler error since it is no longer required to be Sendable. Also no runtime crash

await withTaskExecutorPreference(someRandomExec) {
  assumeIsolated.wait() // No compiler error but runtime crash now
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@FranzBusch the line above is if self.eventLoop.inEventLoop(), so the assumeIsolated() is duplicated.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah sorry, I thought you were asking in a more general term. In this particular case I don't know if the compiler is able to optimise the precondition away.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, it doesn't do that sort of thing. If we're not sure that this is a noop, we should remove it here. Already not super cheap to check it

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or do assertIsolated not preconditionIsolated

} else {
self.eventLoop.execute {
promise.completeWith(self.contextSync(handler: handler))
promise.assumeIsolated().completeWith(self.contextSync(handler: handler))
}
}

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

if self.eventLoop.inEventLoop {
promise.completeWith(self.contextSync(name: name))
promise.assumeIsolated().completeWith(self.contextSync(name: name))
} else {
self.eventLoop.execute {
promise.completeWith(self.contextSync(name: name))
promise.assumeIsolated().completeWith(self.contextSync(name: name))
}
}

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

if self.eventLoop.inEventLoop {
promise.completeWith(self._contextSync(handlerType: handlerType))
promise.assumeIsolated().completeWith(self._contextSync(handlerType: handlerType))
} else {
self.eventLoop.execute {
promise.completeWith(self._contextSync(handlerType: handlerType))
promise.assumeIsolated().completeWith(self._contextSync(handlerType: handlerType))
}
}

Expand Down
5 changes: 3 additions & 2 deletions Sources/NIOCore/DispatchQueue+WithFuture.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,10 @@ extension DispatchQueue {
/// - callbackMayBlock: The scheduled callback for the IO / task.
/// - returns a new `EventLoopFuture<ReturnType>` with value returned by the `block` parameter.
@inlinable
public func asyncWithFuture<NewValue>(
@preconcurrency
public func asyncWithFuture<NewValue: Sendable>(
eventLoop: EventLoop,
_ callbackMayBlock: @escaping () throws -> NewValue
_ callbackMayBlock: @escaping @Sendable () throws -> NewValue
) -> EventLoopFuture<NewValue> {
let promise = eventLoop.makePromise(of: NewValue.self)

Expand Down
1 change: 1 addition & 0 deletions Sources/NIOCore/Docs.docc/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ More specialized modules provide concrete implementations of many of the abstrac

- <doc:swift-concurrency>
- <doc:ByteBuffer-lengthPrefix>
- <doc:loops-futures-concurrency>

### Event Loops and Event Loop Groups

Expand Down
176 changes: 176 additions & 0 deletions Sources/NIOCore/Docs.docc/loops-futures-concurrency.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
# EventLoops, EventLoopFutures, and Swift Concurrency

This article aims to communicate how NIO's ``EventLoop``s and ``EventLoopFuture``s interact with the Swift 6
concurrency model, particularly regarding data-race safety. It aims to be a reference for writing correct
concurrent code in the NIO model.

NIO predates the Swift concurrency model. As a result, several of NIO's concepts are not perfect matches to
the concepts that Swift uses, or have overlapping responsibilities.

## Isolation domains and executors

First, a quick recap. The core of Swift 6's data-race safety protection is the concept of an "isolation
domain". Some valuable reading regarding the concept can be found in
[SE-0414 (Region based isolation)](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0414-region-based-isolation.md)
but at a high level an isolation domain can be understood to be a region within which there cannot be
Lukasa marked this conversation as resolved.
Show resolved Hide resolved
multiple executors executing code at the same time.

In standard Swift Concurrency, the main boundaries of isolation regions are actors and tasks. Each actor,
Lukasa marked this conversation as resolved.
Show resolved Hide resolved
including global actors, defines an isolation domain. Additionally, for functions and methods that are
not isolated to an actor, the `Task` within which that code executes defines an isolation domain. Passing
values between these isolation domains requires that these values are either `Sendable` (safe to hold in
multiple domains), or that the `sending` keyword is used to force the value to be passed from one domain
to another.

A related concept to an "isolation domain" is an "executor". Again, useful reading can be found in
[SE-0392 (Custom actor executors)](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0392-custom-actor-executors.md).
At a high level, an executor is simply an object that is capable of executing Swift `Task`s. Executors can be
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably a nit but I'm not sure if Task is 100% correct here. Like there's no way to retrieve a let theTask: Task from a child task. But then, child tasks still ask Task.isCancelled so maybe it is correct :).

I would've said that they can run Jobs but then that's not really terminology that's actually used. Probably fine

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried for a while to rewrite this and couldn't come up with anything I liked. If we think of anything better we can change it.

concurrent, or they can be serial. Serial executors are the most common, as they can be used to back an
actor.

## Event Loops

NIO's core execution primitive is the ``EventLoop``. An ``EventLoop`` is fundamentally nothing more than
a Swift Concurrency Serial Executor that can also perform I/O operations directly. Indeed, NIO's
``EventLoop``s can be exposed as serial executors, using ``EventLoop/executor``. This provides a mechanism
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),
future versions of SwiftNIO will also be able to offer their event loops for individual `Task`s to execute
Lukasa marked this conversation as resolved.
Show resolved Hide resolved
on as well.

In a Swift 6 world, it is possible that these would be the API that NIO offered to execute tasks on the
loop. However, as NIO predates Swift 6, it also offers its own set of APIs to enqueue work. This includes
(but is not limited to):

- ``EventLoop/execute(_:)``
- ``EventLoop/submit(_:)``
- ``EventLoop/scheduleTask(in:_:)``
- ``EventLoop/scheduleRepeatedTask(initialDelay:delay:notifying:_:)``
- ``EventLoop/scheduleCallback(at:handler:)-2xm6l``

The existence of these APIs requires us to also ask the question of where the submitted code executes. The
answer is that the submitted code executes on the event loop (or, in Swift Concurrency terms, on the
executor provided by the event loop).

As the event loop only ever executes a single item of work (either an `async` function or one of the
closures above) at a time, it is a _serial_ executor. It also provides an _isolation domain_: code
submitted to a given `EventLoop` never runs in parallel with other code submitted to the same loop.

The result here is that a all closures passed into the event loop to do work must be transferred
in: they may not be kept hold of outside of the event loop. That means they must be sent using
the `sending` keyword.

> Note: As of the current 2.75.0 release, NIO enforces the stricter requirement that these closures
are `@Sendable`. This is not a long-term position, but reflects the need to continue
to support Swift 5 code which requires this stricter standard. In a future release of
SwiftNIO we expect to relax this constraint: if you need this relaxed constraint
then please file an issue.

## Event loop futures

In Swift NIO the most common mechanism to arrange a series of asynchronous work items is
_not_ to queue up a series of ``EventLoop/execute(_:)`` calls. Instead, users typically
use ``EventLoopFuture``.

``EventLoopFuture`` has some extensive semantics documented in its API documentation. The
most important principal for this discussion is that all callbacks added to an
``EventLoopFuture`` will execute on the ``EventLoop`` to which that ``EventLoopFuture`` is
bound. By extension, then, all callbacks added to an ``EventLoopFuture`` execute on the same
executor (the ``EventLoop``) in the same isolation domain.
FranzBusch marked this conversation as resolved.
Show resolved Hide resolved

The analogy to an actor here is hopefully fairly clear. Conceptually, an ``EventLoopFuture``
could be modelled as an actor. That means all the callbacks have the same logical semantics:
the ``EventLoopFuture`` defines an isolation domain, and all the callbacks are `sent` into the
Lukasa marked this conversation as resolved.
Show resolved Hide resolved
isolation domain. To that end, all the callback-taking APIs require that the callback is sent
using `sending` into the ``EventLoopFuture``.

> Note: As of the current 2.75.0 release, NIO enforces the stricter requirement that these callbacks
are `@Sendable`. This is not a long-term position, but reflects the need to continue
to support Swift 5 code which requires this stricter standard. In a future release of
SwiftNIO we expect to relax this constraint: if you need this relaxed constraint
then please file an issue.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure the repeated exact Note as above here isn't too confusing, maybe explicitly call out what "these" are or shorten the repeated notes?


Unlike ``EventLoop``s, however, ``EventLoopFuture``s also have value-receiving and value-taking
APIs. This is because ``EventLoopFuture``s pass a value along to their various callbacks, and
so need to be both given an initial value (via an ``EventLoopPromise``) and in some cases to
extract that value from the ``EventLoopFuture`` wrapper.

This implies that ``EventLoopPromise``'s various success functions
(_and_ ``EventLoop/makeSucceededFuture(_:)``) need to take their value as `sending`. The value
is potentially sent from its current isolation domain into the ``EventLoop``, which will require
that the value is safe to move.

> Note: As of the current 2.75.0 release, NIO enforces the stricter requirement that these values
are `Sendable`. This is not a long-term position, but reflects the need to continue
to support Swift 5 code which requires this stricter standard. In a future release of
SwiftNIO we expect to relax this constraint: if you need this relaxed constraint
then please file an issue.

There are also a few ways to extract a value, such as ``EventLoopFuture/wait(file:line:)``
and ``EventLoopFuture/get()``. These APIs can only safely be called when the ``EventLoopFuture``
is carrying a `Sendable` value. This is because ``EventLoopFuture``s hold on to their value and
can give it to other closures or other callers of `get` and `wait`. Thus, `sending` is not
sufficient.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that's right, yes.


## Combining Futures

NIO provides a number of APIs for combining futures, such as ``EventLoopFuture/and(_:)``.
This potentially represents an issue, as two futures may not share the same isolation domain.
As a result, we can only safely call these combining functions when the ``EventLoopFuture``
values are `Sendable`.

> Note: We can conceptually relax this constraint somewhat by offering equivalent
functions that can only safely be called when all the combined futures share the
same bound event loop: that is, when they are all within the same isolation domain.

This can be enforced with runtime isolation checks. If you have a need for these
functions, please reach out to the NIO team.

## Interacting with Futures on the Event Loop

In a number of contexts (such as in ``ChannelHandler``s), the programmer has static knowledge
that they are within an isolation domain. That isolation domain may well be shared with the
isolation domain of many futures and promises with which they interact. For example,
futures that are provided from ``ChannelHandlerContext/write(_:promise:)`` will be bound to
the event loop on which the ``ChannelHandler`` resides.

In this context, the `sending` constraint is unnecessarily strict. The future callbacks are
guaranteed to fire on the same isolation domain as the ``ChannelHandlerContext``: no risk
of data race is present. However, Swift Concurrency cannot guarantee this at compile time,
as the specific isolation domain is determined only at runtime.

In these contexts, today users can make their callbacks safe using ``NIOLoopBound`` and
``NIOLoopBoundBox``. These values can only be constructed on the event loop, and only allow
access to their values on the same event loop. These constraints are enforced at runtime,
so at compile time these are unconditionally `Sendable`.

> Warning: ``NIOLoopBound`` and ``NIOLoopBoundBox`` replace compile-time isolation checks
with runtime ones. This makes it possible to introduce crashes in your code. Please
ensure that you are 100% confident that the isolation domains align. If you are not
sure that the ``EventLoopFuture`` you wish to attach a callback to is bound to your
``EventLoop``, use ``EventLoopFuture/hop(to:)`` to move it to your isolation domain
before using these types.

> Note: In a future NIO release we intend to improve the ergonomics of this common problem
by offering a related type that can only be created from an ``EventLoopFuture`` on a
given ``EventLoop``. This minimises the number of runtime checks, and will make it
easier and more pleasant to write this kind of code.

## Interacting with Event Loops on the Event Loop

As with Futures, there are occasionally times where it is necessary to schedule
``EventLoop`` operations on the ``EventLoop`` where your code is currently executing.

Much like with ``EventLoopFuture``, you can use ``NIOLoopBound`` and ``NIOLoopBoundBox``
to make these callbacks safe.

> Warning: ``NIOLoopBound`` and ``NIOLoopBoundBox`` replace compile-time isolation checks
with runtime ones. This makes it possible to introduce crashes in your code. Please
ensure that you are 100% confident that the isolation domains align. If you are not
sure that the ``EventLoopFuture`` you wish to attach a callback to is bound to your
``EventLoop``, use ``EventLoopFuture/hop(to:)`` to move it to your isolation domain
before using these types.

> Note: In a future NIO release we intend to improve the ergonomics of this common problem
by offering a related type that can only be created from an ``EventLoopFuture`` on a
given ``EventLoop``. This minimises the number of runtime checks, and will make it
easier and more pleasant to write this kind of code.
3 changes: 2 additions & 1 deletion Sources/NIOCore/EventLoop+Deprecated.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,10 @@ extension EventLoop {
self.makeFailedFuture(error)
}

@preconcurrency
@inlinable
@available(*, deprecated, message: "Please don't pass file:line:, there's no point.")
public func makeSucceededFuture<Success>(
public func makeSucceededFuture<Success: Sendable>(
_ value: Success,
file: StaticString = #fileID,
line: UInt = #line
Expand Down
Loading
Loading