đź§µ

Swift concurrency (async, await, Task, Sendable)

Basics
  • Task { ... } creates an async context, in which an async function can be called with await:
    • func f() -> Int async { return 1 } ... Task { let x = await f() }
 
What runs on main thread
Code inside a function
main thread?
@MainActor func f() async {
...
}
main
@MainActor func f() {
...
}
main
@concurrent func f() async {
...
}
non-main
nonisolated(nonsending) func f() async {
...
}
depends on caller
async func f() {
...
}
With Swift 6.2 NonisolatedNonsendingByDefault flag enabled, it depends on caller
Otherwise it’s non-main thread
func f() {
...
}
depends on caller
In the “depends” case, where it’s called from:
main thread?
From a Task with Task { in @MainActor
main
From a function with @MainActor
main
From a Task in a function with @MainActor
main
From Task.detached {}
non-main
From a Task in a function without @MainActor
depends on where the function is called
From a function without @MainActor , not in a Task
depends on where the function is called
 
Task cooperation
  • One task doesn’t necessarily start a new thread. If many tasks are created, a pool of threads (ten-ish) can be reused
  • If a task is suspended by Task.sleep it may resume on a different thread
  • Task started can continue to run after task.cancel(), but Task.sleep can detect cancellation, and throw error. This is true if sleep is inside a function. Eg URLSession.shared.data can also end a task that is canceled
  • Other than sleep, Task.yield() can also allow a task to suspend and let another task take the thread
 
Task local
  • variables local to a Task (and its children tasks) so they can be accessed without passing a param in a function
  • define the variable as a static var in an enum with @TaskLocal
  • Start a task with EnumName.$paramName.withValue(…) { … }
  • Accessing EnumName.paramName can get the value in any function, and the value only last within the scope. A nested withValue can override the value, and exiting that overriding scope goes back to the old value.
 
Sendable protocol
  • types that can be safely passed across concurrency boundaries
  • Immutable values of most simple value types are by default sendable
  • For class to be sendable, it needs to be final, and cannot store mutable values, etc
  • @Sendable vs @escaping
    • @escaping makes a closure to be able to passed across concurrency boundaries (pre-async/await)
      func load(completion: @escaping () -> Void) { URLSession.shared.data(...) { completion() } print("starting") } // completion closure can be run after `load` finishes, // so `completion` must be @escaping
    • @escaping has no compiler check to make sure that the closure has memory safety/avoids race conditions
    • @Sendable makes a closure to be passed across Task based concurrency boundaries
      Task { // this closure must be @Sendable }
    • @Sendable provides compiler check to make sure that everything passed in must conform to Sendable protocol
      • No mutable value types can be passed in (but can be capture as a readonly and passed in)
      • Reference type passed in must be @Sendable
  • Actor
    • A reference type that is automatically @Sendable
    • If it has value type property, inside the actor the property can be accessed synchronously. Outside the actor, the function must be called asynchronously
      • Except if the function is marked as nonisolated , in which case the function cannot access the stored properties
      ActorIsolated wrapper is a simple way to wrap a non-Sendable object
      struct Something: Sendable { private var userDefaults = ActorIsolated<UserDefaults>(UserDefaults.standard) ... func doSomething() { var x = await userDefaults.withValue { $0.integer(forKey: "key") } x += 1 await userDefaults.withValue { [x] in $0.set(x, forKey: "key") } } }
  • Some bail out options
    • Use @preconcurrency import to off concurrency checks for everything in the library
    • Make a struct/class @unchecked Sendable (e.g. struct A: @unchecked Sendable {), this way this struct is allowed to be passed across task boundaries
    • Swift-dependencies also gives us @UncheckedSendable property wrapper for a particular property
 
structured concurrency for parallelization
  • to parallelize 2 async function calls, the unstructured way:
    • Task { await func1() } Task { await func2() } func3()
      This is not ideal because in this case code doesn’t read from top to bottom. func3() happens before func1() and func2()
  • async let
    • async let a = func1() async let b = func2() let result = await a + b // func1 and func2 will run in parallel let c = func3()
      This way the code reads from top to bottom, func1() and func2() happens before func3()
  • withTaskGroup
    • await withTaskGroup(of: Data.self) { taskGroup in for item in someList { taskGroup.addTask { await asyncFunc(item) } } }
      This is more structured.
      Â