skip to Main Content

Having memory leak issue while asynchronously iterating over AsyncPublisher (kind of async sequence)!

In the following code I have timerSequence (AsyncPublisher<Publishers.Autoconnect<Timer.TimerPublisher>>) and on inti I’m asynchronously iterating over that timer sequence. But event after [weak self] capture inside Task it’s still not deallocating! Wondering it’s a Combine’s bug!!

GitHub demo: https://github.com/satishVekariya/AnyPublisherMemoryLeakDemoApp

import Foundation
import Combine

class MyServiceClass {
    
    let timerSequence = Timer.publish(every: 1, on: .main, in: .default).autoconnect().values
    
    init() {
        Task { [weak self] in
            await self?.setUpStreamIteration()
        }
    }
    
    func setUpStreamIteration() async {
        for await time in timerSequence {
            print(time)
        }
    }
}

var service: MyServiceClass? = MyServiceClass()
service = nil

Output:

2023-03-26 00:14:11 +0000
2023-03-26 00:14:12 +0000
2023-03-26 00:14:13 +0000
2023-03-26 00:14:14 +0000
2023-03-26 00:14:15 +0000
2023-03-26 00:14:16 +0000
2023-03-26 00:14:17 +0000
2023-03-26 00:14:18 +0000
2023-03-26 00:14:19 +0000
...

2

Answers


  1. First, I’m going to suggest that we simplify the example:

    class MyServiceClass {
        private let timerSequence = Timer
            .publish(every: 1, on: .main, in: .default)
            .autoconnect()
            .values
        
        func setUpStreamIteration() async {
            for await time in timerSequence {
                print(time)
            }
        }
    }
    
    // It obviously doesn’t make much sense to create a local var for a service
    // and then let it immediately fall out of scope, but we’re doing this just
    // to illustrate the problem…
    
    func foo() {
        let service = MyServiceClass()
        Task { service.setUpStreamIteration }
    }
    

    Once setUpStreamIteration starts running, the MyServiceClass cannot be released until setUpStreamIteration finishes. But in the absence of something to stop this asynchronous sequence, this method will never finish. Inserting a weak capture list in Task {…} will not save you once setUpStreamIteration starts. (It actually introduces a race between the deallocation of the service and the starting of this method, which complicates our attempts to reproduce/diagnose this problem.)

    One approach, if you are using SwiftUI, is to create the stream in a .task view modifier, and it will automatically cancel it when the view is dismissed. (Note, for this to work, one must remain within structured concurrency and avoid Task { … } unstructured concurrency.)

    The other typical solution is to explicitly opt into unstructured concurrency, save the Task when you start the sequence and add a method to stop the sequence. E.g.:

    class MyServiceClass {
        private var task: Task<Void, Never>?
    
        private let timerSequence = Timer
            .publish(every: 1, on: .main, in: .default)
            .autoconnect()
            .values
    
        func setUpStreamIteration() {
            task = Task {
                for await time in timerSequence {
                    print(time)
                }
            }
        }
    
        func stopStreamIteration() {
            task?.cancel()
        }
    }
    

    And you just need to make sure that you call stopStreamIteration before you release it (e.g., when the associated view disappears).


    By the way, if you want to avoid introducing Combine, you can use AsyncTimerSequence from the Swift Async Algorithms package.

    for await time in AsyncTimerSequence(interval: .seconds(1), clock: .continuous) {
        print(time)
    }
    

    You still have the same issue about needing to cancel this sequence (using one of the two approaches outlined above), but at least it avoids introducing Combine into the mix). You could also write your own AsyncStream wrapping Timer or a GCD timer, but there’s no reason to reinvent the wheel.

    Login or Signup to reply.
  2. using .sink I managed to prevent the leak like so:

    import Foundation
    import Combine
    
    class MyServiceClass {
        
        let timerSequence = Timer.publish(every: 1, on: .main, in: .default).autoconnect()
    
        
        init() {
            Task { [weak self] in
                await self?.setUpStreamIteration()
            }
        }
        
        func setUpStreamIteration() async {
             await timerSequence
                            .sink { [weak self] time in
                                print(time)
                            }
                            .store(in: &cancellations)
        }
    }
    
    var service: MyServiceClass? = MyServiceClass()
    service = nil
    

    but to be honest, I can’t say for sure why it managed to work this way.
    so even if it works for you, I wouldn’t sign my answer as the correct without someone else adding a clear explanation

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search