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

Conversation

FranzBusch
Copy link
Member

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, 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.

@FranzBusch FranzBusch force-pushed the fb-event-loop-future-sendable branch 2 times, most recently from d95beaf to b438afd Compare February 19, 2024 11:10
Copy link
Contributor

@glbrntt glbrntt left a comment

Choose a reason for hiding this comment

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

I think the explanation makes sense and this all ties together nicely. I think the spelling of the new APIs is not quite right though and we should align with patterns we've used elsewhere in NIO (i.e. sync views)

Sources/NIOCore/EventLoop.swift Outdated Show resolved Hide resolved
Comment on lines 194 to 196
public func succeed(eventLoopBoundValue: Value) {
self._resolve(eventLoopBoundResult: .success(eventLoopBoundValue))
}
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not sure this is the right spelling; the pattern we have for functions which must be called on the right EL with runtime assertions are synchronous views (e.g. ChannelPipeline.SynchronousOperations and Channel.syncOptions).

It also skirts around the slight oddity with this API in that you could call this function with a Sendable i.e. not EL bound value with no ill side effect.

Copy link
Member Author

Choose a reason for hiding this comment

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

I agree. I removed those loopBound APIs from this PR since they are really part of the AssumeIsolated PR

Sources/NIOCore/EventLoopFuture.swift Outdated Show resolved Hide resolved
Sources/NIOCore/EventLoopFuture.swift Outdated Show resolved Hide resolved
Sources/NIOCore/EventLoopFuture.swift Outdated Show resolved Hide resolved
… checking

# 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 apple#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()`
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.
@FranzBusch FranzBusch force-pushed the fb-event-loop-future-sendable branch from b438afd to a5c83a4 Compare February 19, 2024 15:03
@FranzBusch FranzBusch force-pushed the fb-event-loop-future-sendable branch from a5c83a4 to 5d20621 Compare February 19, 2024 15:29
Copy link
Contributor

@glbrntt glbrntt left a comment

Choose a reason for hiding this comment

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

LGTM now. Purposefully not selecting 'Approve' as I'd like at least one other set of eyes to approve this too, but consider it approved by me.

@Lukasa Lukasa added the 🔨 semver/patch No public API change. label Feb 28, 2024
@Lukasa Lukasa requested a review from glbrntt February 28, 2024 13:19
// transfer the value to the promise. This ensures that the value is now isolated to the event loops
// isolation domain. Note: Sendable values can always be transferred

extension EventLoopPromise: @unchecked Sendable { }
Copy link
Member

@dnadoba dnadoba Jun 27, 2024

Choose a reason for hiding this comment

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

No need for unchecked. EventLoopFuture is already Sendable and that is the only store property.

Suggested change
extension EventLoopPromise: @unchecked Sendable { }
extension EventLoopPromise: Sendable { }

Copy link
Member Author

Choose a reason for hiding this comment

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

I think this still applies.

@Lukasa
Copy link
Contributor

Lukasa commented Oct 17, 2024

I've modified this further, and also added some narrative documentation explaining the thinking behind the type. Please re-review.

Copy link
Member Author

@FranzBusch FranzBusch left a comment

Choose a reason for hiding this comment

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

LGTM. Thanks for taking the time to write the document. I can't approve the PR since I am the author but consider this comment as an approval. I only had three small comments.

Sources/NIOCore/Docs.docc/loops-futures-concurrency.md Outdated Show resolved Hide resolved
Sources/NIOCore/EventLoopFuture.swift Show resolved Hide resolved
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?

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.

Copy link
Member

@ktoso ktoso left a comment

Choose a reason for hiding this comment

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

I didn;t notice anything in the .md that would trigger spidey senses, It looks right I think.

Sources/NIOCore/AsyncAwaitSupport.swift Show resolved Hide resolved
@@ -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

Sources/NIOCore/Docs.docc/loops-futures-concurrency.md Outdated Show resolved Hide resolved
Sources/NIOCore/Docs.docc/loops-futures-concurrency.md Outdated Show resolved Hide resolved

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.

@Lukasa Lukasa added 🆕 semver/minor Adds new public API. and removed 🔨 semver/patch No public API change. labels Oct 18, 2024
@Lukasa Lukasa merged commit ff98c93 into apple:main Oct 18, 2024
31 of 33 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
🆕 semver/minor Adds new public API.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants