I have a simple non-Sendable type here:
class Foo {
var foo = 3
init() {}
}
All the following are called in viewDidLoad:
Case 1: This compiles fine:
DispatchQueue.global().async {
Task {
let foo = Foo()
Task { @MainActor in
print(foo)
}
}
}
Case 2: compile error
let foo = Foo()
DispatchQueue.global().async {
Task {
Task { @MainActor in
print(foo)
}
}
}
Case 3: compile error
DispatchQueue.global().async {
let foo = Foo()
Task {
Task { @MainActor in
print(foo)
}
}
}
I was expecting all cases to fail. But why does case 1 pass? What's the difference between 1 and 2/3?
Edit: I found 2 even more confusing cases:
case 4: This gives warning, instead of error.
let foo = Foo()
DispatchQueue.global().async {
Task { @MainActor in
print(foo)
}
}
Case 5: compiles fine:
DispatchQueue.global().async {
let foo = Foo()
Task { @MainActor in
print(foo)
}
}
Why is case 4 warning, rather than error? and why is case 5 fine?
Edit 2:
Case 4 warning:
In Swift 6, we have “region based isolation”. As SE-0414, says:
Swift Concurrency assigns values to isolation domains determined by actor and task boundaries. Code running in distinct isolation domains can execute concurrently, and Sendable checking defines away concurrent access to shared mutable state by preventing non-Sendable values from being passed across isolation boundaries full stop. In practice, this is a significant semantic restriction, because it forbids natural programming patterns that are free of data races.
In this document, we propose loosening these rules by introducing a new control flow sensitive diagnostic that determines whether a non-
Sendable
value can safely be transferred over an isolation boundary. This is done by introducing the concept of isolation regions that allows the compiler to reason conservatively if two values can affect each other. Through the usage of isolation regions, the language can prove that transferring a non-Sendable
value over an isolation boundary cannot result in races because the value (and any other value that might reference it) is not used in the caller after the point of transfer.
And this is expanded in SE-0440 which says that:
This proposal extends region isolation to enable the application of an explicit
sending
annotation to function parameters and results. A function parameter or result that is annotated with sending is required to be disconnected at the function boundary and thus possesses the capability of being safely sent across an isolation domain or merged into an actor-isolated region in the function's body or the function's caller respectively.
And if you look at the definition of the Task
initializer, it is:
public init(
priority: TaskPriority? = nil,
operation: sending @escaping @isolated(any) () async -> Success
)
I draw your attention to the sending
qualifier of operation
.
So, in Swift 6, this is fine:
let foo = Foo()
Task { @MainActor in
print(foo) // ✅
}
This why your case 1 and 5 are ok. This is just standard region based isolation behaviors.
But the following is not OK:
let foo = Foo()
DispatchQueue.global().async {
print(foo) // ❌
}
This is why your cases 2 and 4 are failing: It is not because foo
is a local var of a non-isolated function or anything like that; it is just because the closure parameter of GCD’s async
method is not marked sending
(as outlined in SE-0440). The Task.init
closure parameter is marked as sending
, but the GCD API is not.
That only leaves case 3, where you are passing the non-Sendable
object across two different isolation contexts (i.e., a Task
within another Task
). It is interesting that this is not permitted, but does not seem entirely surprising (given the complexity this entails). As an aside, I experimentally noticed that it works it apparently works if both Task
closures were isolated to the main actor, but in your example, only one is, and that is apparently a bridge too far (for now, at least).
In short, region based isolation allows us to pass a non-Sendable
objects across a single actor boundary in very specific, narrow cases, namely where (a) the closure is marked as sending
; and (b) you do not reference the non-Sendable
object after passing it to the the closure.
This region based isolation of SE-0414/SE-0440 really is not intended as generalized “get out of jail free” card for sending non-Sendable
objects. It is just eliminating one friction point in the process. If you really are going to be sending these objects across isolation domains a lot, you might want to consider whether you might just want to make them Sendable
, if possible.
You said:
I was expecting all cases to fail.
In fact, if you run with in Xcode 15.4 (Swift 5.10), before we had any region based isolation, that is exactly what happens (with the “Strict concurrency checking” build setting of “Complete”). If we do that, we can see that in the absence of region based isolation, all five cases generate warnings!
In Swift 6, if you can enjoy transfer of a non-Sendable object in very specific situations.
For future readers unfamiliar with how Sendable
works, I might refer you to the Eliminate data races using Swift Concurrency video.