Where data lives — Actor's value isolation (SE-0414)
| Meaning | |
|---|---|
| Free to travel (not yet bound) | |
| Bound to actor | |
| Bound to current async task |
Tracks which isolation a NonSendable value belongs to.
@MainActor func example() async {
let x = NonSendable() // x at disconnected
mainActorViewModel.field = x // x at isolated(MainActor) <- BOUND!
let other = OtherActor()
await other.use(x) // ❌ error: x is no longer disconnected
}
Region merge:
Once bound, a value cannot be sent across isolation boundaries.
| invalid | ||
| invalid | ||
| Aspect | Capability | Region |
|---|---|---|
| What | Execution context | Value location |
| Question | Where does code run? | Where does data live? |
| Examples | @nonisolated, @MainActor |
, |
| Changes | Fixed per scope | Flow-sensitive |
| Controls | async / await |
sending |
| Keyword | Description |
|---|---|
Copyable |
Can be copied and used multiple times |
~Copyable |
Cannot be implicitly copied (consume once) |
consuming param |
Takes ownership |
borrowing param |
Temporary read-only access |
No copy = Enforced consumption
Ownership
func useConsuming(
_ x: consuming NonCopyable) {}
func consumingExample() {
let x = NonCopyable()
useConsuming(x)
_ = x // ❌
}
Concurrency
func useSending(
_ x: sending NonSendable) {}
func sendingExample() {
let x = NonSendable()
useSending(x)
_ = x // ❌
}
Both consuming and sending enforce
consumption of ~Copyable / ~Sendable value.
sending param (in same isolation)non-sending
@MainActor func use(
_ x: NonSendable
) {}
@MainActor func example() async {
let x = NonSendable() // disconnected
use(x) // bind: x → MainActor
_ = x.value // ✅ still accessible
let other = OtherActor()
// ❌ `x` cannot cross actor region
await other.useSending(x)
}
binds to actor region
sending
@MainActor func useSending(
_ x: sending NonSendable
) {}
@MainActor func example() async {
let x = NonSendable() // disconnected
useSending(x) // stays disconnected
_ = x.value // ✅ still accessible
let other = OtherActor()
// ✅ passing from disconnected region
await other.useSending(x)
}
keeps in same region
sending return@MainActor func makeNonSendable() -> NonSendable {
self.nonSendable // region could be isolated(MainActor)
}
@MainActor func makeSending() -> sending NonSendable {
NonSendable() // guarantees disconnected region
}
@MainActor func example() async {
let x = makeNonSendable()
await other.use(x) // ❌ x may be isolated(MainActor)
let y = makeSending()
await other.use(y) // ✅ y is guaranteed disconnected
}
sending-return guarantees the value is disconnected
nonisolated func example() {
let x: T = ... // x is newly created → disconnected
_ = x // just reading: Γ stays the same
}
@SomeActor func f(_ x: NonSendable) -> T { ... }
@MainActor func example() async {
let x = NonSendable() // x : NonSendable at disconnected
_ = await f(x) // cross-isolation: x is sent
// ❌ error: Γ does not contain `x` anymore
_ = x
}
Access-only variable rule:
call-nonsendable-consume — cross-isolation consumption:
region-merge — e.g. binding via assignment:
Cross-isolation shrinks . Region-merge refines it.
Non-@Sendable — closure inherits parent's capability :
@Sendable — No inheritance, uses instead:
sending Capturetransfer case — consumes NonSendable captures:
func transferCase() {
let y = NonSendable()
Task.detached { _ = y.value }
// _ = y.value // ❌ transferred / race risk
}
@MainActor func sameActor() {
let x = NonSendable()
Task { _ = x.value }
_ = x.value // ✅ same-actor Task.init keeps access
}
| Component | Role | Details |
|---|---|---|
| WHERE code runs | @nonisolated, @MainActor, ... |
|
| WHERE data lives | ||
| Affine discipline | Merge refines, transfer shrinks |
Swift Concurrency = Capability × Region
@κ: where code runs / ρ: where data livessending = Affine transfer (in many cases)
x : T at disconnectedRegion Merge = Context refinement
disconnected ⊔ isolated(a) = isolated(a)Hi everyone! My name is Inamiy. It's a pleasure to speak at try! Swift Tokyo Conference. Today, I'd like to talk about Swift Concurrency, which everyone loves — and also struggles with. In this session, we’ll start with concrete examples and then deep-dive into the foundations of Swift Concurrency type system. It's a lot to cover, so let’s get started! 00:30
Let's start with a quick recap on Swift Concurrency. Here's a list of all the related keywords we have today. As you can see, there are already more than 20 types, keywords, and attributes — and this list keeps growing. If you find this list is already overwhelming, you're probably not alone.
Swift Concurrency is indeed hard to learn. 1:00
So, what makes Swift Concurrency hard for us? In my opinion, the hardest part is, when the "closures" come into play. Consider this example: We have `@MainActor` class calling a method with a completion closure handler. But this doesn't compile. Can you tell why? Answering this kind of question is often tricky and hard to reason about. So, we really want to have a bit more clear and formal way to explain this kind of problem. So let's reframe our question. 1:30
"What are the core rules behind this Swift Concurrency's type system?" 1:40
To dig deeper, let's first start exploring "Closure conversion rules".
Imagine we have a closure `f` annotated with some concurrency attribute `@A`. Now, our question is: "Can we convert this `f` with a new attribute `@B` instead?" This question is really important to us, because it asks about "closure subtyping" and "coercion rules". 2:10
And if we play around with Swift code, we will get this kind of conversion results. Note that all closures in this slide are "synchronous". Starting with the top-left: conversion fails in both directions, because the two closures belong to different actor isolations. And we are not even allowed to use `await` here, so the compiler simply rejects both cases. The bottom-left is the same story. `@MainActor` versus `nonisolated`, which are also different isolations, so neither direction compiles. Now, look at the top-right. This is actually the same example case as I shared in the beginning of this talk. Here, `@Sendable` closure can be converted to `@MainActor`-isolated, but not vice versa. Why? Because `@Sendable` is safe to be used in any isolation domain, while `@MainActor` closure is restricted to the main actor. Finally, the bottom-right shows `@isolated(any)` versus `nonisolated` closure. `@isolated(any)` is an isolation-erased type that can represent any isolation or even nonisolated. So, a nonisolated closure can be weakened to `@isolated(any)`, but going the other way is not allowed. 3:30
By exhaustively checking every possible pattern, we can summarize the results into this matrix. Now, let's visualize this into a diagram...
... and we get this simple, clean graph now. At the very bottom sits `nonisolated @Sendable` — the most flexible type that can be used anywhere. Each arrow points upward, showing that a closure can be converted from bottom to top. And at the very top is `@isolated(any)`. This is the type-erasure that accepts all the others — making it the weakest to call. Once we have this hierarchy in mind, reasoning about closure conversions becomes much more intuitive. 5:00
Next, let's move on to async closure conversion. Unlike the synchronous case, `async` opens up many more conversions because we can now `await` across actor isolation boundaries. Of course there is a small runtime cost for each actor hop, but in return, differences in actor isolation become less of a barrier. 5:30
And here is the async conversion matrix. You can see many more "green checks" compared to the synchronous version.
And here is the diagram for async case. Notice the red bi-directional arrows. These represent conversions that work both ways, thanks to `await` actor hops. 6:00
Finally, here is the full picture combining both synchronous and async diagrams. The blue arrow in the center represents "Async Lift", which means, any synchronous closure can be implicitly promoted to its async counterpart. 06:30
Let's recap what we've covered so far. The trickiest part would be the synchronous closure conversion — since we can't use `await`, our options for crossing isolation boundaries are very limited. And with these diagrams in hand, writing Swift Concurrency code should feel more predictable now. ... But that said, these rules are still the tip of the iceberg. To go even deeper, we need to explore more of the "type system" that forms the foundation of Swift Concurrency. To demystify what drives Swift Concurrency under the hood, we need to look at two key axes... 07:15
which are: "Capability" and "Region". 07:30
Before we go deeper, here's a quick quiz. Given this code, which isolation does `ViewModel.handler` actually have? Take a moment to think about it... ... Did you find the answer? 08:00
The answer is, there are actually two distinct isolation layers at play here. First, `@BarActor` determines the "isolation domain" — that is, "where the code actually runs". We can think of it as the "execution context". Second, the handler itself "lives" in `@FooActor` as part of an "isolation region". This is a value-level isolation — it controls "who can access the value", not where the code executes. So, to recap, there are two separate isolation concepts in Swift: "Isolation Domain" and "Isolation Region". For the rest of this talk, I'll simply call them "Capability" and "Region", borrowing the terminology from type theory. 09:00
Let's start with the first axis: Capability. Capability determines the execution context of a function or closure. This decides whether we need `await` keyword when calling across actor isolation boundaries. 09:15
In Swift, concurrency annotation can be split into two parts: Sendability and Isolation. And within Isolation, we define Capability as a "subset" of it — which is only either "isolated to an actor" or "nonisolated". Note that `@isolated(any)` and `@concurrent` are classified as `nonisolated` here. This matches how Swift compiler actually handles them internally. 09:45
The 2nd axis is Region. As many of you may already know, Swift 6 introduced "Region-based isolation", a major addition that is also covered extensively in Swift Evolution proposal SE-0414. In short, regions track where NonSendable values belong. A value can be in one of three states: "disconnected", meaning it's free and not yet bound to any actor, "isolated to actor `a`", meaning it's tied to a specific actor, or "task" region, meaning it's bound to the current task inherited from the caller. 10:30
Here is a concrete example of how region-check works. First, we create a `NonSendable` value `x`. At this point, it's in a disconnected region. Then, we assign it to a MainActor-isolated property, which binds `x` to the MainActor-region. Now, if we try to pass this same `x` to a different actor, the compiler rejects it. And this makes sense. Once a value is bound to some actor-region, it cannot jump to another. Under the hood, Swift compiler performs this check line by line, using a key operation called "region merge". 11:15
Region-Merge works like in this table. The key insight here is that, merging two different actor regions produces an "invalid" state, meaning the compiler rejects it. 11:30
And once again, using diagram is much more intuitive to understand. This kind of structure is so-called "join semilattice". A value starts at the leftmost node as "disconnected" region. This can be merged to the right direction into either an "actor-isolated region" or a "task region". But these branches never mix together. If we do so, it will be an "invalid region" landing at the rightmost node, which is a compile error. 12:00
To summarize Capability and Region: Capability answers "where does code run?", and it is the execution context. Region answers "where does data live?", and it is the value-level isolation scope. 12:15
Now, let's take a brief detour to talk about Affine Type System.
What are Affine Types? In Swift, actually, we already know them as Swift Ownership. Since Swift 5.9, `NonCopyable` type has been introduced alongside `consuming` and `borrowing` keywords, which enables "move semantics and aliasing" that avoids unnecessary copies for better performance. 12:45
The key property of Affine Types is: you cannot duplicate a value. The first sentence says "from one A, you cannot produce two A's." The second says "but you can still discard one A." A real-world analogy would be: you can't clone an apple, and you can decide not to eat it. And this actually maps more naturally to the real-world problem than a purely copy-only model. Now, you might be wondering: "Why is this guy talking about Ownership in a Concurrency talk?" 13:30
Well, actually, both of them share the same idea. Look at these two code examples side by side — `consuming` from Ownership and `sending` from Concurrency. They behave almost identically: once a value is consumed or sent to another actor isolation boundary, it becomes inaccessible on the very next line. 14:00
That said, we can think of it this way. `Sendable` is like `Copyable`. A Sendable value can freely appear in multiple isolation domains, just as a Copyable value can be used in multiple places by copying. Conversely, `NonSendable` is like `NonCopyable`. Once you `send` a NonSendable value away, you can no longer access it, just like `NonCopyable` value becomes inaccessible after `consuming`. For `sending` keyword, it behaves almost like `consuming`, but slight nuance here is that, it is "NOT always" consuming. 14:30
And here's the exception. Only within the same actor isolation, `sending` doesn't actually consume the value but "keeps it in the same region". This is quite interesting Swift compiler behavior that is worth paying attention to. 15:00
By the way, for `sending` return values, the sent value is guaranteed to be in the disconnected region. To recap this `sending` behavior, to be honest, it is a bit tricky. But we can mostly say that, `sending` is similar to `consuming`, so Swift Concurrency behavior is actually very similar to Swift Ownership and Affine Types. 15:00
Alright, so far, we have covered Capability, Region, and Affine Types. But we are still only halfway to answering our original question: "What are the "core rules" behind the Swift Concurrency type system?" To fully distill those rules, we need to "formalize" them in more type-theoretic approach. As this is going to be a very deep academic topic, I won't challenge myself for full rigor here. Instead, I will share my rough sketch based on "AI vibe-formalization". 15:45
To start, this is the standard typing judgement we normally see in many programming languages' type systems. Here, `Γ` is the typing context — a dictionary from variable names to their types. It reads: "under context Γ, expression `e` has type `T`." But Swift's concurrency type system needs much more information than just Γ, so we will need to extend this form. 16:15
And here is my proposed extended judgement for Swift Concurrency. It looks intimidating at a glance, so let's break it down into pieces. 16:30
Here is what each symbol means. We've added κ for capability, α for sync/async mode, and ρ for region. There is also Γ' which is the output environment on the right side. This output environment can be also seen in Affine Type System. 17:00
And let's take a look at some examples. This slide shows that, in a synchronous, nonisolated context, a newly created value `x` starts in the disconnected region. Since we're only reading it, the environment Γ stays unchanged. 17:15
Now a more interesting case. We're on `@MainActor`, and we call `await f(x)` where `f` runs on a different actor. Since `x` is NonSendable, it gets transferred by crossing the actor isolation boundary. The red box shows `Γ subtract x`, which means `x` is removed from the original input environment. So, any attempt to use `x` afterward will be a compile error. 17:45
Now, let's take a look at the actual typing rules. The first one is the "variable" access rule. It looks normal, but notice the red box. There's an extra check, which means, the current capability κ must be allowed to access the variable's region ρ. For example, a nonisolated context can access disconnected values, but not those in actor-isolated regions. 18:15
Next, here are two rules that modify the typing environment. The first rule shows, what happens when a NonSendable value crosses isolation via `await`: the value is consumed, and Γ shrinks. The second rule, region-merge, shows what happens on assignment: Two regions are joined, and Γ is refined. 18:45
This slide is about "closure isolation inferences". For a non-`@Sendable` closure, it "inherits" its parent's capability κ. And this is why closures usually "just work" inside the same actor. But `@Sendable` closure does "NOT" inherit — its body capability is derived from its own annotation. So, a plain `@Sendable` closure normally becomes "nonisolated", which is exactly why the first example of this talk fails. 19:15
And lastly, here is the "closure sending" rule. When a closure is passed as a `sending` parameter and the compiler cannot prove the same actor context, `NonSendable` captures in the disconnected region will be consumed. `Task.detached` is the best example. On the other hand, `Task.init` runs within a same-actor isolation, so it will actually follow with a different typing rule. 19:30
As I don't have much time left to explain all rules, I will skip the rest of this topic for now. For more information, please refer to this GitHub URL which I open-sourced today. Please also check my "AI vibe-paper" in this repository if you are interested. 19:45
To recap about this type-system topic... Capability, Region, and Affine Type System. These are the 3 keys to deep-dive into Swift Concurrency Type System. 20:00
And, to wrap up: We started with a simple question — "Why doesn't this Swift Concurrency code compile?" — and we ended up exploring its "core rules" in depth. Yes, Swift Concurrency is indeed a very deep topic. But that depth is what makes Swift "powerful". Unlike many other programming languages that rely on runtime locks or just wishes for the best, Swift can guarantee lock-free, data-race safety at compile time. This is really awesome and rare feature to see in mainstream programming languages, and I believe it's worth the learning curve. I hope this talk gave you some new insights to reason about Swift Concurrency. 21:00
And that's all for my talk. Thank you very much for listening, and please enjoy the rest of try! Swift Tokyo.