Swift Concurrency Cheat Sheet

2023/06/17

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 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)
}

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)
            }
        }
    }
}

Task

Task {
    let data = try await fetchData()
}

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

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)
}
  1. 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.
  2. another async let property is created for some other async work, the child task will be run immediately and asynchronously for both async let when they are defined
  3. to get the result of the async function, await the child task, here we assign the result of the async let to a property that will hold the result of the child task
  4. since both tasks are started immediately when they are created and asynchronously, there’s a chance that briefTask has already finished at this point.

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
}

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

fnc usingProcessedData() async -> String {
    let data = await dataFetcher.processAndReturnData()
    return data.toString()
}
actor Utilities {
    private let currencyFormatter = MyCurrencyFormatter()
    nonisolated func defaultCurrencyFormatter() -> MyCurrencyFormatter {
        currencyFormatter
    }
}

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:

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:

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.

rules that make a value type implicitly conform to Sendable:

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:

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:

Async Sequence

An AsyncSequence resembles the Sequence type - offering a list of values you can step through one at a time - and adds synchronicity. An AsyncSequence may have all, some, or none of its values available when you first use it. Instead, you use await 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 {
    // …
}

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:

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)
}
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
}