Swift Concurrency Cheat Sheet
This article is meant to be a cheat sheet of Swift concurrency framework, rather than a introduction of Swift or concurrency, if you encounter terminologies that you do not understand, please refer to relevant materials. If there’re many things you don’t understand you may need to first build yourself a foundation of Swift. If you’re using Swift Concurrency at work but still got confused with a constant lack of understanding, you may want to consider a career change - in the long run, this is not necessarily a bad thing.
Before async/await
GCD: created work item as closure and schedule on queue to run asap.
DispatchQueue.main
- main threadDispatchQueue.global()
- multiple work items in parallel
DispatchQueue
can also schedule synchronous work, caller will wait for the work to complete.
DispatchGroup: tracks the number of tasks started and completed, run a final work once all tasks are completed.
let group = DispatchGroup()
group.enter()
manager.startAndWhenFinish {
group.leave()
}
group.enter()
manager.startAndWhenFinish {
group.leave()
}
group.notify(queue: DispatchQueue.main) {
somethingUpdateUI()
}
Semaphore: an object that keeps track of how much a given resource is available, forces code to wait for a resource to become available before the code can proceed. For a single resource item that only one access can be given at any time, NSLock
(ie. binary semaphore) can be used.
DispatchSemaphore:
class Cache<Key: Hashable, T> {
private var cache: [Key: T] = [:]
private let semaphore: DispatchSemaphore(value: 1)
func getValue(forKey key: Key) -> T? {
semaphore.wait()
let value = cache[key]
semaphore.signal()
return value
}
func setValue(_ value: T, forKey key: Key) {
semaphore.wait()
cache[key] = value
semaphore.signal()
}
}
NSLock:
class Cache<Key: Hashable, T> {
private var cache: [Key: T] = [:]
private let lock = NSLock()
func getValue(forKey key: Key) -> T? {
lock.lock()
let value = cache[key]
lock.unlock()
return value
}
func setValue(_ value: T, forKey key: Key) {
lock.lock()
cache[key] = value
lock.unlock()
}
}
async/await
The async/await based code reads like synchronous code while still being non-blocking for the duration of the call.
asynchronous function: add async
before the return type; If the function can throw error, add async throws
. Within async
function, you can await
other async functions, if the function can throw, use try await
.
func myFunction() async throws -> MyObject {
await fetchData()
}
let myObject = try await myFunction()
suspension point: when execution reaches await
, it may suspend the task in current thread.
asynchronous readonly property: add async
after its get
. Async property must be readonly, from Swift 5.5, asynchronous property can throw error, in this case, add throws
after async
:
var thumbnail: UIImage? {
get async throws {
try await self.fetchThumbnail()
}
}
Async property setter: has to consider the behaviour of inout
and when should didSet
and willSet
be invoked, etc. To asynchronously set a property, use asynchronous function:
var value: Int
func updateValue(newValue: Int) async throws {
await validateValue(newValue)
try checkShouldWrite(newValue)
value = newValue
}
asynchronous function parameter
func doSomething(worker: (Work) async -> Void) -> Outcome {
// …
}
doSomething { work in
return await process(work)
}
asynchronous properties in protocol
protocol Patient {
var isRecovered: Bool { get async throws }
}
asynchronous sequence
for try await value in someAsyncSequence {
process(value)
}
- an async function that’s not bound to any actor will always run on the default executor
- executor: a low-level mechanism that takes jobs an runs them
- default executor runs jobs on the shared thread pool - not main thread unless explicitly tied to main actor
- an async function does not run on the thread it was called from, the function itself decides whether it’s ran on the main actor or on background thread
Converting callbacks to async/await
A good strategy to convert existing callback based functions to async/await is to bridge between async/await and callback based functions by adding an async/await function alongside:
func fetchDataModel(_ id: String,
completion: @escaping (Result<DataModel, Error>) -> Void)
async/await:
func fetchDataModel(_ id: String) async throws -> DataModel {
try await withCheckedThrowingContinuation { continuation in
fetchDataModel(id) { result in
switch result {
case let .sccess(dataModel):
continuation.resume(returning: dataModel)
case let .error(error):
continuation.resume(throwing: error)
}
}
}
}
- always make sure the continuation is resumed at some point and only resumed once
- checked continuation: performs runtime checks to ensure continuation isn’t compelted more than once, and continuation doesn’t outlive the scope that created it; If any rule’s violated, runtime crashes with a meaningful message
Task
Task {
let data = try await fetchData()
}
- create a task when you want to execute something asynchronously from a non-async context, e.g.
viewDidLoad
; Or when you want to run some work concurrently with other work - every Task created will begin running immediately, and concurrently with other tasks
You can give up your thread by calling Task.yield()
in between performing heavy work to allow the system to suspend the task between each file processing:
Task {
for file in files {
processFile(file)
await Task.yield()
}
}
Task and Error Handling
- tasks have implicit
self
capture, making it easy to accidentally capture a strongself
reference - A task created with
Task
orTask.detached
will swallow any errors thrown from inside of the task body, it’s a good practice to at least add ado…catch…
:
Task {
do {
let data = try await fetchData()
} catch {
// even if we do nothing here
}
}
Task can be assigned to a property, it can then produce a value eventually, even if the task returns nothing:
let task = Task {
return try await fetchData()
}
let data = try await task.value
Child Task via async let
Use async let
to run multiple asynchronous tasks in parallel.
detail is fetched only after brief is fetched:
func fetchData(id: String) async throws -> (Brief, Detail) {
let brief = try await fetchDataBriefFor(id)
let detail = try await fetchDataDetailFor(id)
return (brief, detail)
}
brief and detail are fetched in parallel:
func fetchData(id: String) async throws -> (Brief, Detail) {
async let briefTask = fetchDataBriefFor(id) // 1
async let detailTask = fetchDataDetailFor(id) // 2
let brief = try await briefTask // 3
let detail = try await detailTask // 4
return (brief, detail)
}
- a child task is created when
async let
property is defined by calling and assigning async function to the property, no waiting is required at this point. - another
async let
property is created for some other async work, the child task will be run immediately and asynchronously for bothasync let
when they are defined - to get the result of the async function,
await
the child task, here we assign the result of theasync let
to a property that will hold the result of the child task - since both tasks are started immediately when they are created and asynchronously, there’s a chance that
briefTask
has already finished at this point.
- a child task is cancelled when its parent task is cancelled
- a parent task cannot complete until its child tasks have completed (either successful or with error)
- a cancelled parent task does not stop the child task, the child task must check for cancellation and respect it by stopping its work
- priority, actors and task local values are inherited from parent task
TaskGroup
With a TaskGroup
it is possible to add any number of child tasks that run as part of task group. To create a TaskGroup
, call withThrowingTaskGroup(of:_:)
or withTaskGroup(of:_:)
depending on whether or not the child tasks can throw errors.
func withThrowingTaskGroup<ChildTaskResult, GroupResult>(
of childTaskResultType: ChildTaskResult.Type,
returning returnType: GroupResult.Type = GroupResult.self,
body: (inout ThrowingTaskGroup<ChildTaskResult, any Error>)
async throws -> GroupResult
) async rethrows -> GroupResult where ChildTaskResult : Sendable
Example:
let dataModels = try await withThrowingTaskGroup(of: DataModel.self) { group in
for source in dataSources {
group.addTask(operation: {
try await self.fetchDataModel(from: source)
})
}
var results = [DataModel]()
for try await result in group {
results.append(result)
}
return results
}
- the first argument passed to a task group is the type of elements that the child tasks produce
- in a task group, all child tasks must produce the same type as output
- if child tasks perform some work without producing output, use
Void.self
as the task group’s output - the type of output must be
Sendable
- the second argument passed to
TaskGroup
is a closure, called with one argument - a group- works can be added to the group by calling
addTask
- group can be iterated over with async for loop to obtain results
- works can be added to the group by calling
- all tasks added to a task group must be completed before code resumes from
await withTaskGruop…
- if a child task in a throwing task group throws an error, the task group itself will implicitly swallow that error, ie.
try await withThrowingTaskGroup…
would never receive the error that’s thrown by child task- as soon as an error is thrown from task group, all child tasks get cancelled immediately, every child task must stop as soon as it can, to respect the cancellation action
- once all child tasks have stopped their work, the thrown error from the child task is rethrown by the
TaskGroup
, allowing caller to handle the error
- the closure passed to
withTaskGroup
orwithThrowingTaskGroup
can return a value, which will be the result for the call towith(Throwing)TaskGroup
, and it doesn’t have to have the same type as the result of child tasks
Tasks with Different Types
Using TaskGroup to fetch data from different URLs with different types.
Source:
enum NewsItemType {
case article, photo, ads
}
struct NewsItem {
let id: String
let type: NewsItemType
}
Data models:
struct ArticleItem {
let title: String
let author: String
let category: String
let content: String
}
struct PhotoItem {
let photographer: String
let location: String
let url: URL
}
struct AdsItem {
let imageUrl: URL
let sponsorName: String
}
Define an enum that has cases with associated types, to cover all types of news item under a single type.
enum PopulatedNewsItem {
case article(ArticleItem)
case photo(PhotoItem)
case ads(AdsItem)
}
Now we can create a TaskGroup
that fetches all items based on given source:
let newsItems: [NewsItem] = // get news items from somewhere
let populatedItems = try await withThrowingTaskGroup(of: PopulatedNewsItem.self) { group in
for item in newsItems {
group.addTask {
switch item.type {
case .article:
let articleItem = try await fetchArticleItem(withID: item.id)
return PopulatedNewsItem.article(articleItem)
case .photo:
let photoItem = try await fetchPhotoItem(withID: item.id)
return PopulatedNewsItem.photo(photoItem)
case .ads:
let adsItem = try await fetchAdsItem(withID: item.id)
return PopulatedNewsItem.ads(adsItem)
}
}
}
var results = [PopulatedNewsItem]()
while let result = await group.nextResult() {
do {
let item = try result.get()
results.append(item)
} catch {
print("Error: \(error)")
}
}
return results
}
Actor
Actors are a new kind of type in Swift. They provide the same capabilities as all of the named types in Swift. They can have properties, methods, initializers, subscripts, and so on. They can conform to protocols and be augmented with extensions.
Consider preventing data racing in pre-concurrency world:
class DataFetcher {
func processAndReturnData() {
// process data
return data
}
}
Using `NSLock`:
```swift
class DataFetcher {
private let lock = NSLock()
func processAndReturnData() {
lock.lock()
defer { lock.unlock() }
// process data
return data
}
}
Using actor we just need to change class DataFetcher
to actor DataFetcher
, then when we call dataFetcher.processAndReturnData()
we’ll get an error:
Actor-isolated instance method cannot be referenced from a non-isolated context
- actor serializes access to its mutable state
- when we interact with an actor, it receives a message saying we want to do so, then it takes the first message in its message list, process it, then take next message from the list and so on
fnc usingProcessedData() async -> String {
let data = await dataFetcher.processAndReturnData()
return data.toString()
}
- calling a function or accessing a property on an actor requires the operation be
await
ed, with the exception where the compiler knows we’re already in the actor’s isolation context let
constants can be freely accessed from anywhere withoutawait
due to its immutability- if we are certain that a given function is safe to call concurrently without isolation we can mark it
nonisolated
:
actor Utilities {
private let currencyFormatter = MyCurrencyFormatter()
nonisolated func defaultCurrencyFormatter() -> MyCurrencyFormatter {
currencyFormatter
}
}
- whenever an actor hits an
await
, it suspends what it’s doing at the time and potentially starts processing the next message in its list
Actor Reentrancy
Actor reentrancy means whenever an actor is awaiting something, it can go ahead and pickup other messages from its message list until the awaited job completes and the function that got suspended can resume.
New data request can be fired for the same url
that a previous request has been made for, in case if the previous request hasn’t come back with a result:
actor ImageLoader {
private var imageData: [UUID: Data] = [:]
func loadImageData(using id: UUID) async throws -> Data {
if let data = imageData[id] { return data }
let url = buildURL(using: id)
let (data, _) = try await URLSession.shared.data(from: url)
imageData[id] = data
return data
}
}
Solution:
- do we have a cached state, loading or loaded for the requested resource
- if it’s loading, return the result of the in progress loading operation
- if it’s loaded, return the cached resource
- if we don’t have a cached state, create a new loading operation for the requested resource and cache it
- await the result of the loading operation
- cache the loaded resource
- return the loaded resource
extension ImageLoader {
enum LoadingState {
case loading(Task<Data, Error>)
case completed(Data)
}
}
actor ImageLoader {
private var imageData: [UUID: LoadingState] = [:]
func loadImageData(using id: UUID) async throws -> Data {
if let state = imageData[id] {
switch state {
case .loading(let task):
return try await task.value
case .completed(let data):
return data
}
}
let task = Task<Data, Error> {
let url = buildURL(using: id)
let (data, _) = try await URLSession.shared.data(from: url)
return data
}
imageData[id] = .loading(task)
do {
let data = try await task.value
imageData[id] = .completed(data)
return data
} catch {
imageData[id] = nil
throw error
}
}
}
Global Actor
When you need to run code on main thread, you can use @MainActor
, it’s an actor that synchronizes its work on the main thread.
Global annotation can be applied to:
- function
- object (class, struct)
- closure
- property
Interactions with annotated object occur on the main thread, often view models and observed objects for SwiftUI are annotated with main actor because they often end up triggering UI updates.
Annotating a full object as @MainActor
will make the entire object looking like defined as an actor because access to all states on the object will be subject to actor isolation. But if your goal is to synchronize mutable states rather than requiring the code runs on main thread, it’s better to define the class as actor.
Sendability
Sendable: objects and closures that can be safely passed from one concurrency context to another, potentially passing boundaries between threads. Sendable is defined as a protocol without required properties or methods.
To turn on safety check by compiler: Build Settings -> Strict Concurrency, “Targeted” includes sendability, actor isolation check; “Complete” includes all “Targeted” plus more checks and Swift 6 features.
- value types can usually be passed around in application with concurrency code without problem
- struct isn’t automatically Sendable, e.g. if a struct has a property that’s a non-sendable type.
rules that make a value type implicitly conform to Sendable
:
- All members of the struct or enum must be sendable, and:
- the struct or enum is marked as frozen, or
- the struct or enum is not public, or
- the struct or enum is not marked as
@useableFromInline
To make the struct or enum sendable even if it doesn’t meet all requirements, manually add Sendable
conformance (at your own risk):
public struct SomeModel: Sendable {
let somethingNotSendable: NotSendableType
}
actors are always considered to conform to Sendable
implicitly
Classes can be manually marked to conform to Sendable
:
- a sendable class must be final
- all properties on this class must be sendable and immutable (ie.
let
) - the class cannot have any superclass other than
NSObject
- class annotated with
@MainActor
are implicitly sendable due to their synchronization through the main actor, this is true regardless of the class’ stored properties being mutable or sendable
To force compiler accept Sendable
conformance without verification, mark the class as @unchecked Sendable
:
final class MayNotBe: @unchecked Sendable {
// …
}
Sendability of functions and closures
When a closure or function is Sendable
, the closure or function does not capture or use any non-sendable objects. We declare sendability using @Sendable
annotation.
var sample: @Sendable () -> Void
var sample: @Sendable @escaping () -> Void
@Sendable func someFunction() {}
One way to access the state of non-sendable object inside sendable closure or function, is to capture the state of that object. e.g.
doesn’t compile:
var sample: @Sendable () -> Void = {
if nonSendable.isReady {
// do something
}
}
capture the state instead of the object:
let isReady = nonSendable.isReady
var sample: @Sendable () -> Void = { [isReady] in
if isReady {
// do something
}
}
Downsides:
- cannot mutate the non sendable object
- doesn’t gaurantee always have the latest value
Async Sequence
An
AsyncSequence
resembles theSequence
type - offering a list of values you can step through one at a time - and adds synchronicity. AnAsyncSequence
may have all, some, or none of its values available when you first use it. Instead, you useawait
to receive values as they become available.
Sequence
:
let names = ["Tom", "Jerry"]
for name in names {
print(name)
}
AsyncSequence
:
let namesURL = URL(string: "https://names.somewhere/all")!
for try await name in namesURL.lines {
print(name)
}
To handle the possible error:
do {
for try await name in namesURL.lines {
// …
}
} cathc {
// …
}
- as soon as a name is fetched as a full line from the network, printed, we are suspended and waiting for the next name (line) to be loaded
- asynchronous for loop’s control flow is the same as that of a synchronous one, meaning:
- the loop can be exited using
break
- current loop can be skipped using
continue
- when we want finish the function inside a loop we can use
return
- the loop can be exited using
AsyncStream
AsyncStream conforms to AsyncSequence, providing a convenient way to create an asynchronous sequence without manually implementing an asynchronous iterator. In particular, an asynchronous stream is well-suited to adapt callback- or delegation-based APIs to participate with async-await.
class QuakeMonitor {
var quakeHandler: ((Quake) -> Void)?
func startMonitoring() {…}
func stopMonitoring() {…}
}
extension QuakeMonitor {
static var quakes: AsyncStream<Quake> {
AsyncStream { continuation in
let monitor = QuakeMonitor()
monitor.quakeHandler = { quake in
continuation.yield(quake)
}
continuation.onTermination = { @Sendable _ in
monitor.stopMonitoring()
}
monitor.startMonitoring()
}
}
}
for await quake in QuakeMonitor.quakes {
print("Quake: \(quake.date)")
}
print("Stream finisehd.")
All values yielded are buffered, even though some values might be yielded before receiving the continuation, all values will still be received. AsyncStream
lets you specify a bufferingPolicy
:
bufferingNewest(Int)
: only buffer recent specified number of valuebufferingNewest(1)
: only buffer 1 recent valuebufferingNewest(0)
: discards any values that weren’t received by the for loop immediately
bufferingOldest(Int)
: keep the first n values that were not yet received by a for loop and discards any new values
AsyncStream
also allows us to keep a reference to continuation
outside the closure that’s passed to the initialiser.
import CoreLocation
class LocationProvider: NSObject {
fileprivate let locationManager
fileprivate let continuation: AsyncStream<CLLocation>.Continuation?
override init() {
super.init()
locationManager.delegate = self
}
deinit {
continuation?.finish()
}
func requestPermissionIfNeeded() {
if locationManager.authorizationStatus == .notDetermined {
locationManager.requestWhenInUseAuthorization()
}
}
func startUpdatingLocation() -> AsyncStream<CLLocation> {
requestPermissionIfNeeded()
locationManager.startUpdatingLocation()
return AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in
continuation.onTermination = { [weak self] _ in
self?.locationManager.stopUpdatingLocation()
}
self.continuation = continuation
}
}
}
extension LocationProvider: CLLocationManagerDelegate {
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
for location in locations {
continuation?.yield(location)
}
}
}
Problem: we can only call startUpdatingLocation
once, calling it twice will result the previously created stream never ending or receiving values. We can, reuse the async stream:
// …
private var stream: AsyncStream<CLLocation>?
func startUpdatingLocation() -> AsyncStream<CLLocation> {
if let stream {
return stream
}
// …
}
// …
Problem: multiple for loops receive values from a single stream but async sequences like AsyncStream
do not support sending values to multiple iterators.
We can use Combine to solve the problem because a key difference between an AsyncStream
and a Subject
is that a Subject
can have multiple subscribers.
class LocationProvider: NSObject {
fileprivate let locationManager = CLLocationManager()
fileprivate var subject = CurrentValueSubject<CLLocation?, Never>(nil)
}
- a
CurrentValueSubject
takes two generic arguments, one for the type of objects that it emits, another for the type of error it can produce CurrentValueSubject
also need an initial value
func startUpdatingLocation() -> AnyPublisher<CLLocation, Never> {
requestPermissionIfNeeded()
locationManager.startUpdatingLocation()
return subject
.compactMap { $0 }
.eraseToAnyPublisher()
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
for location in locations {
subject.send(location)
}
}
To use it:
var cancellables = Set<AnyCancellable>()
let location = provider.startUpdatingLocation()
location.sink { location in
// handle location
}.store(in: &cancellables)
To convert the Combine publisher into an async sequence:
func startUpdatingLocation() -> AsyncPublisher<AnyPublisher<CLLocation, Never>> {
requestPermissionIfNeeded()
locationManager.startUpdatingLocation()
return subject
.compactMap { $0 }
.eraseToAnyPublisher()
.values
}