Understanding Swift Concurrency's memory system (retain cycle)

Swift Concurrency introduces a unique memory tracking system, especially evident in scenarios where asynchronous tasks are involved. Let's set up an example to delve into this system.


import Foundationimport Combineclass Concurrency {    let publisher = PassthroughSubject<Void, Never>()    let (stream, continuation) = AsyncStream<Void>.makeStream()    init() {        listen()    }    func sendEvent() {        Task { [weak self] in            self?.publisher.send()            self?.continuation.yield()            try! await Task.sleep(nanoseconds: 1_000_000_000)            self?.publisher.send()            self?.continuation.yield()            try! await Task.sleep(nanoseconds: 1_000_000_000)            self?.publisher.send()            self?.continuation.yield()        }    }    private func listen() {        // Different listening methods will demonstrate varying memory management behaviors    }}var concurrency: Concurrency?concurrency = .init()concurrency?.sendEvent()Task {    try! await Task.sleep(nanoseconds: 100_000_000)    concurrency = nil}



The Concurrency class includes a publisher and a stream, both of which are involved in sending events asynchronously. Upon invoking sendEvent(), an event is sent every second, three times in total. However, to induce memory leaks, we set up a scenario where we call sendEvent() and then deallocate the Concurrency instance after a brief delay.



Listening Strategies


1. Non-Capture


private func listen_1() {    Task {        for await _ in publisher.values {            print("publisher 1")        }    }    Task {        for await _ in stream {            print("stream 1")        }    }}


In this approach, we listen to events without capturing anything. This results in memory leaks, as evidenced by the repeated print statements.


2. Only Capture Weak Self

private func listen_2() {    Task { [weak self] in        guard let self else { return }        for await _ in self.publisher.values {            print("publisher 2")        }    }    Task { [weak self] in        guard let self else { return }        for await _ in self.stream {            print("stream 2")        }    }}



Even capturing only a weak reference to self doesn't prevent memory leaks.


3. Capture Async Streams

private func listen_3() {    Task { [publisher] in        for await _ in publisher.values {            print("publisher 3")        }    }    Task { [stream] in        for await _ in stream {            print("stream 3")        }    }}



Here, we directly capture the publisher and stream instances, which successfully prevents memory leaks.


4. Weak Self with Unwrapping

private func listen_4() {    Task { [weak self] in        if let values = self?.publisher.values {            for await _ in values {                print("publisher 4")            }        }    }    Task { [weak self] in        if let s = self?.stream {            for await _ in s {                print("stream 4")            }        }    }}



By capturing weak references to self and unwrapping them within the task, we effectively avoid memory leaks.


Conclusion

When utilizing Swift Concurrency, it's crucial to adopt appropriate memory management strategies, especially when dealing with asynchronous tasks. Capturing async streams directly or using weak references with careful unwrapping ensures proper memory handling and prevents leaks.

Additionally, understanding how Swift's concurrency model interacts with memory management is essential for writing robust and efficient code in modern Swift applications.