8.1 KiB
Async Contexts
Virtually all programs need to make at least one transition from a synchronous context to an asynchronous one.
Ad-Hoc
You need to call some async function from a synchronous one.
func work() async throws {
}
Solution #1: Plain Unstructured Task
// Hazard 1: Ordering
Task {
// Hazard 2: thrown errors are invisible
try await work()
}
Solution #2: Typed Unstructured Task
Adding explicit return/error types to the Task will make it impossible to accidentally ignore thrown errors.
// Hazard 1: Ordering
Task<Void, Never> {
try await work()
}
Background Work
This is an extremely common pattern in Swift code: You need to kick off some work from the main thread, complete it in the background, and then update some state back on the main thread. For example, you might need to add a spinner and disable some buttons in the "before" stage, do an expensive computation in the "background" stage, then update the UI again in the "after" stage. Using DispatchQueues, you could write the code like this:
final class DemoViewController: UIViewController {
func doWork() {
// We assume we're on the main thread here
beforeWorkBegins()
DispatchQueue.global().async {
let possibleResult = expensiveWork(arguments)
DispatchQueue.main.async {
afterWorkIsDone(possibleResult)
}
}
}
}
This DispatchQueue pattern provides the following properties:
- Ordering: You know
beforeWorkBegins()runs beforeexpensiveWorks()which runs beforeafterWorkIsDone(). - Thread-safety: You know
beforeWorkBegins()andafterWorkIsDone()run on the main thread andexpensiveWork()runs on a background thread. - "Immediacy": There is no "waiting" before running
beforeWorkBegins().
There are different recipes for recreating this pattern in Swift Concurrency and preserving these properties depending upon:
- Does an asynchronous wrapper exist for
expensiveWork()? - Does the immediacy property need to be preserved for a synchronous or asynchronous context?
Solution #1: Asynchronous wrapper exists & "synchronous immediacy"
This recipe assumes there is an asynchronous wrapper for expensiveWork() and ensures that beforeWorkBegins() runs without any waiting in the caller's synchronous context.
final class DemoViewController: UIViewController {
func doWork() {
// hazard 1: timing.
//
// If you moved this to inside the `Task`, you'd introduce a "wait" before
// executing `beforeWorkBegins()`, losing the "immediacy" property.
beforeWorkBegins()
// hazard 2: ordering
//
// While this recipe guarantees that `beforeWorkBegins()` happens before
// `asyncExpensiveWork()`, if there are any **other** Tasks that are
// created by the caller, there is no ordering guarantees among the tasks.
// This differs from the DispatchQueue.global().async world, where blocks
// are started in the order in which they are submitted to the queue.
Task {
// MainActor-ness has been inherited from the creating context.
// hazard 3: lack of caller control
// hazard 3: sendability (for both `result and `arguments`)
let result = await asyncExpensiveWork(arguments)
// post-await we are now back on the original, MainActor context
afterWorkIsDone(result)
}
}
}
Solution #2: Asynchronous wrapper exists & "asynchronous immediacy"
If all of the callers of doWork() are already in asynchronous contexts, or if the callers can easily be made asynchronous (beware the "async virality" hazard), you can use this recipe:
final class DemoViewController: UIViewController {
// hazard 1: async virality. Can you reasonably change all callsites to `async`?
func doWork() async {
beforeWorkBegins()
let result = await asyncExpensiveWork(arguments)
afterWorkIsDone(result)
}
}
This recipe preserves immediacy of beforeWorkBegins() for any asynchronous callers and avoids introducing additional unstructured Task operations.
Solution #3: No async wrapper exists for expensiveWork()
If there is no async wrapper for expensiveWork(), you cannot directly use Solutions 1 or 2.
One option you have: Write your own async wrapper, then proceed with Solution 1 or 2!
func asyncExpensiveWork(arguments: Arguments) async -> Result {
await withCheckedContinuation { continuation in
// Hazard: Are you using the appropriate quality of service queue?
DispatchQueue.global.async {
let result = expensiveWork(arguments)
continuation.resume(returning: result)
}
}
}
Yes, this just sneaks DispatchQueue.global.async into the Swift Concurrency world. However, this seems appropriate: You won't tie up one of the threads in the cooperative thread pool to execute expensiveWork(). Another advantage of this approach: It's now baked into the implementation of asyncExpensiveWork() that the expensive stuff happens on a background thread. You can't accidentally run the code in the main actor context.
Sidenote To Swift, func foo() and func foo() async are different and the compiler knows which one to use depending on if the callsite is a synchronous or asynchronous context. This means your async wrappers can have the same naming as their synchronous counterparts:
func expensiveWork(arguments: Arguments) async -> Result {
await withCheckedContinuation { continuation in
DispatchQueue.global().async {
let result = expensiveWork(arguments)
continuation.resume(returning: result)
}
}
}
Solution #4: No async wrapper exists and you don't want to write one
final class DemoViewController: UIViewController {
func doWork() {
// Work must start here, because we're going to explicitly hop off the MainActor
beforeWorkBegins()
// hazard 1: ordering
Task.detached {
// hazard 2: blocking
// hazard 3: sendability (arguments)
let possibleResult = expensiveWork(arguments)
// hazard 4: sendability (possibleResult)
await MainActor.run {
afterWorkIsDone(possibleResult)
}
}
}
}
Order-Dependent Work
You need to call some async function from a synchronous one and ordering must be preserved.
func work() async throws {
}
Solution #1: use AsyncStream as a queue
// define a sequence that models the work (can be less-general than a function)
typealias WorkItem = @Sendable () async throws -> Void
let (stream, continuation) = AsyncStream<WorkItem>.makeStream()
// begin enumerating the sequence with a single Task
// hazard 1: this Task will run at a fixed priority
Task {
// the sequence guarantees order
for await workItem in stream {
try? await workItem()
}
}
// the continuation provides access to an async context
// Hazard 2: per-work item cancellation is unsupported
continuation.yield({
// Hazard 3: thrown errors are invisible
try await work()
})
Program Entry
This one is less-common for app developers, but handy for CLI tools and other programs that need a programmer-defined entry point.
Solution #1: Top-Level Code
Swift's top level code allows you to both throw and await.
// swift test.swift
func doThing() async throws {
try await Task.sleep(nanoseconds: 1_000_000_000)
}
try await doThing()
print("finished")
Solution #2: @main
Remember that Swift does not support using @main with a file that is inferred to have top-level code. It's confusing, but does make sense as this would result in an ambiguous start point.
// swiftc -parse-as-library test.swift
// ./test
func doThing() async throws {
try await Task.sleep(nanoseconds: 1_000_000_000)
}
@main
struct ProgramEntry {
static func main() async throws {
try await doThing()
print("finished")
}
}