Combine: Best practices with memory management using subjects to publish values

Consider an object like this that publishes values via a subject:

class IntStream { 
    let subject = PassthroughSubject<Int, Never>()
    var publisher: AnyPublisher<Int, Never> {
        subject.eraseToAnyPublisher()
    }
    init(_ times: Int = 5) {
        for i in 0..<times {
            DispatchQueue.global(qos: .default).asyncAfter(deadline: .now() + .seconds(i)) { [weak self] in
                self?.subject.send(i)
                if i == times - 1 {
                    self?.subject.send(completion: .finished)
                }
            }
        }
    }
}

This works great, but the consumer is now required to hold onto the subscription and also the IntStream (the object that retains the PassthroughSubject).

If the consumer doesn't hold onto their reference to IntStream, then the subject is dealloc'd and values are not published.

My question is if this is expected? The reason I ask is because with custom publishers that I've written, I've been able to have the custom Subscription class hold onto all the state necessary to publish values, and in those cases, it's not necessary to hold onto the object that vends the publisher. What is the best practice here?

Thank you!

I don't know about “best practice” because Apple has given little guidance about writing your own Publisher or managing this problem.

Here's a solution that only requires you to add one line of code: return a Publisher that uses map to hold a reference to the IntStream.

Here's a different version of IntStream that uses this technique:

class IntStream {
    var publisher: AnyPublisher<Int, Never> {
        subject
            .map { (self, $0).1 }
            .eraseToAnyPublisher()
    }

    init() {
        step(0)
    }

    private let subject = PassthroughSubject<Int, Never>()

    deinit { print(#function) }

    private func step(_ i: Int) {
        subject.send(i)

        DispatchQueue.global().asyncAfter(deadline: .now() + .seconds(1)) { [weak self] in
            self?.step(i + 1)
        }
    }
}

My version has three differences from yours:

  • It emits indefinitely, until it is destroyed.
  • It prints a message when it is destroyed, so we can see if it is destroyed at the correct time.
  • It returns a publisher that uses map to hold a reference to self, which should keep the IntStream from being destroyed if there are any live subscriptions.

Here's the test code:

func run() -> [AnyCancellable] {
    let stream = IntStream()
    return [
        stream.publisher.sink { print("sink 1: \($0)") },
        stream.publisher.sink { print("sink 2: \($0)") },
    ]
}

var tickets = run()
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(4)) {
    tickets.removeLast()
}
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(7)) {
    tickets.removeLast()
}

So we create two subscriptions from the same IntStream and cancel them at different times. Here's the output:

sink 1: 1
sink 2: 1
sink 1: 2
sink 2: 2
sink 1: 3
sink 2: 3
sink 1: 4
sink 2: 4
sink 1: 5
sink 1: 6
sink 1: 7
deinit

So we can see that each subscription continues for the correct amount of time, and the IntStream is destroyed after all subscriptions have been cancelled.


I only tested this in a playground. Maybe in an optimized build, that self reference in the map closure gets optimized out. In that case, you'd need to use withExtendedLifetime to defeat the optimizer.

1 Like

@mayoff - Thank you for taking the time to answer my question. That's a clever solution!

I don't know about “best practice” because Apple has given little guidance about writing your own Publisher or managing this problem.

I don't see any downsides to doing it this way, other than if a consumer of the api expected the stream to stop if you release all references to it. Maybe I should test the URLSession.DataTaskPublisher to see what happens if you release all references to the URLSession.

The source code for URLSession.DataTaskPublisher is part of the Swift repo, in case you want to look at it.

1 Like

Good point! Looking at the source it looks like the Subscription holds onto a copy of the DataTaskPublisher, which retains the Session. So I guess that answers that. Thank you!