2

I created a small wrapper around a PassthroughSubject which converts it into an AsyncStream.

protocol Channelable {}

final class Channel {
    private let subject = PassthroughSubject<Channelable, Never>()
    
    func send(_ value: Channelable) {
        subject.send(value)
    }
    
    func getValues() -> AsyncStream<Channelable> {
        AsyncStream { continuation in
            let task = Task {
                for await value in subject.values {
                    continuation.yield(value)
                }
            }
            
            continuation.onTermination = { _ in
                task.cancel()
            }
        }
    }
}

When I do the following:

enum Action: Channelable {
    case execute
}

func test() async {
        let channel = Channel()
        
        DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
            channel.send(Action.execute)
            channel.send(Action.execute)
            channel.send(Action.execute)
        }
        
        for await value in channel.getValues() {
            print(value)
        }
    }

"execute" gets printed only once, and I would expect it to be printed three times.

What am I doing wrong?

hydro1337x
  • 95
  • 5
  • Why make an `AsyncStream` specifically? What's wrong with using the `values` property directly? – Sweeper Mar 18 '23 at 13:28
  • 1
    I think the `values` property might be a little buggy. From my experiments, sometimes it outputs totally incorrect values. If you are not using Xcode 14.3, I'd say try updating to that version. – Sweeper Mar 18 '23 at 13:54
  • @Sweeper wow, can you give a simple example of `values` screwing up? – matt Mar 18 '23 at 18:40
  • @matt i was testing with a `CurrentValueSubject`, sending the values with very little (non-zero) delay, and then I sometimes see the same value being _duplicated_ in `values`. It is not very consistent either - different runs could get different results. Now looking at Rob’s answer, I think I might have been observing another manifestation of the same “backpressure” problem, though embarrassingly I never really understood what that word meant. – Sweeper Mar 18 '23 at 23:37
  • @hydro1337x - You said, “I created a small wrapper around a PassthroughSubject which converts it into an AsyncStream.” - I am wondering what problem you were trying to solve with this. An `AsyncStream` is merely a type to simplify the creation of an `AsyncSequence`. But `PassthroughSubject` already provides an `AsyncSequnce` for us with `values`. Why create a `AsyncSequence` from an existing `AsyncSequence`? E.g., you could do something like https://gist.github.com/robertmryan/3bff0ae109dd9575205e9e31f09a0969, which begs the question of why one would need this type at all? – Rob Mar 19 '23 at 01:57
  • @Sweeper As I explain in my online book: "The architecture of the Combine framework leaves it up to the subscriber to “pull” values from the publisher. The subscriber can thus prevent the publisher from sending values by not pulling values. This ability of the subscriber to regulate the publisher’s rate of value production is called backpressure." – matt Mar 19 '23 at 03:27
  • 1
    @Rob I went with this approach so I don't need to import Combine everywhere in my code and still remain flexible to swap out a Combine Subject for a Rx one if needed. – hydro1337x Mar 19 '23 at 17:38

1 Answers1

3

I believe that you will find that if you insert delays between the three send calls, you will see all of the values in your for-await-in loop.

The problem appears to be related to back-pressure. Consider the following, with Task.sleep introduced to consistently manifest the issue:

class Channel<Channelable> {
    private let subject = PassthroughSubject<Channelable, Never>()
    
    func send(_ value: Channelable) {
        subject.send(value)
    }
    
    func values() -> AsyncStream<Channelable> {
        AsyncStream { continuation in
            let task = Task {
                for await value in subject.values {
                    continuation.yield(value)
                    try? await Task.sleep(for: .seconds(1.5))
                }
            }
            
            continuation.onTermination = { _ in
                task.cancel()
            }
        }
    }
}

enum Action {
    case foo
    case bar
    case baz
}

func test() async {
    let channel = Channel<Action>()
    
    Task {
        try? await Task.sleep(for: .seconds(5))
        channel.send(.foo)
        try? await Task.sleep(for: .seconds(1))
        channel.send(.bar)
        try? await Task.sleep(for: .seconds(1))
        channel.send(.baz)
    }
    
    for await action in channel.values() {
        print(action)
    }
}

That will output:

foo
baz

But if I eliminate that 1.5 second delay in the values function, so that it can yield the values more quickly than they are consumed, the back-pressure problem goes away:

foo
bar
baz

The documentation warns us:

A PassthroughSubject drops values if there are no subscribers, or its current demand is zero.


In terms of solutions, you have a few options:

  1. Interestingly, I found that the traditional sink approach did not suffer this problem. E.g., here is an example:

    class Channel<Channelable> {
        private let subject = PassthroughSubject<Channelable, Never>()
        private var cancellable: AnyCancellable?
    
        func send(_ value: Channelable) {
            subject.send(value)
        }
    
        func values() -> AsyncStream<Channelable> {
            AsyncStream { continuation in
                cancellable = subject.sink { value in
                    continuation.yield(value)
                }
    
                continuation.onTermination = { [weak self] _ in
                    self?.cancellable = nil
                }
            }
        }
    }
    

    There are refinements that I might make in this, but this is really for illustrative purposes, rather than a solution. As you’ll see below, there are probably better alternatives, anyway, so I will not belabor this one. Just know that the sink approach does not appear to manifest the problem in question. It does, though, block send until the prior sink finishes executing (e.g., add a delay in sink closure, and send seems to block the calling thread for that period of time.

  2. Apple has written a AsyncChannel (part of the Swift Async Algorithms package) with explicit attention placed on back-pressure, notably, with “affordance of back pressure applied from the consumption site to be transmitted to the production site.” Thus, the send will await the asynchronous value, but it won’t block the caller’s thread.

    func test() async {
        let channel = AsyncChannel<Action>()
    
        Task {
            try? await Task.sleep(for: .seconds(5))
            await channel.send(Action.foo)
            try? await Task.sleep(for: .seconds(1))
            await channel.send(Action.bar)
            try? await Task.sleep(for: .seconds(1))
            await channel.send(Action.baz)
        }
    
        for await action in channel {
            print(action)
        }
    }
    

    This is another alternative to writing your own Channel type.

Rob
  • 415,655
  • 72
  • 787
  • 1,044