How do you bridge repeated events, GCD and Combine into async code with AsyncStream?

10.6.2 · L3 · Interview

Predict first

A stream yields 1, 2, 3 and finishes. What does for await collect?

let stream = AsyncStream<Int> { c in
    for i in 1...3 { c.yield(i) }
    c.finish()
}
var got = [Int]()
for await v in stream { got.append(v) }
print(got)
  • [1, 2, 3]
  • [3]
  • []

Predict first

Five values are yielded before anything is consumed, with a newest-1 buffer. What is collected?

let stream = AsyncStream<Int>(bufferingPolicy: .bufferingNewest(1)) { c in
    for i in 1...5 { c.yield(i) }
    c.finish()
}
var got = [Int]()
for await v in stream { got.append(v) }
print(got)
  • [1]
  • [5]
  • [1, 2, 3, 4, 5]

Mental model. A continuation is a one-time reply. A stream is a conveyor belt: the producer puts items on it, the consumer takes them off, and the belt has a rule for what to do when it is full.

When an API delivers many values over time (delegate callbacks, notifications, location updates, a Combine publisher) wrap it in an AsyncStream (non-throwing) or AsyncThrowingStream. You build it with a closure that receives a continuation: call continuation.yield(value) for each event, finish() (or finish(throwing:)) when done, and set continuation.onTermination to clean up (remove the observer, stop the location manager) when the consumer stops or the task is cancelled. The consumer then writes for await value in stream { … }, which is cancellable and ends when the task is cancelled. A stream has a buffering policy: the default .unbounded keeps every value until consumed, .bufferingNewest(n) keeps only the latest n (right for state such as the current location: with newest-1, five rapid yields leave only the last), and .bufferingOldest(n) drops later ones. A stream is single-consumer by default; iterating it from two tasks splits the values.

Other bridges. A Combine publisher has a .values property that returns an async sequence (handle that it is not backpressure-aware in the same way); the other direction is a Future wrapping a Task. A GCD API with a completion handler becomes async through a continuation; moving from async to a GCD API means calling it inside a task, and a DispatchQueue can be turned into a custom actor executor if you must keep ordering with legacy code. Use AsyncStream.makeStream() to get the stream and continuation separately, which is convenient when the producer lives elsewhere. Test bridges for cancellation (the underlying resource is released) and for the exactly-once or ends-once contract.

The default buffer keeps all values until they are consumed, and finish() ends the sequence.

The buffer holds only the newest value, so older unread values are dropped.

Common traps

  • Forgetting onTermination. The observer or hardware keeps running after the consumer is gone.
  • Using an unbounded stream for fast, state-like events. It buffers everything; use bufferingNewest(1).

They might follow up with

  • How many values does a newest-1 stream keep? Only the latest one that the consumer has not yet read.
  • How do you turn a Combine publisher into for await? Iterate publisher.values.

Related questions

  • 10.6.1 How do continuations bridge callback APIs, and what is the difference between checked and unsafe?
  • 10.3.3 How does cancellation work in Swift concurrency, and what are task priorities and task locals for?
  • 10.3.1 What does async/await actually do, and what is a suspension point?

← How do continuations bridge callback APIs, and what is the difference…