Search code examples
iosswiftasync-awaitconcurrencyasyncstream

AsyncStream not executing closure


so I'm reading the Modern Concurrency book from raywenderlich.com and I assume the book must be outdated or something, I'm trying to run the closure insde the AsyncStream but it doesn't seem to get there, I'm still pretty new to this Async/Await thing, but when adding some breakpoints I can see my code is not getting there. This is my code and a screenshot with some warnings showing. I am not really familiar with what the warnings mean, just trying to learn all this new stuff, I would truly appreciate some help and is there a way to fix it with Swift 6? Thanks in advance!

Reference to captured var 'countdown' in concurrently-executing code; this is an error in Swift 6

Mutation of captured var 'countdown' in concurrently-executing code; this is an error in Swift 6

func countdown(to message: String) async throws {
    guard !message.isEmpty else { return }
    var countdown = 3

    let counter = AsyncStream<String> { continuation in
      Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { timer in
          continuation.yield("\(countdown)...")
          countdown -= 1
        }
    }

    for await countDownMessage in counter {
      try await say(countDownMessage)
    }
 }

enter image description here


Solution

  • Timer.scheduleTimer requires that it be scheduled on a run loop. In practical terms, that means we would want to schedule it on the main thread’s run loop. So, you either call scheduleTimer from the main thread, or create a Timer and manually add(_:forMode:) it to RunLoop.main . See the Scheduling Timers in Run Loops section of the Timer documentation.

    The easiest way would be to just isolate this function to the main actor. E.g.,

    @MainActor
    func countdown(to message: String) async throws { … }
    

    There a few other issues here, too:

    1. I would suggest defining the countdown variable within the AsyncStream:

      @MainActor
      func countdown(to message: String) async throws {
          guard !message.isEmpty else { return }
      
          let counter = AsyncStream<String> { continuation in
              var countdown = 3
              Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { timer in
                  continuation.yield("\(countdown)...")
                  countdown -= 1
              }
          }
      
          for await countDownMessage in counter {
              try await say(countDownMessage)
          }
      }
      
    2. The AsyncStream is never finished. You might want to finish it when it hits zero:

      @MainActor
      func countdown(to message: String) async throws {
          guard !message.isEmpty else { return }
      
          let counter = AsyncStream<String> { continuation in
              var countdown = 3
              Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { timer in
                  continuation.yield("\(countdown)...")
      
                  // presumably you want this countdown timer to finish when it hits zero
      
                  guard countdown > 0 else {
                      timer.invalidate()
                      continuation.finish()
                      return
                  }
      
                  // otherwise, decrement and carry on
      
                  countdown -= 1
              }
          }
      
          for await countDownMessage in counter {
              try await say(countDownMessage)
          }
      }
      
    3. There should be a continuation.onTermination closure to handle cancelation of the asynchronous sequence.

      @MainActor
      func countdown(to message: String) async throws {
          guard !message.isEmpty else { return }
      
          let counter = AsyncStream<String> { continuation in
              var countdown = 3
              let timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { timer in
                  continuation.yield("\(countdown)...")
      
                  // presumably you want this countdown timer to finish when it hits zero
      
                  guard countdown > 0 else {
                      timer.invalidate()
                      continuation.finish()
                      return
                  }
      
                  // otherwise, decrement and carry on
      
                  countdown -= 1
              }
      
              continuation.onTermination = { _ in
                  timer.invalidate()
              }
          }
      
          for await countDownMessage in counter {
              try await say(countDownMessage)
          }
      }
      

    Going back to the original question (why this is not running), I personally would avoid the use of Timer in conjunction with Swift concurrency at all. A GCD timer would be better, as it doesn’t require a RunLoop. Even better, I would advise Task.sleep. Needless to say, that is designed to work with Swift concurrency, and also is cancelable.

    I personally would suggest something like:

    func countdown(to message: String) async throws {
        guard !message.isEmpty else { return }
        
        let counter = AsyncStream<String> { continuation in
            let task = Task {
                for countdown in (0...3).reversed() {
                    try await Task.sleep(for: .seconds(1))
                    continuation.yield("\(countdown)...")
                }
                
                continuation.finish()
            }
            
            continuation.onTermination = { _ in
                task.cancel()
            }
        }
        
        for await countDownMessage in counter {
            try await say(countDownMessage)
        }
    }