Swift Async and Concurrent

Reading notes of the book Swift 异步和并发

Swift Concurrency Usage Guide

  • Task
    • Begins an async task from a sync method.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    func reloadView(with data: Data?) { ... }
    func requestData() async throws -> Data? { ... }

    func reloadData() {
    Task {
    let data = try await requestData()
    reloadView(with: data)
    }
    }
  • Continuation Methods
    • Used to convert the async patterns of callback or delegate to async/await format in Swift environment.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    func withUnsafeContinuation<T>(
    _ fn: (UnsafeContinuation<T, Never>) -> Void
    ) async ->" T

    func withUnsafeThrowingContinuation<T>(
    _ fn: (UnsafeContinuation<T, Error>) -> Void
    ) async throws -> T

    func withCheckedContinuation<T>(
    function: String = #function,
    _ body: (CheckedContinuation<T, Never>) -> Void
    ) async -> T

    func withCheckedThrowingContinuation<T>(
    function: String = #function,
    _ body: (CheckedContinuation<T, Error>) -> Void
    ) async throws -> T
    • Having the Throwing word means the methods can throws an error.
    • Having the Checked word means Swift will assist in checking these situations that the continuation doesn’t be invoked or be invoked multiple times at runtime. (The first may leak memory and the second will trigger a crash.)
  • Task.detached
    • Used to convert async/await methods to the async patterns of block or delegate for Objective-C.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    @objc func calculate(
    input: Int,
    completionHandler: @escaping (Int) !" Void)
    {
    Task.detached {
    let value = await calculate(input: input)
    completionHandler(value)
    }
    }
    • In Objective-C, have nothing available Task contexts, so we need use Task.detached to create an completely isolated async environment.
  • withTaskGroup or async let
    • Used to execute multiple async tasks simultaneously.
    1
    2
    3
    4
    5
    6
    async let loadHeaderInfo = requestHeaderInfo()
    async let loadContentInfo = requestContentInfo()
    // The two async tasks above have started executing.

    let headerInfo = try await loadHeaderInfo
    let contentInfo = try await loadContentInfo
    1
    2
    3
    4
    5
    6
    7
    8
     await withThrowingTaskGroup(of: Void.self) {  group in
    group.addTask {
    try await requestHeaderInfo()
    }
    group.addTask {
    try await requestBodyInfo()
    }
    }
  • AsyncSequence and AsyncIterator
    • Similar to LazySequence, you can use for try await element in asyncSequence to async iterate a sequence.
    1
    2
    3
    4
    5
    struct TimerSequence { ... } // Return the current time once a second.

    for await currentTime in TimerSequence() {
    print(currentTime) // Will print the current time once a second forever.
    }
  • AsyncStream
    • Used to convert the callback or delegate that may be invoked multiple times(like progress callback) to async/await format.
  • withTaskCancellationHandler
    • Getting an immediate cancellation message when the task is canceled. Don’t need to check Task.isCanceled at next event coming.
  • try Task.checkCancellation()
    • Used to check the cancellation state of the current task. If the task is already canceled, throw an cancellation exception.
  • nonisolated
    • Used to mark a method or property of an actor excluded the isolated scope.
    • If an actor type needs to conform to a normal protocol that contains some getter properties or sync methods, you can mark these properties or methods with nonisolated to satisfy the protocol’s requirements.
  • isolated
    • Sometimes we want to invoke methods of normal classes or global functions in a special isolated scope.
    • If these methods have an parameter of actor types, you can mark the parameter with isolated , and then the function will execute in the parameter’s isolated scope.
    • Of course, maybe some of these methods have several parameters of actor types marked with isolated. These methods will be confused to execute in which isolated cope of these actor parameters. So do not mark several isolated parameters in one method, that would violate the thread-safety of actors.
  • @MainActor
    • Like a property wrapper, you can use it to modify a property, method or class statement. All marked properties and methods will be guaranteed running in an global actor(Main thread).
    • Many classes of UIKit were implicitly marked with @MainActor by Swift.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    class MainViewController: UIViewController {
    let titleLabel = UILabel()

    func updateTitle() {
    /* Task.init will run the block code in the isolated scope of the current actor that creates it.*/
    Task {
    let title = await requestTitle()
    titleLabel.text = title
    }
    }
    }
  • @globalActor
    • A modifier that can used to custom you own global actors just like @MainActor.
    1
    2
    3
    4
    @globalActor actor MyActor {
    static let shared = MyActor()
    private init() {}
    }
    • A global actor needs having a singleton shared. Mind you, an instance of the MyActor is a isolated actor that doesn’t equal to the shared actor.
  • @Sendable & protocol Sendable
    • When we execute an async method in a task, we might need to pass a value out of the isolated scope as the async method’s parameter.
    • The value might be accessed simultaneously by other tasks, they would read/write the same memory at the same time. That will make a crash.
    • So Swift requires that all value passed to isolated scopes must be Sendable. Sendable is a marker protocol that doesn’t require any properties or methods, only used to be a compiler mark. A Sendable value must ensure its value can be safely accessed by multiple threads at the same time.
    • As Swift agreed:
      • All basic value types are implicitly Sendable.
      • Structures, enums, or other container types(Dictionary, Array) are implicitly inferred to be Sendable type when their all properties or elements are also of Sendable type.
      • A class that marked with final, conforming to Sendable protocol, and all properties are marked with let and of Sendable type can be as a Sendable value.
      • A class that uses other technologies(like lock or serial queue) to ensure thread safety without conforming to the requirements above can be announced to conform to @unchecked Sendable to become a Sendable type.
  • @unchecked Sendable
  • @_unsafeSendable
    • Used to mark a method or block to be sendable and do not check wether the passed parameters or captured variables are Sendable.
  • @TaskLocal
    • Used to passthrough values from super tasks to child tasks, likes Task.isCancelled.
    • Must be a static variable property.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    enum Log {
    @TaskLocal static var id: String = ""
    }
    Log.$id.withValue("room") {
    Task {
    print("Log id: \(Log.id)") /* room */
    await Log.$id.withValue("inner") {
    Task {
    print("Log id: \(Log.id)") /* inner */
    }
    }
    print("Log id: \(Log.id)") /* room */
    }
    }

What’s the concurrent?

Before learning the concurrent, we should first understand the two concepts below.

  • Synchronous
    In one thread, the second method must wait for the first return or is thrown to execute. The first method needing a long time to perform, the thread would be blocked. If the thread is main, users would feel stumble.

  • Asynchronous
    Being asynchronous, the content of the first method would be executed in another thread, itself will return speedily. In Swift or Objective-C, we usually use GCD to execute an asynchronous method in background threads and use a callback block to fetch the result of the method.

The other two important concepts are serial and parallel. it goes without saying that the serial means executing tasks one by one and the parallel means executing multiple tasks at the same time.

Not only the synchronous tasks can execute in serial, but the asynchronous tasks can do also if the next task is always invoked in the last task’s callback.

From the traditional view, the concurrent is emulating several threads in single-core CPUs and the parallel is creating threads in multiple-core CPUs.

What’re the difficulties of implementing concurrent?

  1. Keep interactions and communications between different operations in right order.
  2. Make sure calculation resources can be safely shared, accessed and transported in different operations.

Swift uses structured concurrent to fit the first rule and the Actor to implement the second.

What’s the Async method?

Swift provides two new keywords async and await. Using them can sharply simplify our async code and make async logics more readable.

Let’s see a simple example:

Usually, we write a data requesting method likes blew.

1
2
3
4
5
6
7
8
9
func requestRoomInfo(with roomId: String, completionHandler: @escaping (Data?, Error?) -> Void) {}

func loadData() {
requestRoomInfo(with: "1") { data, error in
if let roomInfo = data {
// Setup UI with roomInfo.
}
}
}

If we refactor it with the async keyword, the code is more clear.

1
2
3
4
5
6
7
8
func requestRoomInfo(with roomId: String) async throws -> Data {
return Data()
}

func loadData() async throws {
let roomInfo = try await requestRoomInfo(with: "1")
// Setup UI with roomInfo
}

If we want to execute a serial async operations, the classic block invoking would nest multilayer. Using concurrent pattern, we can process these async tasks like they are sync.

What’s the Task?

As you see we must invoke an async method in another async one. Following the invoking link to end, how do we invoke the last async method?

Swift provides the Task to help us to build a async context. The Task constructor receives a async block to write async methods.

1
2
3
4
5
6
struct Task<Success, Failure> where Failure : Error {
init(
priority: TaskPriority? = nil,
operation: @escaping @Sendable () async throws -> Success
)
}

Features:

  • A task has itself priority and cancel flag, it can have several child tasks to execute async methods.
  • When a parent task was canceled, the cancel flag of it would be set and transmitted to all the child tasks.
  • Whether throwing errors or completing successfully, the child tasks would report results to their parent task. The parent task would finish after all child tasks did.

Executing a task waiting until multiple tasks finish.

Formerly, we usually use GCD semaphore wait to implement it. In Swift concurrent pattern, we can use async let to declare a variable being bound to a result from a async task. Use await variable to get its value.

The async tasks will execute immediately when declared.

1
2
3
4
5
6
7
8
func loadData() async throws {
async let roomInfo = requestRoomInfo(with: "1")
async let roomInfo2 = requestRoomInfo(with: "2")

let data = (try await roomInfo, try await roomInfo2)

// Setup UI with data
}

You can write the keyword try before the method requestRoomInfo, but there is useless, you still must write the try before the await when reading the value.

What’s the Actor?

If a value in the memory is modified and read by multiple threads at the same time, it might cause a memory error crashing applications. Normally, we can use serial queues or locks to work out this problem, but using these needs developers to have a thorough knowledge of thread scheduling and the data model base on memory-sharing.

Now, we can use the Actor to declare a data structure like a class. The instance of the type can safely be accessed by multiple threads simultaneously.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct Person {
let name: String
}

class Room {
var users: [String: Person] = [:]

func enter(_ person: Person) {
users[person.name] = person
}
}

func viewDidLoad() {
Task {
for i in (0...9999) {
Task {
self.room.enter(.init(name: "\(i)"))
}
}
}
}

In the example above, we opened a lot of threads to modify the array user at the same time. With a high probability, you would get an error EXC_BAD_ACCESS meaning you access memory addresses via a wrong way.

If we use the actor to declare the Room type, the accesses to the instance of the Room would be protected just like they are running in a queue.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
actor Room {
var users: [String: Person] = [:]

func enter(_ person: Person) {
users[person.name] = person
}
}

func viewDidLoad() {
Task {
for i in (0...9999) {
Task {
await self.room.enter(.init(name: "\(i)"))
}
}
}
}

What’s the structured concurrency?

Before learning the structured concurrency, we firstly should make out the structured programming. In unstructured programming, we use the goto syntax to control the execution logic of our programs. If we invoke a method that includes goto statements, that means the method might jump to any executing addresses and never back to our invoking context.

Now, nearly all programing languages are structured. In sync code, we invoke a method can certainly get an result before exiting the invoking scope. Unlike the unstructured programing, we can now determine the ins and outs of invocations, so we can write more clear and maintainable code.

But in async code, like invoke a method with a callback block, the async code would still be running after exiting current invoking scope. That is similar to the unstructured programming that we cannot forecast behaviors of the async code(may invoke other methods),
The async code will run in another invocation stack that is different compared to the current invocation stack, so the async code can’t not directly throws an error and pass up it. This limits the flexibility of which we write code.

The structured concurrency resolves these questions. In structured concurrency, an async method will wait until all async tasks finish before returning. You can create several child tasks in a root task and the root task will never be complete before all its child tasks finish.

How to understand the executing steps of a concurrency task?

  1. In iOS, we always create a new root task in a sync context. Like viewDidLoad of UIViewController.
  2. The created task will run in the current actor’s isolated scope. In most part of UIKit, the actor is the main actor that runs in the main thread(UI thread).
  3. When we invoke an async method, like requesting data, at the await position, the main actor’s isolated scope will be released so other code can continuously execute.
  4. Every await point means that the current actor’s isolated scope(thread) will be released.
  5. When the async method is finished executing, the remained code will try to recapture the thread to continuously execute.

How does the Swift concurrency work?

  • All async methods are run in a task.
  • The block of a task will be dispatched by a cooperative thread pool that uses a serial queue to schedule these tasks.
  • These tasks will be divided into several continuations.
  • A global concurrency queue can create multiple threads at most of the count of cpu cores to execute these continuations.
  • Do not execute synchronous time-consuming tasks, they would block the dispatching threads and slow the performance of the whole Swift concurrency.

Caveats

  • Task.init or Task.detached will capture the self of the context until the async task finishes. So we should carefully use it when processing an infinite AsyncSequence or AsyncStream like Notification. One resolution is that saving the task to a property and then cancel it when the view or object are deinited.

  • SwiftUI provides a task extension method for View, the task’s async block will be invoked when the view appears.

    • The biggest difference with onAppear is that the async tasks in the task method will be automatically canceled when the view is removed from the screen.
  • In Swift concurrency, the defer will be invoked after all tasks finish.

    • Even in async let, these async tasks are implicitly canceled when not using. The defer will still wait for these tasks to finish.
  • If a task invoke the cancel method, the point of throwing cancellation errors is later than the task was canceled. But you can use the withTaskCancellationHandler method to wrap your async tasks and get an immediate cancellation message through the callback.

  • You can synchronously access let properties of an actor instance, because constant properties cannot be modified so have natural thread-safety.

  • await Task.yield() can temporarily give up the current thread and pack the rest of the task to the dispatching queue for waiting next dispatching.