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
9func 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
17func 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 thecontinuation
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 useTask.detached
to create an completely isolated async environment.
withTaskGroup
orasync let
- Used to execute multiple async tasks simultaneously.
1
2
3
4
5
6async let loadHeaderInfo = requestHeaderInfo()
async let loadContentInfo = requestContentInfo()
// The two async tasks above have started executing.
let headerInfo = try await loadHeaderInfo
let contentInfo = try await loadContentInfo1
2
3
4
5
6
7
8await withThrowingTaskGroup(of: Void.self) { group in
group.addTask {
try await requestHeaderInfo()
}
group.addTask {
try await requestBodyInfo()
}
}AsyncSequence
andAsyncIterator
- Similar to
LazySequence
, you can usefor try await element in asyncSequence
to async iterate a sequence.
1
2
3
4
5struct TimerSequence { ... } // Return the current time once a second.
for await currentTime in TimerSequence() {
print(currentTime) // Will print the current time once a second forever.
}- Similar to
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.
- Getting an immediate cancellation message when the task is canceled. Don’t need to check
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 severalisolated
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
11class 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
4actor 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
& protocolSendable
- 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 ofSendable
type. - A class that marked with
final
, conforming toSendable
protocol, and all properties are marked withlet
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 aSendable
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
.
- Used to mark a method or block to be sendable and do not check wether the passed parameters or captured variables are
@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
14enum Log {
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 */
}
}- Used to passthrough values from super tasks to child tasks, likes
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?
- Keep interactions and communications between different operations in right order.
- 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 | func requestRoomInfo(with roomId: String, completionHandler: @escaping (Data?, Error?) -> Void) {} |
If we refactor it with the async keyword, the code is more clear.
1 | func requestRoomInfo(with roomId: String) async throws -> Data { |
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 | struct Task<Success, Failure> where Failure : Error { |
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 | func loadData() async throws { |
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 | struct Person { |
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 | actor Room { |
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?
- In iOS, we always create a new root task in a sync context. Like
viewDidLoad
of UIViewController. - 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).
- 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.
- Every
await
point means that the current actor’s isolated scope(thread) will be released. - 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 usesa 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
orTask.detached
will capture theself
of the context until the async task finishes. So we should carefully use it when processing an infiniteAsyncSequence
orAsyncStream
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 thetask
method will be automatically canceled when the view is removed from the screen.
- The biggest difference with
-
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.
- Even in
-
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.