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

Add MultiProducerSingleConsumerChannel #305

Open
wants to merge 5 commits into
base: main
Choose a base branch
from

Conversation

FranzBusch
Copy link
Member

Motivation

The pitch to add external backpressure support to the standard libraries AsyncStream got returned for revision since there are larger open questions around AsyncSequence. However, having external backpressure in a source asynchronous sequence is becoming more and more important.

Modification

This PR adds a modified proposal and implementation that brings the Swift Evolution proposal over to Swift Async Algorithms.

# Motivation

The pitch to add external backpressure support to the standard libraries `AsyncStream` got returned for revision since there are larger open questions around `AsyncSequence`. However, having external backpressure in a source asynchronous sequence is becoming more and more important.

# Modification

This PR adds a modified proposal and implementation that brings the Swift Evolution proposal over to Swift Async Algorithms.
@FranzBusch FranzBusch force-pushed the fb-async-backpressured-stream branch from ddd7523 to 65e8957 Compare December 19, 2023 11:49
@FranzBusch
Copy link
Member Author

@swift-ci please test

@FranzBusch FranzBusch requested a review from phausler December 20, 2023 09:57
@FranzBusch FranzBusch force-pushed the fb-async-backpressured-stream branch from 4904039 to 662693e Compare June 14, 2024 08:15
@FranzBusch FranzBusch changed the title Add AsyncBackpressuredStream proposal and implementation Add MultiProducerSingleConsumerChannel Jun 14, 2024

### Upstream producer termination

The producer will get notified about termination through the `onTerminate`
Copy link

Choose a reason for hiding this comment

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

nit: can you add calls to onTerminate to the snippets below?

})
}
} catch {
// `send(contentsOf:)` throws if the asynchronous stream already terminated
Copy link

Choose a reason for hiding this comment

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

Would this be clearer as another case on the sendResult?

/// - Parameters:
/// - low: When the number of buffered elements drops below the low watermark, producers will be resumed.
/// - high: When the number of buffered elements rises above the high watermark, producers will be suspended.
/// - waterLevelForElement: A closure used to compute the contribution of each buffered element to the current water level.
Copy link

Choose a reason for hiding this comment

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

Oh, neat. This works nicely for collections.

for producerContinuation in producerContinuations {
switch producerContinuation {
case .closure(let onProduceMore):
onProduceMore(.failure(CancellationError()))

Choose a reason for hiding this comment

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

This is a state machine bug I picked up running the OpenAPIURLSession tests with this type:

Suggested change
onProduceMore(.failure(CancellationError()))
onProduceMore(.success(()))

Might be worth seeing if we can have similar tests in this project. This particular test, 1kChunk_10kChunks_100BDownloadWatermark, exercised the suspend and resume of the producer a lot due to the relation of the chunk size to the watermark.

Comment on lines +111 to +115
public static func makeChannel(
of elementType: Element.Type = Element.self,
throwing failureType: Failure.Type = Never.self,
backpressureStrategy: Source.BackpressureStrategy
) -> ChannelAndStream {

Choose a reason for hiding this comment

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

What's the reason for this returning a nominal type? Previously it returned a tuple which allows for initialization of the sequence as follows:

let (channel, source) = MultiProducerSingleConsumerChannel.makeChannel(/* ... */)

This is also what the documentation comment on the type suggests.

However, with the nominal type you cannot do that and, if you are storing these as properties, are forced to do this...

let channelAndSource = MultiProducerSingleConsumerChannel.makeChannel(/* ... */)
self.channel = channelAndSource.channel
self.source = channelAndSource.source

...instead of this...

(self.channel, self.source) = MultiProducerSingleConsumerChannel.makeChannel(/* ... */)

Copy link
Member Author

Choose a reason for hiding this comment

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

Sadly, tuples don't support ~Copyable types. That's the only reason for a nominal type here.

//
//===----------------------------------------------------------------------===//

#if compiler(>=6.0)

Choose a reason for hiding this comment

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

Please can we consider offering a pre-Swift 6 variant of this type. There are several places we'd like to adopt this that need to also support Swift 5.9+ for the foreseeable.

Copy link
Member Author

Choose a reason for hiding this comment

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

Hm not sure yet. Especially since it is part of the public API in the Source. Making the Source copyable makes the type behave way differently.

private let backing: _Backing

@frozen
public struct ChannelAndStream: ~Copyable {
Copy link

@ser-0xff ser-0xff Oct 23, 2024

Choose a reason for hiding this comment

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

Was it intended to be called ChannelAndStream while it is actually a container for channel and source?
Shouldn't it be called ChannelAndSource instead?

@ser-0xff
Copy link

Hi!
Thank you for the PR!
We are looking for the functionality you did in MultiProducerSingleConsumerChannel and tried to play with it.
Unfortunately we were not able to compile it because we need it in the framework which is compiled with evolution mode.
I did some changes to make it compile, but not sure these changes are good enough to be a part of public framework.
Do you have any plans to support evolution mode for the AsyncAlgorithms?
We prepared a small reproducing project for you for a case if you will have some time to look at that.
Just

git clone https://github.com/ordo-one/external-reproducers.git
cd external-reproducers/swift/async-algorithms-evolution
xcodebuild archive -quiet -scheme async-algorithms-evolution -configuration Debug -destination 'platform=macOS,arch=arm64' BUILD_LIBRARY_FOR_DISTRIBUTION=YES

@FranzBusch
Copy link
Member Author

@ser-0xff Thanks for trying it out. We don't plan on support library evolution mode in any of our packages. What we normally advise is to not compile packages as frameworks but rather add this library as an internal import only dependency inside the frameworks that need it.

@ser-0xff
Copy link

Thank you for the reply...
Unfortunately internal import does not work if we want to use package type (like MultiProducerSingleConsumerChannel) in the framework public API...

@hassila
Copy link

hassila commented Oct 24, 2024

Thank you for the reply... Unfortunately internal import does not work if we want to use package type (like MultiProducerSingleConsumerChannel) in the framework public API...

@ser-0xff I guess we need to wrap up the API surface we want to support then and just do internal import as suggested? But let's take that offline from this PR - thanks @FranzBusch for quick reply.

@ser-0xff
Copy link

ser-0xff commented Oct 24, 2024

Hi, @FranzBusch
We run into another issue when we played with the MultiProducerSingleConsumerChannel.
Our projects are already on Swift6 which has a stricter non-copyable types checks. The MultiProducerSingleConsumerChannel<>.Source type is non-copyable so we can't share it across many tasks, as a result we can use it only in single producer mode.
How could we solve that issue and produce data within multiple tasks?

Here is a minimized test showing the usage pattern we want if you will find some time to look at.

@FranzBusch
Copy link
Member Author

We run into another issue when we played with the MultiProducerSingleConsumerChannel.
Our projects are already on Swift6 which has a stricter non-copyable types checks. The MultiProducerSingleConsumerChannel<>.Source type is non-copyable so we can't share it across many tasks, as a result we can use it only in single producer mode.
How could we solve that issue and produce data within multiple tasks?

The source has a copy method that allows you to get a second source. Now the problem is that closures cannot be marked as ~Copyable or rathe @calledOnce. To solution to this is to box the source into a class right now and move it into a task. This either requires a lock or an @unchecked Sendable annotation right now.

@ser-0xff
Copy link

ser-0xff commented Oct 28, 2024

Missed copy() method in source code.
Will look at that, thank you for the reply.

@ser-0xff
Copy link

ser-0xff commented Nov 5, 2024

HI, Franz!
We tried using MultiProducersSingleConsumerChannel instead of AsyncStream and encountered behavior unexpected for us. We expected the channel to remain functional as long as at least one associated source was active. However, in the current implementation, it appears that the channel transitions to a finished state as soon as any single source instance is deinitialized.
I created a PR with minimized test into your feature branch for a case if you could find some time to look at that and may be later will include that test to the test suite for MultiProducersSingleConsumerChannel.

@FranzBusch
Copy link
Member Author

HI, Franz! We tried using MultiProducersSingleConsumerChannel instead of AsyncStream and encountered behavior unexpected for us. We expected the channel to remain functional as long as at least one associated source was active. However, in the current implementation, it appears that the channel transitions to a finished state as soon as any single source instance is deinitialized. I created a PR with minimized test into your feature branch for a case if you could find some time to look at that and may be later will include that test to the test suite for MultiProducersSingleConsumerChannel.

You are right. I haven't gotten around to implement the correct handling of multiple sources yet.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants