9

Is there a good way to handle an array of AnyCancellable to remove a stored AnyCancellable when it's finished/cancelled?

Say I have this

import Combine
import Foundation

class Foo {

    private var cancellables = [AnyCancellable]()

    func startSomeTask() -> Future<Void, Never> {
        Future<Void, Never> { promise in
            DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) {
                promise(.success(()))
            }
        }
    }

    func taskCaller() {
        startSomeTask()
            .sink { print("Do your stuff") }
            .store(in: &cancellables)
    }

}

Every time taskCaller is called, a AnyCancellable is created and stored in the array. I'd like to remove that instance from the array when it finishes in order to avoid memory waste.

I know I can do something like this, instead of the array

var taskCancellable: AnyCancellable?

And store the cancellable by doing:

taskCancellable = startSomeTask().sink { print("Do your stuff") }

But this will end to create several single cancellable and can pollute the code. I don't want a class like

class Bar {

    private var task1: AnyCancellable?
    private var task2: AnyCancellable?
    private var task3: AnyCancellable?
    private var task4: AnyCancellable?
    private var task5: AnyCancellable?
    private var task6: AnyCancellable?

}
Rico Crescenzio
  • 3,952
  • 1
  • 14
  • 28

2 Answers2

11

I asked myself the same question, while working on an app that generates a large amount of cancellables that end up stored in the same array. And for long-lived apps the array size can become huge.

Even if the memory footprint is small, those are still objects, which consume heap, which can lead to heap fragmentation in time.

The solution I found is to remove the cancellable when the publisher finishes:

func consumePublisher() {
    var cancellable: AnyCancellable!
    cancellable = makePublisher()
        .sink(receiveCompletion: { [weak self] _ in self?.cancellables.remove(cancellable) },
              receiveValue: { doSomeWork() })
    cancellable.store(in: &cancellables)
}

Indeed, the code is not that pretty, but at least there is no memory waste :)

Some high order functions can be used to make this pattern reusable in other places of the same class:

func cleanupCompletion<T>(_ cancellable: AnyCancellable) -> (Subscribers.Completion<T>) -> Void {
    return { [weak self] _ in self?.cancellables.remove(cancellable) }
}

func consumePublisher() {
    var cancellable: AnyCancellable!
    cancellable = makePublisher()
        .sink(receiveCompletion: cleanupCompletion(cancellable),
              receiveValue: { doSomeWork() })
    cancellable.store(in: &cancellables)
}

Or, if you need support to also do work on completion:

func cleanupCompletion<T>(_ cancellable: AnyCancellable) -> (Subscribers.Completion<T>) -> Void {
        return { [weak self] _ in self?.cancellables.remove(cancellable) }
    }
    
func cleanupCompletion<T>(_ cancellable: AnyCancellable, completionWorker: @escaping (Subscribers.Completion<T>) -> Void) -> (Subscribers.Completion<T>) -> Void {
    return { [weak self] in
        self?.cancellables.remove(cancellable)
       completionWorker($0)
    }
}

func consumePublisher() {
    var cancellable: AnyCancellable!
    cancellable = makePublisher()
        .sink(receiveCompletion: cleanupCompletion(cancellable) { doCompletionWork() },
              receiveValue: { doSomeWork() })
    cancellable.store(in: &cancellables)
}
Cristik
  • 30,989
  • 25
  • 91
  • 127
  • Hey, I implemented this and noticed a crash in my code: https://stackoverflow.com/questions/74878416/removing-cancellable-from-set-crashes-app?noredirect=1#comment132188641_74878416. Do you know why this might happen? – Tometoyou Dec 24 '22 at 12:40
  • @Tometoyou had a quick look over your question, there is very little chance your crashes are related to the code from this answer. The crash messages are related to Objective-C features, while Combine is pure Swift. – Cristik Dec 24 '22 at 15:11
2

It's a nice idea, but there is really nothing to remove. When the completion (finish or cancel) comes down the pipeline, everything up the pipeline is unsubscribed in good order, all classes (the Subscription objects) are deallocated, and so on. So the only thing that is still meaningfully "alive" after your Future has emitted a value or failure is the Sink at the end of the pipeline, and it is tiny.

To see this, run this code

for _ in 1...100 {
    self.taskCaller()
}

and use Instruments to track your allocations. Sure enough, afterwards there are 100 AnyCancellable objects, for a grand total of 3KB. There are no Futures; none of the other objects malloced in startSomeTask still exist, and they are so tiny (48 bytes) that it wouldn't matter if they did.

matt
  • 515,959
  • 87
  • 875
  • 1,141
  • Do you think I'm just overthinking? – Rico Crescenzio Mar 14 '20 at 18:16
  • I was about to do the same, thanks, I think I'll just ignore it. – Rico Crescenzio Mar 14 '20 at 18:32
  • 5
    "Premature optimization is the root of all evil." :) – matt Mar 14 '20 at 18:34
  • 7
    This answer gives bad advice, and that is that because a sink is "tiny," it's okay to let them accumulate. This is a memory leak. At best it'll confuse efforts to find other leaks, and at worst it will consume excessive memory. And it might well be fine when you first write it, but a subsequent change might make this bite you. Better to clean up when you can. I wish Combine made this easier. – Rick Jul 04 '21 at 05:44
  • 6
    @Rick Yes I agree, it fails to grapple with the issue. I like the other answer better than mine. – matt Jul 04 '21 at 06:22