Go back to Day 2
Day 3 will be fun. Swift Concurrency is something that really hit its stride after I started working on Xcode Cloud, so I haven't dived in as deep as I'd have liked. That said, I do try to keep my finger on the pulse, and so I know that Matt Massicotte is the person to keep an eye on for this stuff. His Swift Concurrency Glossary is exactly what I need, I'll probably refer to it often.
Since this is a topic that is in flux - Apple has been making changes to concurrency quite a bit over the last few years - I'm going to keep a pretty close eye on what the LLM's claims, and try to verify everything it suggests.
1. Concept Overview
Structured Concurrency
LLM Says: Introduced in Swift 5.5, tasks run in a hierarchy managed by the runtime. This avoids the “callback hell” of GCD/closures. Think of it as: each async function can spawn child tasks that automatically cancel when the parent cancels.
The LLM seems to be right on the money that Structured Concurrency was introduced in Swift 5.5. According to Paul Hudson, it builds on the work introduced with async / await, and async sequences (👆🎗️which is something I should look up).
Before Swift 5.5, iOS concurrency was more familiar to me; using DispatchQueue and OperationQueue have their roots in Objective-C, so when I see those, I'm more or less instantly familiar with what they're trying to achieve.
So generally, Structured Concurrency is about creating the same kind of structure and control-flow for asynchronous code that we have in synchronous code (eg. if/else, for loops).
This includes things like:
- Tasks & Task Groups
async/await
- `Structured Sequences (💣 The LLM missed this one, but I think it's pretty important)
(💣 I think putting Structured Concurrency first in the concepts list is a bit of a miss for the LLM. Given that async/await is foundational, it probably should have put that first. A small quibble, but what's life without minor complaints?)
async / await
LLM Says: Mark a function
asyncto indicate it can suspend. Call withawaitat a suspension point—the task yields without blocking a thread, then later resumes (potentially on a different thread).
I like this explanation from the LLM; I hadn't really connected to the - obvious in retrospect - idea that each await indicates a suspension point.
I've used async/await plenty in other languages, so this feels pretty familiar. It's worth noting that the async keyword is meant to mark functions, while await is to mark specific calls TO functions, at points where the code might want to suspend.
The Swift Programming Language chapter on Concurrency notes a similarity between async/await and try/catch. In fact, they work hand-in-hand. A method signature can be marked:
methodName async throws {}
Informing the compiler that this method should run asynchronously, and can throw errors, while a method call would get:
try await methodName()
Which means that this method call is a possible suspension point, and that we know that it might throw an error.
The compiler knows about these keywords, and will do the right thing if you mess them up.
Suspension Points
LLM Says: Every
awaitis a potential suspension. Do not assume thread affinity afterawait. Use@MainActor(orMainActor.run {}) for UI work.
I think I've got the fact that await is a suspension point. That's a nice mental model.
What is meant by "thread affinity"? I'm assuming that it just means that you shouldn't assume that the function being awaited is running on the same thread that it was called from.
Ahh ok. I think I'm piecing it together. Swift actors were introduced in 5.5 along with async/await, and provide guarantees of data isolation, not so much thread safety. Ultimately, isolation is the thing we need to be more concerned with, rather than with threading.
I think the point about using @MainActor for UI work is akin to running your UI code on the main thread, in days of yore. But again - I probably shouldn't think of the threads as much anymore, because the actors will abstract that away from me.
Error Handling
LLM Says: Async functions integrate with Swift’s throws — you call with try await. Errors propagate like synchronous code, but across suspensions.
I think I referenced this up above: async throws on method signatures, and try await on method calls will allow you to plug concurrency and error handling together.
I think this will just work as expected; if my asynchronous try await call throws an error, I will just be able to handle that in the catch {} block in the same way as we handle try catch{} in synchronous code.
Cancellation
LLM Says: Tasks carry cooperative cancellation. Check
Task.isCancelledor calltry Task.checkCancellation()in long-running loops; design APIs to handle early exit cleanly.
💣 I feel like the LLM hasn't done a good job of introducing Task yet - I'm near the end of the Concept Overview, and while Tasks have been mentioned, it's been in passing - there's no recognition that a Task is a concrete type that you can create, or that it's the smallest unit of asynchronous code that can be created.
With that being said, I'm curious about what is meant by 'carry cooperative cancellation'
According to both Paul Hudson and Majid Jabrayilov, the cooperative cancellation model essentially boils down to the fact that Tasks will continue to run after being told to stop, so that you can still gather information about the task (such as, whether it has been cancelled or not!). Cooperative cancellation means that the task actually has to check if it's been cancelled, and return some value (or throw).
Actors
LLM Says: Concurrency-safe reference types that guarantee isolation of their mutable state. Accessing an actor’s isolated state is an
await. Prefer actors to ad-hoc serial queues.
Actors are a pretty major thing to understand in the Swift Concurrency model. They are reference types, and designed for concurrency. So once you have some data inside an actor, the compiler will guarantee that no two locations can access that data at the same time.
So actor is basically a new type of type, much like struct, class, or enum. It's a reference type, and it forces accessors to use concurrency when accessing its members.
As I understand it, an instance of an actor holds a queue of requests for its data, and provides that data when possible. You don't have to mess around with locks or mutable state when you use an actor. You just have to await any access to the actor instance itself.
This process is called actor isolation, and THIS is what Swift makes concurrency guarantees about. Not threads, but isolation.
Concrete usage: Paul Hudson notes that Swift Data uses model actors, to ensure that the database isn't written to by multiple different places at once.
Note: Donny Walls cautions that actors are reentrant, which can introduce subtle bugs in code. This is probably only an issue when the actor itself is making some asynchronous calls (look for an await inside the actor), as that will be a suspension point. You could have a situation where you make multiple calls to an actor, and you might think that each of those calls would be processed serially. But that would only be guaranteed to be the case if the actor had no asynchronous calls within the code path that you were calling on it.
2. Code Examples
2.1 Networking with async/await
So, with all the learning from the Concept Overview, this post looks pretty straightforward.
The fetchPosts function is marked async throws, which means it can be awaited by calling code, and can await functions within it. Additionally, it can throw errors.
The URLSession.shared.data(from: url) is itself async throwa, so when we call it, we can use try await to opt in to catching exceptions and also having that work farmed out to another actor.
I had read somewhere that you shouldn't use guard in the middle of a function, that it's better used at the top of a function block. I think it works here, to ensure that the response that comes back is a valid HTTPURLResponse. I like simple way of checking the status code, by just saying: "the range between 200 to less than 300 should contain the http.statusCode".
Then the next section is more like how to use it. PostsViewModel is sort of a SwiftUI class, and uses a Task to load the posts (asynchronously, using try await). I also notice that it's implementing the ObservableObject protocol, and its properties are wrapped with the @Published wrapper, which means these properties can be observed in SwiftUI.
🤔 What's the defer keyword there? Presumably that setting of isLoading = true happens after the do/catch block's async call is resolved. Let me check that.
Ahh yes. Found a Paul Hudson blog post on it. Anything within the defer {} block will be run as the function exits. So it's kinda perfect for try/catch, and acts like a finally clause in other languages. Interesting that it's been around since Swift 2! Also interesting that there's now a proposal to allow async calls in defer blocks, which hasn't existed before.
import SwiftUI
struct Post: Decodable { let id: Int; let title: String }
func fetchPosts() async throws -> [Post] {
let url = URL(string: "https://jsonplaceholder.typicode.com/posts")!
let (data, response) = try await URLSession.shared.data(from: url)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
throw URLError(.badServerResponse)
}
return try JSONDecoder().decode([Post].self, from: data)
}
@MainActor
final class PostsViewModel: ObservableObject {
@Published var posts: [Post] = []
@Published var error: String?
@Published var isLoading = false
func load() {
Task {
isLoading = true
defer { isLoading = false }
do {
posts = try await fetchPosts()
} catch {
error = error.localizedDescription
}
}
}
}
2.2 Simple Actor
So from everyting I've understood so far, we create an actor type called Counter (which is a reference type). Making Counter an actor means that its members are automatically isolated. Each instance of Counter will essentially provide its own locks for accessing its members.
So in the code, we create a Task, increment the counter, and then check the counter's current value. This really IS a simple actor.
So in theory, if there were multiple ways to increment a counter on this screen, there might be two separate tasks with the same code. But they would be safe to use, because the Counter instance would manage its own access to the increment() and current() methods.
actor Counter {
private var value = 0
func increment() { value += 1 }
func current() -> Int { value }
}
let counter = Counter()
Task {
await counter.increment()
print(await counter.current())
}
2.3 Cancellation-aware Work
Hmm, this is something that the LLM didn't mention alongside checking Task.isCancelled and Task.checkCancellation().
This seems to be slightly outside Task's cooperative cancellation model. Indeed, the Swift documentation says:
This differs from the operation cooperatively checking for cancellation and reacting to it in that the cancellation handler is always and immediately invoked when the task is canceled.
💣 Actually, wait. The below code is DEPRECATED. Yikes. Big swing and a miss by the LLM here.
The operation I linked is the current method, that takes an operation, an onCancel handler, AND an isolation context (which is optional).
At any rate, using a task cancellation handler means that the handler will be run as soon as the task is cancelled.
Now my question is; when would I want to use this, vs. the cooperative cancellation model, and vice versa?
Task.checkCancellation()
This method is a static method on Task, but will always check the cancellation status of the Task that it's run within. It will throw an error if the task is cancelled, which will break out of the task automatically for you.
Task.isCancelled
This requires you to do a bit more work. As isCancelled simply returns a Bool, you'll have to do the work of cancelling yourself. But it's a good option if you still have some computation to do before cancelling the task, or if you want to have some specialized behaviour, like returning a cached result.
Both of the two options above are done within the task, and are more under your control. YOU decide when to checkCancellation or to check isCancelled. So the moment of cancellation might come and go, but you'll only find out about it later, when you purposefully check.
withTaskCancellationHandler
This handler is different in that it will be executed right AT THE MOMENT of cancellation. So you may not wait for a database call or network request to finish, this handler will be executed immediately.
func fetchLargePage(at url: URL) async throws -> Data {
var request = URLRequest(url: url)
request.timeoutInterval = 30
return try await withTaskCancellationHandler {
// onCancel cleanup if needed
} operation: {
let (data, _) = try await URLSession.shared.data(for: request)
try Task.checkCancellation()
return data
}
}
Hands-On Exercises
3.1 Basic Networking (Image)
Write an async function to fetch a random dog image from https://dog.ceo/api/breeds/image/random. Display it in SwiftUI (e.g., AsyncImage). Handle errors with a fallback view.
This is pretty ugly, but it works! I even put in a little task cancellation check between fetching the JSON and grabbing the image.
struct DogPic: Codable {
var message: URL?
}
struct BasicNetworkingView: View {
@State var isLoading: Bool = false
@State var image: Image? = nil
var body: some View {
VStack {
Button("Load Image") {
isLoading = true
Task {
let url = URL(string: "https://dog.ceo/api/breeds/image/random")!
let (data, response) = try await URLSession.shared.data(from: url)
print("got data?")
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
print("oops")
throw URLError(.badServerResponse)
}
print("got data!")
print(String(describing: data))
let pic = try JSONDecoder().decode(DogPic.self, from: data)
image = try await fetchDog()
isLoading = false
}
}
isLoading ? Text("Loading") : Text("Not Loading")
image
}
}
func fetchDog() async throws -> Image? {
let url = URL(string: "https://dog.ceo/api/breeds/image/random")!
let (imageLocationData, response) = try await URLSession.shared.data(from: url)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
throw URLError(.badServerResponse)
}
let pic = try JSONDecoder().decode(DogPic.self, from: imageLocationData)
// Check if the task is cancelled
try Task.checkCancellation()
let (imageData, imageResponse) = try await URLSession.shared.data(from: pic.message!)
let nsImage = NSImage(data: imageData)
if let nsImage {
return Image(nsImage: nsImage)
} else {
Image(systemName: "dog")
}
return image
}
}
3.2 Suspension Exploration
Print before and after await Task.sleep(nanoseconds: 500000000). Log the current thread or actor to observe that execution may resume on a different thread.
💣 This is a bit of a weird one. You can't log the current Thread within a Swift concurrency context, probably because it might be incorrect. Also, I don't see a way to log the current actor. You can specify the actor you want to USE, but AFAICT you can't actually query to figure out which actor or isolation context you're in.
print(Thread.current)
Task {
print("will sleep @ \(Date.now)")
try await Task.sleep(nanoseconds: 2000000000)
print("did sleep @ \(Date.now)")
}
3.3 Actor Safety
Implement a BankAccount actor with deposit, withdraw, and balance. Spin up multiple Task {} blocks performing operations concurrently; confirm the final balance is correct and no races occur.
So, I think this is what I want. I created an actor that has the required methods, and has a private balance property.
This code output 250, which I think is correct. This isn't a fantastic demonstration, because all these Tasks are executed serially, which basically makes this behave like a class.
BUT, I did try to add a Task.sleep to one of the Tasks, which changed the ultimate number. I would expect that, because if you delay a deposit or withdrawal, then the balance check can happen beforehand.
actor BankAccount {
private var accountBalance: Decimal = NSNumber(floatLiteral: 0.0).decimalValue
func deposit(depositAmount: Decimal) {
accountBalance = accountBalance + depositAmount
}
func withdraw(withdrawalAmount: Decimal) {
accountBalance = accountBalance - withdrawalAmount
}
func balance() -> Decimal {
return accountBalance
}
}
let account = BankAccount()
Task {
await account.deposit(depositAmount: 2000.0)
}
Task {
await account.withdraw(withdrawalAmount: 700.0)
}
Task {
await account.withdraw(withdrawalAmount: 1400.0)
}
Task {
await account.deposit(depositAmount: 350.0)
}
Task {
let balance = await account.balance()
print(balance)
}
After introducing the sleep, I wondered how you'd make sure that all the deposits & withdrawals go first. So I wrapped all the tasks in a TaskGroup, like so:
await withTaskGroup { group in
group.addTask {
print("Adding 2000")
await account.deposit(depositAmount: 2000.0)
}
group.addTask {
print("Remove 700")
await account.withdraw(withdrawalAmount: 700.0)
}
group.addTask {
print("Remove 1400")
await account.withdraw(withdrawalAmount: 1400.0)
}
group.addTask {
print("Adding 350")
await account.deposit(depositAmount: 350.0)
}
}
That works, but also crashes the Playground. I'm not sure what's up there.
4. Interview Angle
Q1: How does async/await differ from GCD/completion handlers?
A: Structured, readable flow; compiler-checked suspension points; integrated throws and cancellation; task priorities; less callback nesting.
Verification: Looks like the LLM decided to just do an outline of the answer today. These all seem to be true, tackling them in order:
- Structured: Yes, I guess? This is a weird one. GCD/completion handlers is still "structured". This feels like just pulling a buzzword out of the docs somewhere.
- Readable flow & less callback nesting: Ok, this is fair. It's definitely less nested, because each Task can be spun off and
awaited, so it reads more like normal code.
- Compiler-checked suspension points: Yep, this is really good. When you mark a function async, the compiler KNOWS to check the methods that could run asynchronously, and warn you if you don't mark a suspension point with await.
- Integrated throws and cancellation This is nice. I figured out the 3 ways to cancel, and it's kinda neat how at least one of them (
Task.checkCancellation()) just throws an error if a task has been cancelled.
Q2: What’s a suspension point? Why does it matter?
A: Any await where the task may yield; after resumption you might be on a different thread—use @MainActor for UI; important for reasoning about state and reentrancy.
Verification: Yep, I think this is correct here. It was interesting to learn that every await is a possible suspension point; where the running of task might yield, and stop execution while some other work gets done. The answer mentions that you may be on a different thread when you resume, and that you want to pick the @MainActor, as opposed to trying to manage which thread you're running on.
Q3: Why use an actor instead of a serial dispatch queue?
A: Language-level isolation with compiler enforcement, clearer APIs, automatic cooperation with the concurrency runtime (priority, cancellation), fewer foot-guns than manual queueing.
Verification: This answer seems good, I'm not sure what's missing. The automatic cooperation thing is probably the best part IMO; Actors automatically synchronize access to their properties and functions, so it's a lot easier to reason about how concurrent code is executing.
Go on to Day 3