Basics
Task { ... }creates an async context, in which an async function can be called withawait:
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 callerOtherwise 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.sleepit may resume on a different thread
- Task started can continue to run after
task.cancel(), butTask.sleepcan detect cancellation, and throw error. This is true if sleep is inside a function. EgURLSession.shared.datacan 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.paramNamecan get the value in any function, and the value only last within the scope. A nestedwithValuecan 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
@Sendablevs@escaping@escapinghas no compiler check to make sure that the closure has memory safety/avoids race conditions@Sendableprovides compiler check to make sure that everything passed in must conform toSendableprotocol- 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
@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
@Sendable makes a closure to be passed across Task based concurrency boundaries
Task { // this closure 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 importto 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
@UncheckedSendableproperty 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()
func1() and func2() happens before func3()- withTaskGroup
await withTaskGroup(of: Data.self) { taskGroup in for item in someList { taskGroup.addTask { await asyncFunc(item) } } }
Â