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

Implement a NIOAsyncWriter #2251

Merged
merged 13 commits into from
Sep 21, 2022
Merged

Implement a NIOAsyncWriter #2251

merged 13 commits into from
Sep 21, 2022

Conversation

FranzBusch
Copy link
Member

Motivation

We previously added the NIOAsyncSequenceProducer to bridge between the NIO channel pipeline and the asynchronous world. However, we still need something to bridge writes from the asynchronous world back to the NIO channel pipeline.

Modification

This PR adds a new NIOAsyncWriter type that allows us to asynchronously yield elements to it. On the other side, we can register a NIOAsyncWriterDelegate which will get informed about any written elements. Furthermore, the synchronous side can toggle the writability of the AsyncWriter which allows it to implement flow control.
A main goal of this type is to be as performant as possible. To achieve this I did the following things:

  • Make everything generic and inlinable
  • Use a class with a lock instead of an actor
  • Provide methods to yield a sequence of things which allows users to reduce the amount of times the lock gets acquired.

Result

We now have the means to bridge writes from the asynchronous world to the synchronous

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.

Only just got to the meat of this PR; will pick up the rest of the review later. Leaving my comments so far.

Sources/NIOCore/AsyncSequences/NIOAsyncWriter.swift Outdated Show resolved Hide resolved
Sources/NIOCore/AsyncSequences/NIOAsyncWriter.swift Outdated Show resolved Hide resolved
Sources/NIOCore/AsyncSequences/NIOAsyncWriter.swift Outdated Show resolved Hide resolved
Sources/NIOCore/AsyncSequences/NIOAsyncWriter.swift Outdated Show resolved Hide resolved
Sources/NIOCore/AsyncSequences/NIOAsyncWriter.swift Outdated Show resolved Hide resolved
Sources/NIOCore/AsyncSequences/NIOAsyncWriter.swift Outdated Show resolved Hide resolved
Sources/NIOCore/AsyncSequences/NIOAsyncWriter.swift Outdated Show resolved Hide resolved
Sources/NIOCore/AsyncSequences/NIOAsyncWriter.swift Outdated Show resolved Hide resolved
Sources/NIOCore/AsyncSequences/NIOAsyncWriter.swift Outdated Show resolved Hide resolved
@Lukasa Lukasa added the 🔼 needs-minor-version-bump For PRs that when merged cause a bump of the minor version, ie. 1.x.0 -> 1.(x+1).0 label Aug 30, 2022
Copy link
Member

@fabianfett fabianfett left a comment

Choose a reason for hiding this comment

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

Great start. Left some broader questions for now.

Sources/NIOCore/AsyncSequences/NIOAsyncWriter.swift Outdated Show resolved Hide resolved
Sources/NIOCore/AsyncSequences/NIOAsyncWriter.swift Outdated Show resolved Hide resolved
var yieldID: UInt64
/// The yield's produced sequence of elements.
@usableFromInline
var elements: AnySequence<Element>
Copy link
Member

Choose a reason for hiding this comment

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

Should the AsyncWriter be generic over the Sequence type as well? How often do user's want to send different Sequence types? For the SequenceOfOne/Two case users could always opt for an enum that implements Sequence.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah, I didn't like the point of holding an AnySequence here. If we do that, then we should get rid of the yielding of a single element.

Copy link
Member

@fabianfett fabianfett Aug 30, 2022

Choose a reason for hiding this comment

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

If we do that, then we should get rid of the yielding of a single element.

I think we must and I consider this a good thing :P

Copy link
Contributor

Choose a reason for hiding this comment

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

Should the AsyncWriter be generic over the Sequence type as well? How often do user's want to send different Sequence types?

I'm not sure that's a good idea in practice. I think the typical usage pattern would be for a framework to create a writer and pass it to a caller. That would mean the framework would be responsible for choosing what the sequence type is or the caller has to ask the framework for a writer with a given sequence type (which is a pretty awkward API).

For the SequenceOfOne/Two case users could always opt for an enum that implements Sequence.

Not offering a yield-one API and requiring users to implement an enum like this makes for a really user hostile API IMHO.


FWIW I also don't like AnySequence here but I think we should keep it for the time being (it is an implementation detail and can be changed later).

One alternative would be to always store [Element] and special case the API to avoid copies when an array is provided and copy into an array when anything else is provided.

Taking that further we could be to pull a 'NIOAny' trick and have an enum of array, one, and anySequence.

That said, I think we should focus on getting the API right first and then compare perf.

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 with what George said. Making the whole thing generic of the sequence that is yielded severely limits the usage of the type and makes it very awkward to hold. I would stick with AnySequence for now and as George said we can always make it faster by applying some of our tricks.

Copy link
Contributor

Choose a reason for hiding this comment

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

As discussed offline, I think we should change this to remove the AnySequence.

Comment on lines 345 to 360
let yieldID = self._yieldIDCounter.loadThenWrappingIncrement(ordering: .relaxed)

try await withTaskCancellationHandler {
// We are manually locking here to hold the lock across the withCheckedContinuation call
self._lock.lock()

let action = self._stateMachine.yield(yieldID: yieldID)
Copy link
Member

Choose a reason for hiding this comment

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

What's the benefit of having the yieldIDCounter as an atomic, if we acquire a lock afterwards anyway?

Copy link
Member Author

Choose a reason for hiding this comment

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

We acquire the lock inside the cancellation handler and inside the operation. However, we need to get the ID outside of both of them. We have two options:

  1. Aquire the lock before once to get an id and then acquire the lock inside again
  2. Use an atomic for the id

I went with the atomic because that should perform better than the lock.

Sources/NIOCore/AsyncSequences/NIOAsyncWriter.swift Outdated Show resolved Hide resolved
Sources/NIOCore/AsyncSequences/NIOAsyncWriter.swift Outdated Show resolved Hide resolved
Sources/NIOCore/AsyncSequences/NIOAsyncWriter.swift Outdated Show resolved Hide resolved
var yieldID: UInt64
/// The yield's produced sequence of elements.
@usableFromInline
var elements: AnySequence<Element>
Copy link
Contributor

Choose a reason for hiding this comment

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

Should the AsyncWriter be generic over the Sequence type as well? How often do user's want to send different Sequence types?

I'm not sure that's a good idea in practice. I think the typical usage pattern would be for a framework to create a writer and pass it to a caller. That would mean the framework would be responsible for choosing what the sequence type is or the caller has to ask the framework for a writer with a given sequence type (which is a pretty awkward API).

For the SequenceOfOne/Two case users could always opt for an enum that implements Sequence.

Not offering a yield-one API and requiring users to implement an enum like this makes for a really user hostile API IMHO.


FWIW I also don't like AnySequence here but I think we should keep it for the time being (it is an implementation detail and can be changed later).

One alternative would be to always store [Element] and special case the API to avoid copies when an array is provided and copy into an array when anything else is provided.

Taking that further we could be to pull a 'NIOAny' trick and have an enum of array, one, and anySequence.

That said, I think we should focus on getting the API right first and then compare perf.

Copy link
Contributor

@Lukasa Lukasa 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 we're mishandling the delegate throughout this code.

Because we only call the delegate from outside the lock, we do not guarantee to maintain ordering of yields. We can suspend immediately after we drop the lock, and then have a completely unrelated Task come in and yield results, get an immediate call to didYield, and then drop into the delegate, beating the first Task.

This didn't matter in the previous delegate, which was only being called with signals that were themselves well-ordered, but it does matter here. We need a fundamentally different behaviour that maintains ordering, probably by way of keeping the lock held across these calls. That will require some thought as to how it interacts with setWritability, which we need to be careful wtih.

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.

Looks great! Left a few documentation nits/fixes.

Sources/NIOCore/AsyncSequences/NIOAsyncWriter.swift Outdated Show resolved Hide resolved
Sources/NIOCore/AsyncSequences/NIOAsyncWriter.swift Outdated Show resolved Hide resolved
Sources/NIOCore/AsyncSequences/NIOAsyncWriter.swift Outdated Show resolved Hide resolved
Sources/NIOCore/AsyncSequences/NIOAsyncWriter.swift Outdated Show resolved Hide resolved
Sources/NIOCore/AsyncSequences/NIOAsyncWriter.swift Outdated Show resolved Hide resolved
Sources/NIOCore/AsyncSequences/NIOAsyncWriter.swift Outdated Show resolved Hide resolved
Sources/NIOCore/AsyncSequences/NIOAsyncWriter.swift Outdated Show resolved Hide resolved
Sources/NIOCore/AsyncSequences/NIOAsyncWriter.swift Outdated Show resolved Hide resolved
Sources/NIOCore/AsyncSequences/NIOAsyncWriter.swift Outdated Show resolved Hide resolved
var yieldID: UInt64
/// The yield's produced sequence of elements.
@usableFromInline
var elements: AnySequence<Element>
Copy link
Contributor

Choose a reason for hiding this comment

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

As discussed offline, I think we should change this to remove the AnySequence.

@FranzBusch
Copy link
Member Author

@Lukasa I changed the API to always yield a Deque now and I added a performance test for this. Let me know what you think!

@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *)
public struct NIOAsyncWriter<
Element,
Failure,
Copy link
Contributor

Choose a reason for hiding this comment

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

Presumably we require that Element and Failure are Sendable, no?

Copy link
Member Author

@FranzBusch FranzBusch Sep 20, 2022

Choose a reason for hiding this comment

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

They already are. The delegate is currently restricting the Element to be Sendable which propagates to this generic type through the constraints. Furthermore, Failure is restricted to Error which inherits from Sendable.
But I added it here as constraints as well to make it more obvious

Copy link
Member Author

Choose a reason for hiding this comment

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

Actually taking this back since it produces a warning since it is a duplicate constraint.

Sources/NIOCore/AsyncSequences/NIOAsyncWriter.swift Outdated Show resolved Hide resolved
Sources/NIOCore/AsyncSequences/NIOAsyncWriter.swift Outdated Show resolved Hide resolved
Sources/NIOCore/AsyncSequences/NIOAsyncWriter.swift Outdated Show resolved Hide resolved
Sources/NIOCore/AsyncSequences/NIOAsyncWriter.swift Outdated Show resolved Hide resolved
Sources/NIOCore/AsyncSequences/NIOAsyncWriter.swift Outdated Show resolved Hide resolved
Sources/NIOCore/AsyncSequences/NIOAsyncWriter.swift Outdated Show resolved Hide resolved
Sources/NIOCore/AsyncSequences/NIOAsyncWriter.swift Outdated Show resolved Hide resolved
# Motivation
We previously added the `NIOAsyncProducer` to bridge between the NIO channel pipeline and the asynchronous world. However, we still need something to bridge writes from the asynchronous world back to the NIO channel pipeline.

# Modification
This PR adds a new `NIOAsyncWriter` type that allows us to asynchronously `yield` elements to it. On the other side, we can register a `NIOAsyncWriterDelegate` which will get informed about any written elements. Furthermore, the synchronous side can toggle the writability of the `AsyncWriter` which allows it to implement flow control.
A main goal of this type is to be as performant as possible. To achieve this I did the following things:
- Make everything generic and inlinable
- Use a class with a lock instead of an actor
- Provide methods to yield a sequence of things which allows users to reduce the amount of times the lock gets acquired.

# Result
We now have the means to bridge writes from the asynchronous world to the synchronous
@FranzBusch
Copy link
Member Author

@Lukasa @glbrntt @gjcairo I just pushed a new commit that changes a few things after our offline discussion:

  • Allow sending an Error with the Sink/finish(error:) method. Any suspended or subsequent call to NIOAsyncWriter/yield will throw this error. It provides a way to surface an underlying error to the callers of the NIOAsyncWriter. We have been doing this in grpc-swift to surface errors from the channel pipeline
  • I removed the generic Failure parameter from the NIOAsyncWriter since we agreed that there is little value in providing a generic here over just an Error. Furthermore, since we want to allow to send an Error from the Sink/finish(error:) method it would mean we would have to introduce another generic parameter.
  • Added a few more tests for the new state and updated the documentation.

@Lukasa Lukasa enabled auto-merge (squash) September 21, 2022 09:44
@FranzBusch FranzBusch enabled auto-merge (squash) September 21, 2022 09:49
@FranzBusch FranzBusch merged commit f144292 into apple:main Sep 21, 2022
@FranzBusch FranzBusch deleted the fb-async-writer branch September 21, 2022 09:53
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.

This is great @FranzBusch -- I think a couple of docs are out of date and should be fixed in a followup.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
🔼 needs-minor-version-bump For PRs that when merged cause a bump of the minor version, ie. 1.x.0 -> 1.(x+1).0
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

4 participants