- Proposal: SE-0414
- Authors: Michael Gottesman Joshua Turcotti
- Review Manager: Holly Borla
- Status: Implemented (Swift 6.0)
- Upcoming Feature Flag:
RegionBasedIsolation
- Review: (first pitch), (second pitch), (first review), (revision), (second review), (acceptance)
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.
SE-0302 states
that non-Sendable
values cannot be passed across isolation boundaries. The
following code demonstrates a Sendable
violation when passing a
newly-initialized value into an actor-isolated function:
// Not Sendable
class Client {
init(name: String, initialBalance: Double) { ... }
}
actor ClientStore {
var clients: [Client] = []
static let shared = ClientStore()
func addClient(_ c: Client) {
clients.append(c)
}
}
func openNewAccount(name: String, initialBalance: Double) async {
let client = Client(name: name, initialBalance: initialBalance)
await ClientStore.shared.addClient(client) // Error! 'Client' is non-`Sendable`!
}
This is overly conservative; the program is safe because:
client
does not have access to any non-Sendable
state from its initializer parameters since Strings and Doubles areSendable
.client
just being initialized implies thatclient
cannot have any uses outside ofopenNewAccount
.client
is not used withinopenNewAccount
beyondaddClient
.
The simple example above shows the expressivity limitations of Swift's strict
concurrency checking. Programmers are required to use unsafe escape hatches,
such as @unchecked Sendable
conformances, for common patterns that are already
free of data races.
We propose the introduction of a new control flow sensitive diagnostic that
enables transferring non-Sendable
values across isolation boundaries and emits
errors at use sites of non-Sendable
values that have already been transferred
to a different isolation domain.
This change makes the motivating example valid code, because the client
variable does not have any further uses after it's transferred to the
ClientStore.shared
actor through the call to addClient
. If we were to modify
openNewAccount
to call a method on client
after the call to addClient
, the
code would be invalid since a non-Sendable
value that had already been
transferred from a non-isolated context to an actor-isolated context could be
accessed concurrently:
func openNewAccount(name: String, initialBalance: Double) async {
let client = Client(name: name, initialBalance: initialBalance)
await ClientStore.shared.addClient(client)
client.logToAuditStream() // Error! Already transferred into clientStore's isolation domain... this could race!
}
After the call to addClient
, any other non-Sendable
value that is statically
proven to be impossible to reference from client
can still be used safely. We
can prove this property using the concept of isolation regions. An isolation
region is a set of values that can only ever be referenced through other values
within that set. Formally, two values
-
$x$ may alias$y$ at$p$ . -
$x$ or a property of$x$ might be referenceable from$y$ via chained access of$y$ 's properties at$p$ .
This definition ensures that non-Sendable
values in different isolation
regions can be used concurrently, because any code that uses
let john = Client(name: "John", initialBalance: 0)
let joanna = Client(name: "Joanna", initialBalance: 0)
await ClientStore.shared.addClient(john)
await ClientStore.shared.addClient(joanna) // (1)
The above code creates two new Client
instances. It's impossible for
john
to reference joanna
and vice versa, so these two values belong to
different isolation regions. Values in different isolation regions can be
used concurrently, so the use of joanna
at (1)
, which may be executing
concurrently with some code inside ClientStore.shared
that accesses john
,
is safe from data races.
In contrast, if we add a friend
property to Client
and assign joanna
to
john.friend
:
let john = Client(name: "John", initialBalance: 0)
let joanna = Client(name: "Joanna", initialBalance: 0)
john.friend = joanna // (1)
await ClientStore.shared.addClient(john)
await ClientStore.shared.addClient(joanna) // (2)
After the assignment at point (1)
, joanna
can be referenced through
john.friend
, so john
and joanna
must be in the same isolation region at
(1)
. The access to joanna
at point (2)
can be executing concurrently with
code inside ClientStore.shared
that accesses john.friend
. Using joanna
at
point (2)
is diagnosed as a potential data race.
NOTE: While this proposal contains rigorous details that enable the compiler to
prove the absence of data races, programmers will not have to reason about
regions at this level of detail. The compiler will allow transfers of non-Sendable
values between
isolation domains where it can prove they are safe and will emit diagnostics
when it cannot at potential concurrent access points so that programmers don't
have to reason through the data flow themselves.
An isolation region is a set of non-Sendable
values that can only be aliased
or reachable from values that are within the isolation region. An isolation
region can be associated with a specific isolation domain associated with a
task, protected by an actor instance or a global actor, or disconnected from any
specific isolation domain. As the program executes, each isolation region can be
merged with other isolation regions as new values begin to alias or be reachable
from each other.
Isolation regions and isolation domains are not concepts that are explicitly denoted in source code. To help explain the concepts throughout this proposal, isolation regions and their isolation domains will be written in comments in the following notation:
-
[(a)]
: A single disconnected region with a single value. -
[{(a), actorInstance}]
: A single region that is isolated to actorInstance. -
[(a), {(b), actorInstance}]
: Two values in separate isolation regions. a's region is disconnected but b's region is assigned to the isolation domain of the actor instanceactorInstance
. -
[{(x, y), @OtherActor}, (z), (w, t)]
: Five values in three separate isolation regions.x
andy
are within one isolation region that is isolated to the global actor@OtherActor
.z
is within its own disconnected isolation region.w
andt
are within the same disconnected region. -
[{(a), Task1}]
: A single region that is part ofTask1
's isolation domain.
Isolation regions are merged together when the program introduces a potential alias or access path to another value. This can happen through function calls, and assignments. Many expression forms are sugar for a function application, including property accesses.
Given a function
-
All regions of non-
Sendable
arguments$a_{i}$ are merged into one larger region after$f$ executes. -
If any of
$a_{i}$ are non-Sendable
and$y$ is non-Sendable
, then$y$ is in the same merged region as$a_{i}$ . If all of the$a_{i}$ areSendable
, then$y$ is within a new disconnected region that consists only of$y$ . -
If
$y$ is not a new variable, i.e. it's mutable, thena) If
$y$ was previously captured by reference in a closure, then the assignment to$y$ merges$y$ 's new region into its old region.b) If
$y$ was not captured by reference, then$y$ 's old region is forgotten.
The above rules are conservative; without any further annotations, we must assume:
- In the implementation of
$f$ , any$a_{i}$ could become reachable from$a_{j}$ . -
$y$ could be one of the$a_{i}$ values or alias contents of$a_{i}$ . - If
$y$ was captured by reference in a closure and then assigned a new value, calling the closure could reference$y$ 's new value.
See the future directions section for additional annotations that enable more precise regions.
Now lets apply these rules to some specific examples in Swift code:
-
Initializing a
let
orvar
binding.let y = x, var y = x
. Initializing a let or var bindingy
withx
results iny
being in the same region asx
. This follows from rule(2)
since formally a copy is equivalent to calling a function that acceptsx
and returns a copy ofx
.func bindingInitialization() { let x = NonSendable() // Regions: [(x)] let y = x // Regions: [(x, y)] let z = consume x // Regions: [(x, y, z)] }
Note that whether or not
x
is in the region afterconsume x
does not change program semantics. A valid program must still obey the no-reuse constraints ofconsume
. -
Assigning a
var
binding.y = x
. Assigning a var bindingy
withx
results iny
being in the same region asx
. Ify
is not captured by reference in a closure, theny
's previous assigned region is forgotten due to(3)(b)
:func mutableBindingAssignmentSimple() { var x = NonSendable() // Regions: [(x)] let y = NonSendable() // Regions: [(x), (y)] x = y // Regions: [(x, y)] let z = NonSendable() // Regions: [(x, y), (z)] x = z // Regions: [(y), (x, z)] }
In contrast if
y
was captured in a closure by reference, theny
's former region is merged with the region ofx
due to(3)(a)
.// Since we pass x as inout in the closure, the closure has to capture x by // reference. func mutableBindingAssignmentClosure() { var x = NonSendable() // Regions: [(x)] let closure = { useInOut(&x) } // Regions: [(x, closure)] let y = NonSendable() // Regions: [(x, closure), (y)] x = y // Regions: [(x, closure, y)] }
-
Accessing a non-
Sendable
property of a non-Sendable
value.let y = x.f
. Accessing a propertyf
on a non-Sendable
valuex
results in a valuey
that must be in the same region asx
. This follows from(2)
since formally a property access is equivalent to calling a getter passingx
asself
. Importantly, this property forces all non-Sendable
types to form one large region containing their non-Sendable
state:func assignFieldToValue() { let x = NonSendableStruct() // Regions: [(x)] let y = x.field // Regions: [(x, y)] }
-
Setting a non-
Sendable
property of a non-Sendable
value.y.f = x
Assigningx
into a propertyy.f
results iny
andy.f
being in the same region asx
. This again follows from(2)
:func assignValueToField() { let x = NonSendableStruct() // Regions: [(x)] let y = NonSendable() // Regions: [(x), (y)] x.field = y // Regions: [(x, y)] }
-
Capturing non-
Sendable
values by reference in a closure.closure = { useX(x); useY(y) }
. Capturing non-Sendable
valuesx
andy
results inx
andy
being in the same region. This is a consequence of(2)
sincex
andy
are formally arguments to the closure formation. This also means that the closure must be part of that same region:func captureInClosure() { let x = NonSendable() // Regions: [(x)] let closure = { print(x) } // Regions: [(x, closure)] }
-
Function arguments in the body of a function. Given a function
func transfer(x: NonSendable, y: NonSendable) async
, in the body oftransfer
,x
andy
are considered to be within the same region. Sinceself
is a function argument to methods, this implies that whenself
is non-Sendable
all method arguments must be in the same region asself
:func transfer(x: NonSendable, y: NonSendable) { // Regions: [(x, y)] let z = NonSendable() // Regions: [(x, y), (z)] f(x, z) // Regions: [(x, y, z)] }
Isolation regions are also affected by control flow. Let
// Regions: [(x), (y)]
var x: NonSendable? = NonSendable()
var y: NonSendable? = NonSendable()
if ... {
// Regions: [(x), (y)]
x = y
// Regions: [(x, y)]
} else {
// Regions: [(x), (y)]
}
// Regions: [(x, y)]
Because the first block of the if
statement assigns x
to y
, causing
their regions to be merged within that block, x
and y
are in the
same region after the if
statement.
This rule is conservative since it is always safe to consider two values that are disconnected from each other as if they are isolated together. The only effect would be the rejection of programs that we otherwise could accept.
The above description of regions naturally allows the definition of an optimistic forward dataflow problem that allows us to determine at every point of the program the isolation region that a value belongs to. We outline this dataflow in more detail in an appendix to this proposal.
As defined above, all non-Sendable
values in a Swift program belong to some
isolation region. An isolation region is isolated to an actor's isolation
domain, a task's isolation domain, or disconnected from any specific isolation
domain:
actor Actor {
// 'field' is in an isolation region that is isolated to the actor instance.
var field: NonSendable
func method() {
// 'ns' is in a disconnected isolation region.
let ns = NonSendable()
}
}
func nonisolatedFunction() async {
// 'ns' is in a disconnected isolation region.
let ns = NonSendable()
}
// 'globalVariable' is in a region that is isolated to @GlobalActor.
@GlobalActor var globalVariable: NonSendable
// 'x' is isolated to the task that calls taskIsolatedArgument.
func taskIsolatedArgument(_ x: NonSendable) async { ... }
As the program executes, an isolation region can be passed across isolation
boundaries, but an isolation region can never be accessed by multiple
isolation domains at once. When a region
The following code example demonstrates merging a disconnected region into a
region that is @MainActor
isolated:
@MainActor func transferToMainActor<T>(_ t: T) async { ... }
func assigningIsolationDomainsToIsolationRegions() async {
// Regions: []
let x = NonSendable()
// Regions: [(x)]
let y = x
// Regions: [(x, y)]
await transferToMainActor(x)
// Regions: [{(x, y), @MainActor}]
print(y) // Error!
}
Passing x
into transferToMainActor
introduces a potential alias to x
from any @MainActor
-isolated state, because the implementation of
transferToMainActor
can store x
into any state within that isolation
domain. So, the region containing x
must be merged into the @MainActor
's
region. Accessing y
after that merge is an error because x
and y
are now
both effectively @MainActor
isolated, and the access occurs from outside the
@MainActor
.
Formally, when we pass a non-Sendable
value
In this proposal, we are defining the default convention for passing
non-Sendable
values across isolation boundaries as being a transfer
operation. This does not apply when calling async functions from within the same
isolation domain. To do so would require an explicit transferring modifier which
is described in the Future Directions section below.
There are four types of isolation regions that a non-Sendable
value can belong
to that determine the rules for transferring value over an isolation boundary.
A disconnected isolation region is a region that consists only of
non-Sendable
values and is not associated with a specific isolation
domain. A value in a disconnected region can be transferred to another
isolation domain as long as the value is used uniquely by said isolation
domain and never used later outside of that isolation domain lest we introduce
races:
@MainActor func transferToMainActor<T>(_ t: T) async { ... }
actor Actor {
func method() async {
let x = NonSendable()
// Regions: [(x)]
await transferToMainActor(x)
// Regions: [{(x), @MainActor}]
print(x) // Error! x being used outside of @MainActor isolated code.
}
}
An actor isolated region is a region that is strongly bound to a specific
actor's isolation domain. Since the region is tied to an actor's isolation
domain, the values of the region can never be transferred into another
isolation domain since that would cause the non-Sendable
value to be used by
code both inside and outside the actor's isolation domain allowing for races:
actor Actor {
var nonSendable: NonSendable
}
@MainActor func actorRegionExample() async {
let a = Actor()
// Regions: [{(a.nonSendable), a}]
let x = await a.nonSendable // Error!
await transferToMainActor(a.nonSendable) // Error!
}
In the above code example, x
must be in the actor a
's region because it
aliases actor-isolated state, making x
effectively isolated to a
. The
initialization is invalid, because x
is not usable from a @MainActor
context. Similarly, attempting to transfer actor-isolated state into another
isolation domain is invalid.
The parameters of an actor method or a global actor isolated function are considered to be within the actor's region. This is since a caller can pass actor isolated state as an argument to such a method or function. This implies that parameters of actor isolated methods and functions can not be transferred like other values in actor isolation regions.
The objects that make up an actor region varies depending on the kind of actor:
-
Actor. An actor region for an actor contains the actor's non-
Sendable
fields and any values derived from the actor's fields.class NonSendableLinkedList { var next: NonSendableLinkedList? } actor Actor { var listHead: NonSendableLinkedList func method() async { // Regions: [{(self.listHead, self.listHead.next, ...), self}] let x = self.listHead // Regions: [{(x, self.listHead, self.listHead.next, ...), self}] let z = self.listHead.next! // Regions: [{(x, z, self.listHead, self.listHead.next, ...), self}] ... } }
In the above example,
x
is inself
's region because it aliases non-Sendable
state isolated toself
, andz
is inself
's region because the value ofnext
is reachable fromself.listHead
. -
Global Actor. An actor region for a global actor contains any global variables isolated to the global actor, all instances of nominal types isolated to the global actor, and all values derived from the fields of the isolated global variable or nominal types.
@GlobalActor var firstList: NonSendableLinkedList @GlobalActor var secondList: NonSendableLinkedList @GlobalActor func useGlobalActor() async { // Regions: [{(firstList, secondList), @GlobalActor}] let x = firstList // Regions: [{(x, firstList, secondList), @GlobalActor}] let y = secondList.listHead.next! // Regions: [{(x, firstList, secondList, y), @GlobalActor}] ... }
In the above code example
x
is in@GlobalActor
's region because it aliases@GlobalActor
-isolated state, andy
is in@GlobalActor
's region because it aliases a value that's reachable from@GlobalActor
-isolated state.
An operation to disconnect a value from an actor region in order to transfer it to another isolation domain is out of the scope of this proposal. A potential extension to enable this is described in the Future Directions.
A task isolated isolation region consists of values that are isolated to a
specific task. This can only occur today in the form of the parameters of
nonisolated asynchronous functions since unlike actors, tasks do not have
non-Sendable
state that can be isolated to them. Similarly to actor isolated
regions, a task isolated region is strongly tied to the task so values within
the task isolated region cannot be transferred out of the task:
@MainActor func transferToMainActor(_ x: NonSendable) async { ... }
func nonIsolatedCallee(_ x: NonSendable) async { ... }
func nonIsolatedCaller(_ x: NonSendable) async {
// Regions: [{(x), Task1}]
// Not a transfer! Same Task!
await nonIsolatedCallee(x)
// Error!
await transferToMainActor(x)
}
In the example above, x
is in a task isolated region. Since
nonIsolatedCallee
will execute on the same task as nonIsolatedCallee
, they
are in the same isolation domain and a transfer does not occur. In contrast,
transferToMainActor
is in a different isolation domain so passing x
to it is
a transfer resulting in an error.
An invalid isolation region is a region that results from conditional control flow causing the merging of regions that can never be merged together due to isolation properties. It is an error to use a value that is in an invalid isolation region since statically the specific region that the value belongs to can not be determined:
func mergeTwoActorRegions() async {
let a1 = Actor()
// Regions: [{(), a1}]
let a2 = Actor()
// Regions: [{(), a1}, {(), a2}]
let x = NonSendable()
// Regions: [{(), a1}, {(), a2}, (x)]
if await boolean {
await a1.useNS(x)
// Regions: [{(x), a1}, {(), a2}]
} else {
await a2.useNS(x)
// Regions: [{(), a1}, {(x), a2}]
}
// Regions: [{(x), invalid}, {(), a1}, {(), a2}]
}
The behavior of merging two isolation regions depends on the kind of each region.
-
Disconnected and Disconnected. Given two non-
Sendable
values in separate disconnected regions, merging the regions produces one large disconnected region.let x = NonSendable() // Regions: [(x)] let y = NonSendable() // Regions: [(x), (y)] useValue(x, y) // Regions: [(x, y)]
-
Disconnected and Actor Isolated. Merging a disconnected region and an actor-isolated region expands the actor-isolated region with the values in the disconnected region. This forces all values in the disconnected region to be treated as if they are isolated to the actor. This can only occur when calling a method on an actor or assigning into an actor's field:
func example1() async { let x = NonSendable() // Regions : [(x)] let a = Actor() // Regions: [(x), {(a.field), a}] await a.useNonSendable(x) // Regions: [{(x, a.field), a}] useValue(x) // Error! 'x' is effectively isolated to 'a' let y = NonSendable() // Regions: [{(x, a.field), a}, (y)] a.field = y // Regions: [{(x, a.field, y), a}] useValue(y) // Error! 'y' is effectively isolated to 'a' }
-
Disconnected and Task isolated. Merging a disconnected region and a task-isolated region expands the task-isolated region with the values in the disconnected region. This forces all values in the disconnected region to be treated like they are isolated to the task:
func nonIsolated(_ arg: NonSendable) async { // Regions: [{(arg), Task1}] let x = NonSendable() // Regions: [{(arg), Task1}, (x)] arg.doSomething(x) // Regions: [{(arg, x), Task1}] await transferToMainActor(x) // Error! 'x' is isolated to 'Task1' }
-
Actor isolated and Actor isolated. Merging two actor-isolated regions results in an invalid region. This can only occur via conditional control flow since an actor isolated region cannot be transferred into another actor's isolation region:
func test() async { let a1 = Actor() // Regions: [{(), a1}] let a2 = Actor() // Regions: [{(), a1}, {(), a2}] let x = NonSendable() // Regions: [{(), a1}, {(), a2}, (x)] if await boolean { await a1.useNS(x) // Regions: [{(x), a1}, {(), a2}] } else { await a2.useNS(x) // Regions: [{(), a1}, {(x), a2}] } // Regions: [{(x), invalid}, {(), a1}, {(), a2}] }
In the above example,
x
cannot be accessed fromtest
after theif
statement sincex
is now in an invalid isolation domain. -
Actor Isolated and Task Isolated. Merging an actor isolated region and task isolated region results in an invalid isolation region. This occurs since an actor isolated region and a task isolated region can run concurrently from each other. Since values in either type of region cannot be transferred, this can only occur through conditional control flow:
func nonIsolated(_ arg: NonSendable) async { // Regions: [{(arg), Task1}] let a = Actor() // Regions: [{(), a}, {(arg), Task1}] let x = NonSendable() // Regions: [(x), {(), a}, {(arg), Task1}] if await boolean { await a.useNS(x) // Regions: [{(x), a}, {(arg), Task1}] } else { arg.useNS(x) // Regions: [{(), a}, {(arg, x), Task1}] } // Regions: [{(arg, x), invalid}, {(), a}, {(), Task1}] }
-
Task Isolated and Task Isolated. Since task isolated isolation regions are only introduced due to function arguments, it is impossible to have two separate task isolated regions that could be merged.
When we transfer a value over an isolation boundary, the caller according to the ownership conventions of Swift may still own the value despite it being illegal for the caller to use the value due to region based isolation:
class NonSendable {
deinit { print("deinit was called") }
}
@MainActor func transferToMainActor<T>(_ t: T) async { }
actor MyActor {
func example() async {
// Regions: [{(), self}]
let x = NonSendable()
// Regions: [(x), {(), self}]
await transferToMainActor(x)
// Regions: [{(x), @MainActor}, {(), self}]
// Error! Since 'x' was transferred to @MainActor, we cannot use 'x'
// directly here.
useValue(x) // (1)
print("After nonisolated callee")
// But since example still owns 'x', the lifetime of 'x' ends here. (2)
}
}
let a = MyActor()
await a.example()
In the above example, the program will first print out "After nonisolated
callee" and then "deinit was called". This is because even though
nonIsolatedCallee
is transferred x
's region, x
is still passed to
nonIsolatedCallee
using Swift's default guaranteed ownership convention. This
implies that the caller from an ownership perspective still owns the memory of
the class implying the lifetime of x
actually ends at (1)
despite the caller
not being able to use x
directly at that point.
This illustrates how the transfer convention used when passing a value over an isolation boundary is a weak transfer convention. A weak transfer convention implies that one can still reference a value within the transferred region from the original isolation domain, but one cannot access the value through the reference. In contrast, a strong transfer convention would require that the caller isolation domain cannot maintain even references to values in the transferred isolation region. This would require transferring to always be a +1 operation since to preserve this property we would always need to pass off ownership from the caller to the callee to ensure that the callee cleans up the region as shown in the example above.
Requiring our transfer convention to be a strong convention would have several unfortunate side-effects:
-
All async functions would by default take their parameters as owned. This would be an ABI break and would also have the unfortunate consequence that the bodies of asynchronous functions could never be marked as readonly or readnone since they may need to invoke a deinit to end ownership of a value and deinits may have unknown side-effects.
-
This would hurt the performance of asynchronous functions by increasing the amount of ARC overhead required since unless we inline, there will be a cross function call boundary copy that can not be eliminated. This in turn would cause hits to code-size since to remedy this performance problem the inliner would need to be more aggressive about inlining code.
To achieve a strong transfer convention, one can use the transferring function parameter annotation. Please see extensions below for more information about transferring.
Since our transfer convention is weak, a disconnected isolation region that
was transferred into an isolation domain can be used again if the isolation
domain no longer maintains any references to the region. This occurs with
nonisolated
asynchronous functions. When we transfer a disconnected value into
a nonisolated
asynchronous functions, the value becomes part of the function's
task isolated isolation domain for the duration of the function's
execution. Once the function finishes executing, we know that the value is no
longer isolated to the function since:
-
A
nonisolated
function does not have any non-temporary isolated state of its own that the non-Sendable
value could escape into. -
Parameters in a task isolated isolation region cannot be transferred into a different isolation domain that does have persistent isolated state.
Thus the value in the caller's region again becomes disconnected once more and thus can be used after the function returns and be transferred again:
func nonIsolatedCallee(_ x: NonSendable) async { ... }
func useValue(_ x: NonSendable) { ... }
@MainActor func transferToMainActor<T>(_ t: T) { ... }
actor MyActor {
var state: NonSendable
func example() async {
// Regions: [{(), self}]
let x = NonSendable()
// Regions: [(x), {(), self}]
// While nonIsolatedCallee executes the regions are:
// Regions: [{(x), Task}, {(), self}]
await nonIsolatedCallee(x)
// Once it has finished executing, 'x' is disconnected again
// Regions: [(x), {(), self}]
// 'x' can be used since it is disconnected again.
useValue(x) // (1)
// 'x' can be transferred since it is disconnected again.
await transferToMainActor(x) // (2)
// Error! After transferring to main actor, permanently
// in main actor, so we can't use it.
useValue(x) // (3)
}
}
In the example above, we transfer x
into nonIsolatedCallee
and while
nonIsolatedCallee
is executing are not allowed to access x
in the
caller. Since nonIsolatedCallee
's execution ends immediately after it is
called, we are then allowed to use x
again.
Currently non-Sendable
closures like other non-Sendable
values are not
allowed to be passed over isolation boundaries since they may have captured
state from within the isolation domain in which the closure is defined. We would
like to loosen these rules.
A non-Sendable
closure's region is the merge of its non-Sendable
captured
parameters. As such a nonisolated non-Sendable
closure that only captures
values that are in disconnected regions must itself be in a disconnected region
and can be transferred:
let x = NonSendable()
// Regions: [(x)]
let y = NonSendable()
// Regions: [(x), (y)]
let closure = { useValues(x, y) }
// Regions: [(x, y, closure)]
await transferToMain(closure) // Ok to transfer!
// Regions: [{(x, y, closure), @MainActor}]
A non-Sendable
closure that captures an actor-isolated value is considered to
be within the actor-isolated region of the value:
actor MyActor {
var ns = NonSendable()
func doSomething() {
let closure = { print(self.ns) }
// Regions: [{(closure, self.ns), self}]
await transferToMain(closure) // Error! Cannot transfer value in actor region.
}
}
When a non-Sendable
value is captured by an actor-isolated non-Sendable
closure, we treat the value as being transferred into the actor isolation domain
since the value is now able to merged into actor-isolated state:
@MainActor var nonSendableGlobal = NonSendable()
func globalActorIsolatedClosureTransfersExample() {
let x = NonSendable()
// Regions: [(x), {(nonSendableGlobal), MainActor}]
let closure = { @MainActor in
nonSendableGlobal = x // Error! x is transferred into @MainActor and then accessed later.
}
// Regions: [{(nonSendableGlobal, x, closure), MainActor}]
useValue(x) // Later access is here
}
actor MyActor {
var field = NonSendable()
func closureThatCapturesActorIsolatedStateTransfersExample() {
let x = NonSendable()
// Regions: [(x), {(nonSendableGlobal), MainActor}]
let closure = {
self.field.doSomething()
x.doSomething() // Error! x is transferred into @MainActor and then accessed later.
}
// Regions: [{(nonSendableGlobal, x, closure), MainActor}]
useValue(x) // Later access is here
}
}
Importantly this ensures that APIs like assumeIsolated
that take an
actor-isolated closure argument cannot introduce races by transferring function
parameters of nonisolated functions into an isolated closure:
actor ContainsNonSendable {
var ns: NonSendableType = .init()
nonisolated func unsafeSet(_ ns: NonSendableType) {
self.assumeIsolated { isolatedSelf in
isolatedSelf.ns = ns // Error! Cannot transfer a parameter!
}
}
}
func assumeIsolatedError(actor: ContainsNonSendable) async {
let x = NonSendableType()
actor.unsafeSet(x)
useValue(x) // Race is here
}
Within the body of a non-Sendable
closure, the closure and its non-Sendable
captures are treated as being Task isolated since just like a parameter, both
the closure and the captures may have uses in their caller:
var x = NonSendable()
var closure = {}
closure = {
await transferToMain(x) // Error! Cannot transfer Task isolated value!
await transferToMain(closure) // Error! Cannot transfer Task isolated value!
}
A nonisolated non-Sendable
synchronous or asynchronous closure that is in a
disconnected region can be transferred into another isolation domain if the
closure's region is never used again locally:
extension MyActor {
func synchronousNonIsolatedNonSendableClosure() async {
// This is non-Sendable and nonisolated since it does not capture MyActor or
// any field of my actor.
let nonSendable = NonSendable()
let closure: () -> () = {
print("I am in a closure: \(nonSendable.name)")
}
// We can safely transfer closure.
await transferClosure(closure)
// If we were to invoke closure again, an error diagnostic would be
// emitted.
closure() // Error!
// If we were to access nonSendable, an error diagnostic would be
// emitted.
nonSendable.doSomething() // Error!
}
}
An actor-isolated synchronous non-Sendable
closure cannot be transferred to a
callsite that expects a synchronous closure. This is because as part of
transferring the closure, we have erased the specific isolation domain that the
closure was isolated to, so we cannot guarantee that we will invoke the value in
the actor's isolation domain:
@MainActor func transferClosure(_ f: () -> ()) async { ... }
extension Actor {
func isolatedClosure() async {
// This closure is isolated to actor since it captures self.
let closure: () -> () = {
self.doSomething()
}
// When we transfer the closure, we have lost the specific actor that
// the closure belongs to so an error must be emitted!
await transferClosure(closure) // Error!
}
}
We may be able to accept this code in the future if we allowed for isolated synchronous closures to propagate around the specific isolation domain that they belonged to and dynamically swap to it. We discuss dynamic isolation domains as an extension below.
In contrast, one can transfer an actor-isolated synchronous non-Sendable
closure at a call site that expects an asynchronous function argument. This is
because the closure will be wrapped into an asynchronous thunk that will hop
onto the defining isolation domain of the closure:
@MainActor func transferClosure(_ f: () async -> ()) async { ... }
extension Actor {
func isolatedClosure() async {
// This closure is isolated to actor since it captures self.
let closure: () -> () = {
self.doSomething()
}
// As part of transferring the closure, the closure is wrapped into an
// asynchronous thunk that will hop onto the Actor's executor.
await transferClosure(closure)
}
}
In the example above, since the closure is wrapped in the asynchronous thunk and that thunk hops onto the Actor's executor before calling the closure, we know that isolation to the actor is preserved when we call the synchronous closure.
An actor-isolated asynchronous non-Sendable
closure can be transferred since
upon the closure's invocation, we will always hop into the actor's isolation
domain:
extension Actor {
func isolatedClosure() async {
// This async closure is isolated to actor since it captures self.
let closure: () async -> () = {
self.doSomething()
}
// Since the closure is async, we can transfer it as much as we want
// since we will always invoke the closure within the actor's isolation
// domain...
await transferClosure(closure)
// ... so this is safe as well.
await transferClosure(closure)
}
}
If a closure uses values that are isolated from a global actor in any way, we assume that the closure must also be isolated to that global actor:
@MainActor func mainActorUtility() {}
@MainActor func mainActorIsolatedClosure() async {
let closure = {
mainActorUtility()
}
// Regions: [{(closure), @MainActor}]
await transferToCustomActor(closure) // Error!
}
If mainActorUtility
was not called within closure
's body then closure
would be disconnected and could be transferred:
@MainActor func mainActorUtility() {}
@MainActor func mainActorIsolatedClosure() async {
let closure = {
...
}
// Regions: [(closure)]
await transferToCustomActor(closure) // Ok!
}
A non-Sendable
keypath that is not actor-isolated is considered to be
disconnected and can be transferred into an isolation domain as long as the
value's region is not reused again locally:
class Person {
var name = "John Smith"
}
class Wrapper<Root: AnyObject> {
var root: Root
init(root: Root) { self.root = root }
func setKeyPath<T>(_ keyPath: ReferenceWritableKeyPath<Root, T>, to value: T) {
root[keyPath: keyPath] = value
}
}
func useNonIsolatedKeyPath() async {
let nonIsolated = Person()
// Regions: [(nonIsolated)]
let wrapper = Wrapper(root: nonIsolated)
// Regions: [(nonIsolated, wrapper)]
let keyPath = \Person.name
// Regions: [(nonIsolated, wrapper, keyPath)]
await transferToMain(keyPath) // Ok!
await wrapper.setKeyPath(keyPath, to: "Jenny Smith") // Error!
}
A non-Sendable
keypath that is actor-isolated is considered to be in the
actor's isolation domain and as such cannot be transferred out of the actor's
isolation domain:
@MainActor
final class MainActorIsolatedKlass {
var name = "John Smith"
}
@MainActor
func useKeyPath() async {
let actorIsolatedKlass = MainActorIsolatedKlass()
// Regions: [{(actorIsolatedKlass.name), @MainActor}]
let wrapper = Wrapper(root: actorIsolatedKlass)
// Regions: [{(actorIsolatedKlass.name), @MainActor}]
let keyPath = \MainActorIsolatedKlass.name
// Regions: [{(actorIsolatedKlass.name, keyPath), @MainActor}]
await wrapper.setKeyPath(keyPath, to: "value") // Error! Cannot pass non-`Sendable`
// keypath out of actor isolated domain.
}
If a KeyPath captures any values then the KeyPath's region consists of a merge of the captured values regions combined with the actor-isolation region of the KeyPath if the KeyPath is isolated to an actor:
class NonSendableType {
subscript<T>(_ t: T) -> Bool { ... }
}
func keyPathInActorIsolatedRegionDueToCapture() async {
let mainActorKlass = MainActorIsolatedKlass()
// Regions: [{(mainActorKlass), @MainActor}]
let keyPath = \NonSendableType.[mainActorKlass]
// Regions: [{(mainActorKlass, keyPath), @MainActor}]
await transferToMainActor(keyPath) // Error! Cannot transfer keypath in actor isolated region!
}
func keyPathInDisconnectedRegionDueToCapture() async {
let ns = NonSendableType()
// Regions: [(ns)]
let keyPath = \NonSendableType.[ns]
// Regions: [(ns, keyPath)]
await transferToMainActor(ns)
useValue(keyPath) // Error! Use of keyPath after transferring ns
}
When an async let binding is initialized with an expression that uses a
disconnected non-Sendable
value, the value is treated as being transferred
into a nonisolated
asynchronous callee that additionally allows for the value
to be transferred. If the value is used only by synchronous code and
nonisolated
asynchronous functions, we allow for the value to be reused again
once the async let binding has been awaited upon:
func nonIsolatedCallee(_ x: NonSendable) async -> Int { 5 }
actor MyActor {
func example() async {
// Regions: [{(), self}]
let x = NonSendable()
// Regions: [(x), {(), self}]
async let value = nonIsolatedCallee(x) + x.integerField
// Regions: [{(x), Task}, {(), self}]
useValue(x) // Error! Illegal to use x here.
await value
// Regions: [(x), {(), self}]
useValue(x) // Ok! x is disconnected again so it can be used...
await transferToMainActor(x) // and even transferred to another actor.
}
}
If the disconnected value is transferred into an actor region, the value is treated as if the value was transferred into the actor region at the point where the async let is declared and is considered transferred even after the async let has been awaited upon:
// Regions: []
let x = NonSendable()
// Regions: [(x)]
async let y = transferToMainActor(x) // Transferred here.
// Regions: [{(x), @MainActor}]
_ = await y
// Regions: [{(x), @MainActor}]
useValue(x) // Error! x is used after it has been transferred!
If a disconnected value is reused later in an async let initializer after transferring it into an actor region, a use after transfer error diagnostic will be emitted:
// Regions: []
let x = NonSendable()
// Regions: [(x)]
async let y =
transferToMainActorAndReturnInt(x) +
useValueAndReturnInt(x) // Error! Cannot use x after it has been transferred!
Since a disconnected value can only be transferred into one async let binding at
a time, a use after transfer diagnostic will be emitted if one initializes
multiple async let bindings in one statement with the same non-Sendable
disconnected value:
// Regions: []
let x = NonSendable()
// Regions: [(x)]
async let y = x,
z = x // Error! Cannot use x after it has been transferred!
A non-Sendable
value that is in an actor isolation region is never allowed to
be used to initialize an async let binding since values in an async let
binding's initializer are allowed to be transferred into further callees:
actor MyActor {
var field = NonSendable()
func example() async {
// Regions: [{(self.field), self}]
async let value = transferToMainActor(field) // Error! Cannot transfer actor
// isolated field to
// @MainActor!
_ = await value
}
}
In SE-0327, a flow sensitive diagnostic
was introduced to ensure that one can directly access stored properties of self
in nonisolated
actor designated initializers and actor deinitializers despite
the methods not being isolated to self. The diagnostic set out a model where
initially nonisolated
self is stated to have a weaker form of isolation that
relies on having exclusive access to self. While self is in that state, one is
allowed to access stored properties of self, but once self has escaped that
property is lost and self becomes nonisolated preventing one from accessing its
stored properties without using synchronization. In this proposal, we subsume
that proposal into the region based isolation model and eliminate the need for a
separate flow sensitive diagnostic.
In Swift's concurrency model, an actor is Sendable since one can only access the actor's internal state from the actor's executor. If the actor is nonisolated to the current function this implies one must hop on to the actor's executor to safely access state. In the case of an initializer or deinitializer with nonisolated self, this creates a conundrum since we explicitly want to initialize or deinitialize self's stored fields without synchronizing by hopping onto the actor's executor.
In order to implement these semantics, we model self as entering these methods
as a non-Sendable
value that is strongly transferred into the method. Since
self is strongly transferred, we know that there cannot be any other references
in the program to self when the method begins executing and thus it is safe to
initially access the internal state of the actor directly. Self must initially
be a non-Sendable
value since if self's storage can be accessed directly, then
passing self to another task could lead to a race on self's storage. To prevent
this possibility, when self escapes self becomes instantaneously
Sendable
. Once self is Sendable
, it is no longer safe to access self's
storage directly:
actor Actor {
var nonSendableField: NonSendableType
// self is passed into init using a strongly transferred convention. This means
// that it is unique and safe to access without worrying about concurrency.
init() {
// At this point, self is non-Sendable and we can access its fields directly.
self.nonSendableField = NonSendableType()
// self is Sendable once callMethod is executed. This includes in callMethod itself.
self.callMethod()
// Error! Cannot directly access storage of a Sendable actor.
self.nonSendableField.useValue()
}
}
In the example above, self starts as a unique non-Sendable
typed value. Thus
it is safe for us to initialize self.nonSendableField
. When self is passed
into callMethod
, self becomes Sendable
. Since self could have been
transferred to another task by callMethod, it is no longer safe to directly
access self's memory and thus we emit an error when we access
self.nonSendableField
.
Deinits work just like inits with one additional rule. Just like with initializers,
self is considered initially to be strongly transferred and non-Sendable
. One
is allowed to access the Sendable
stored properties of self while self is
non-Sendable
. One can access the non-Sendable
fields of self if one knows
statically that the non-Sendable
fields are uniquely isolated to the self
instance. For the case of actors, this means that since the actor's state is
completely isolated only to that one actor instance we can touch non-Sendable
fields. But in the case of global actor isolated classes this is not true since
other global actor isolated class instances could also have a reference to the
same non-Sendable
value since all global actor isolated instances are part of
the same isolation region:
actor Actor {
var mutableNonSendableField: NonSendableType
let immutableNonSendableField: NonSendableType
var mutableSendableField: SendableType
let immutableSendableField: SendableType
deinit {
_ = self.immutableSendableField // Ok
_ = self.mutableSendableField // Ok
// Safe to access since no other actor instances
_ = self.mutableNonSendableField // Ok
_ = self.immutableNonSendableField // Ok
escapeSelfIntoNonIsolated(self)
_ = self.immutableSendableField // Ok
_ = self.mutableSendableField // Error! Must be immutable.
_ = self.mutableNonSendableField // Error! Must be sendable
_ = self.immutableNonSendableField // Error! Must be sendable
}
}
@MainActor class GlobalActorIsolatedClass {
var mutableNonSendableField: NonSendableType
let immutableNonSendableField: NonSendableType
var mutableSendableField: SendableType
let immutableSendableField: SendableType
deinit {
_ = self.immutableSendableField // Ok
_ = self.mutableSendableField // Ok
_ = self.mutableNonSendableField // Error! Must be sendable!
_ = self.immutableNonSendableField // Error! Must be sendable!
escapeSelfIntoNonIsolated(self)
_ = self.immutableSendableField // Ok
_ = self.mutableSendableField // Error! Must be immutable!
_ = self.mutableNonSendableField // Error! Must be sendable!
_ = self.immutableNonSendableField // Error! Must be sendable!
}
}
In SE-0327, all initializers with non-Sendable
arguments were only allowed to be called by delegating initializers:
actor MyActor {
var x: NonSendableType
// Can call this from anywhere.
init(_ arg: SendableType) {
self.init(NonSendableType(arg))
}
// Since this has a non-Sendable type, this designated initializer can only
// be called by other initializers like the delegating init above.
init(_ arg: NonSendableType) {
x = arg
}
}
func constructActor() {
// Error! Cannot call init with non-`Sendable` argument from outside of
// MyActor.
let a = Actor(NonSendableType())
}
Using isolation regions we can loosen this restriction and allow for
non-Sendable
types to be passed to asynchronous initializers since our region
isolation rules guarantee that the caller will have transferred the value into
the initializer due to the isolation boundary:
actor MyActor {
var x: NonSendableType
init(_ arg: NonSendableType) async {
self.x = arg
}
}
func makeActor() async -> MyActor {
// Regions: []
let x = NonSendableType()
// Regions: [(x)]
let a = await MyActor(x) // Ok!
// Regions: [{(x), a}]
return a
}
In the above example, it is safe to pass x
into MyActor
despite x
being
non-Sendable
since if we were to use x
afterwards, the compiler would error
since we would be using x
from multiple isolation domains:
func makeActor() async -> MyActor {
// Regions: []
let x = NonSendableType()
// Regions: [(x)]
let a = await MyActor(x) // Ok!
// Regions: [{(x), a}]
x.doSomething() // Error! 'x' was transferred to a's isolation domain!
return a
}
Sadly synchronous initializers without additional work can still only take
Sendable
types since there is not a guarantee that the non-Sendable
types
that are passed to it is in its own region. In order to pass a non-Sendable
type to a synchronous initializer, one must mark the parameter with the
transferring
function parameter modifier which is described below in Future
Directions.
In this proposal, regions are not computed in a field sensitive manner. This means that if we assign into a struct with multiple stored fields or a tuple with multiple fields then assigning to one field affects the region of the entire struct and requires us to merge into such types rather than assign since otherwise we would lose the regions associated with the other fields:
struct NonSendableBox {
var s1 = NonSendable()
var s2 = NonSendable()
}
func mergeWhenAssignIntoMultiFieldStructField() async {
var box = NonSendableBox()
// Regions: [(box.s1, box.s1)]
let x = NonSendable()
// Regions: [(box.s1, box.s2), (x)]
let y = NonSendable()
// Regions: [(box.s1, box.s2), (x), (y)]
box.s1 = x
// Regions: [(box.s1, box.s2, x), (y)]
// If we used an assignment operation instead of a merge operation,
// this would cause us to lose that x was still in box.s1 and thus
// in box's region.
box.s2 = y
// Regions: [(box.s1, box.s2, x, y)]
}
In the above example, if we were to treat box.s2 = y
as an assignment
instead of merge then we would be removing x
from box
's region which
would be unsound since x
and box.s1
still point at the same
reference. Unfortunately this has the affect that when we overwrite an element
of a var like struct, the previous region assigned to that field would have to
remain in the overall struct/tuple's region:
func mergeWhenAssignIntoMultiFieldTupleField() async {
var box = (NonSendable(), NonSendable())
// Regions: [(box.0, box.1)]
let x = NonSendable()
// Regions: [(box.0, box.1), (x)]
let y = NonSendable()
// Regions: [(box.0, box.1), (x), (y)]
box.0 = x
// Regions: [(box.0, box.1, x), (y)]
box.0 = y (1)
// Regions: [(box.0, box.1, x, y)]
}
In the above, even though we reassign box.0
from x
to y
, since we
must perform a merge, we must have that x
is still in box
's region. If
one assigns over the entire box though, one can still get an assign instead of a
region:
func mergeWhenAssignIntoMultiFieldTupleField2() async {
var box = (NonSendable(), NonSendable())
// Regions: [(box.0, box.1)]
let x = NonSendable()
// Regions: [(box.0, box.1), (x)]
let y = NonSendable()
// Regions: [(box.0, box.1), (x), (y)]
box.0 = x
// Regions: [(box.0, box.1, x), (y)]
box = (y, NonSendable())
// Regions: [(box.0, box.1, y), (x)]
}
In order to mitigate this, we are able to be stricter with structs and tuples that store a single field. In such a case, since the struct/tuple does not have multiple fields updating the single field does not cause us to lose the region of any other values:
func assignWhenAssignIntoSingleFieldStruct() async {
var box = SingleFieldBox()
// Regions: [(box.field)]
let x = NonSendable()
// Regions: [(box.field), (x)]
let y = NonSendable()
// Regions: [(box.field), (x), (y)]
box.field = x
// Regions: [(box.field, x), (y)]
box.field = y
// Regions: [(box.field, y), (x)]
}
Given a non-Sendable
value x
that has been weakly transferred, a Sendable
field x.f
can be accessed in the caller after x
's transferring if the
compiler can statically prove that there cannot be any writes to x.f
from
another concurrency domain. This is necessary since although x.f
is
Sendable
, if code from another concurrency domain can reference x
in a
manner that allows for x.f
to be written to, our initial access to x.f
could
result in a race. Of course once the access is over, we are safe against races
due to the Sendability of x.f
's underlying type. The situations where this
occurs varies in between reference types and value types. We go through the
individual cases below.
If x
is a reference type like a class, we only allow for Sendable
let fields
of x
to be accessed. This is safe since a let field can never be modified
after initialization implying that we cannot race on assignment to the field
when attempting to read from the field. We cannot allow for Sendable
var
fields to be accessed due to the aforementioned possible race caused by another
concurrency domain writing to the Sendable
field as we attempt to access it:
class NonSendable {
let letSendable: SendableType
var varSendable: SendableType
let ns: NonSendable
}
@MainActor func modifyOnMainActor(_ x: NonSendable) async {
x.varSendable = SendableType()
}
func example() async {
let x = NonSendable()
await modifyOnMainActor(x)
_ = x.letSendable // This is safe.
_ = x.varSendable // Error! Use after transfer of mutable field that could
// race with a write to x.varSendable in modifyOnMainActor.
}
If x
is an immutable binding (e.x.: let) to a value type (e.x.: struct, tuple,
enum) then we allow for access to all of x
's Sendable
subtypes. This is safe
because:
-
x
will be initialized by copying its initial value. This means that even ifx
's initial value is a field of a larger value, any modifications to the other value will not causex
's fields to point to different values. -
When
x
is transferred to a callee,x
will be passed by value. Thus the callee will receive a completely new value type albeit with copied fields. This means that if the callee attempts to modify the value, it will be modifying the new value instead of our caller value implying that we cannot race against any assignment when accessing the field in our caller.struct NonSendableStruct { let letSendableField: Sendable var varSendableField: Sendable let ns: NonSendable } @MainActor func modifyOnMainActor(_ y: consuming NonSendableStruct) async { // These assignments only affect our parameter, not x in the callee. y.varSendableField = Sendable() y = NonSendableStruct() } func letExample() async { let x = NonSendableStruct() await modifyOnMainActor(x) // Transfer x, giving useValueOnMainActor a // shallow copy of x. // We do not race with the assignment in modifyOnMainActor since the // assignment is to y, not to x. Since the fields are sendable, once // we avoid the race on accessing the field, we are safe. print(x.letSendableField) print(x.varSendableField) }
-
If
x
is captured by reference, sincex
is a let it will be captured immutably implying that we cannot write tox.f
.
If x
is a mutable binding (e.x.: var
), then we can follow the same logic as
with our immutable bindings except in the case where x
is captured by
reference. If x
is captured by reference, it is captured mutably implying that
when accessing x.f
, we could race against an assignment to x.f
in the
closure:
struct NonSendableStruct {
let letSendableField: Sendable
var varSendableField: Sendable
let ns: NonSendable
}
@MainActor func invokeOnMain(_ f: () -> ()) async {
f()
}
func unsafeMutableReferenceCaptureExample() async {
var x = NonSendableStruct()
let closure = {
x = NonSendableStruct(otherInit: ())
}
await invokeOnMain(closure)
_ = x.letSendableField // Error! Could race against write in closure!
_ = x.varSendableField // Error! Could race against write in closure!
}
This also implies that one cannot access Sendable
computed properties or
functions later since those routines could perform a read like the above
resulting in a race against a write in the closure.
Region-based isolation opens up a new data-race safety hole when using APIs
change the static isolation in the implementation of a nonisolated
function,
such as assumeIsolated
, because values can become referenced by actor-isolated
state without any indication in the function signature:
class NonSendable {}
@MainActor var globalNonSendable: NonSendable = .init()
nonisolated func stashIntoMainActor(ns: NonSendable) {
MainActor.assumeIsolated {
globalNonSendable = ns
}
}
func stashAndTransfer() -> NonSendable {
let ns = NonSendable()
stashIntoMainActor(ns)
Task.detached {
print(ns)
}
}
@MainActor func transfer() async {
let ns = stashAndTransfer()
await sendSomewhereElse(ns)
}
Without additional restrictions, the above code would be valid under this proposal,
but it risks a runtime data-race because the value returned from stashAndTransfer
is stored in MainActor
-isolated state and send to another isolation domain to
be accessed concurrently. To close this hole, values must be sent into and out of
assumeIsolated
. The base region-isolation rules accomplish this by treating
captures of isolated closures as a region merge, and the standard library annotates
assumeIsolated
as requiring the result type T
to conform to Sendable
. This
impacts existing uses of assumeIsolated
, so the change is staged in as warnings
under complete concurrency checking, which enables RegionBasedIsolation
by default,
and an error in Swift 6 mode.
This has no affect on ABI.
In the above, we mentioned that the transferring of non-Sendable
values as
discussed above is a callee side property since when analyzing an async callee,
we do not know if the callee's caller is from a different isolation domain or
not. This means that we must be conservative and treat all function parameters as
being in the same region and prevent transferring of function parameters.
We could introduce a stronger form of transferring that is applied to a function argument in the callee's signature and forces all callers to transfer the parameter even if the caller is synchronous or is async but in the same isolation domain.
The transferred parameter is guaranteed to be strongly transferred so we know that once the callee is called there are no other program visible references to the value outside of the callee's parameter. The implications of this are:
-
Since the value is strongly isolated, it will be within its own disconnected region separate from the regions of the other parameters:
actor Actor { func method(_ x: transferring NonSendable, _ y : NonSendable, _ z : NonSendable) async { // Regions: [(x), {(y, z), self}] // Safe to transfer x since x is marked as transferring. await transferToMainActor(x) } }
-
Regardless of if the callee is synchronous or asynchronous, a non-
Sendable
value that is passed as a transferring parameter cannot be used again locally.actor Actor { func transfer<T>(_ t: transferring T) async {} func method() async { let a = NonSendable() // Pass a into transfer. Even though we are in the same // isolation domain as transfer... await transfer(a) // Since we transferred a, we are no longer allowed to use a here. Error! useValue(a) } }
-
Given an asynchronous function, one can safely transfer the non-
Sendable
parameter to another asynchronous function with a different isolation domain:@MainActor func transferToMainActor<T>(_ t: T) async {} actor Actor { func method(_ x: transferring NonSendable) async { // Regions: [(x)] // Safe to transfer x since x is marked as transferring. await transferToMainActor(x) } }
-
Given a transferring parameter of a synchronous function, the parameter's strongly isolated implies that we can transfer it into
Task.init
orTask.detach
.func someSynchronousFunction(_ x: transferring NonSendable) { Task { doSomething(x) } }
if we did not have the strong isolation, then
x
could still be used in the caller ofsomeSynchronousFunction
. -
Due to the isolation of a transferring parameter, it is legal to have a non-
Sendable
transferring parameter of a synchronous actor designated initializer:actor Actor { var field: NonSendable init(_ x: transferring NonSendable) { self.field = x } }
Without the transferring argument modifier on
x
, it would not be safe to storex
intoself.field
since it may be introducing a value into the actor's state that could be raced upon.
As discussed above, if a function takes non-Sendable
parameters and has a
non-Sendable
result, then the result is part of the merged region of the
function's parameters. This is not always the appropriate semantics since there
are APIs whose results will be in different regions than their parameters. As an
example of this, consider a function that performs control flow based off of
non-Sendable
state and then returns a result:
func example(_ x: NonSendable) async -> NonSendable? {
if x.boolean {
return NonSendable()
}
return nil
}
In the above, the result of example
is a newly initialized value that has no
data dependence on the parameter x
, but as laid out in this proposal, we
cannot express this. We propose the addition of a new function parameter
modifier called returnsIsolated
that causes callers to treat the result of a
function as being in a disconnected region regardless of the inputs. As part of
this annotation, we would only allow for the callee to return a value that is in
a disconnected region preventing the returning of function arguments or in the
case of an actor any state related internally to the actor:
actor Actor {
var field: NonSendableType
func getValue() -> @returnsIsolated NonSendableType {
// Regions: [{(self.field), self}]
let x = NonSendableType()
// Regions: [(x), {(self.field), self}]
if await booleanValue {
// Safe to do since 'x' is in a disconnected region.
return x
}
// Error! Cannot return a value from the actor's region!
return field
}
}
Since the value returned is always in its own disconnected region, it can be used in the caller isolation domain without triggering races:
func getValueFromActor(_ a: Actor) async {
// Regions: [{(a.field), a}]
// This is safe since we know that 'x' is independent of the actor.
let x = await a.getValue()
// Regions: [(x), {(a.field), a}]
// So we could transfer it to another function if we wanted to.
await transferToMainActor(x)
}
NOTE: @returnsIsolated is just a strawman syntax introduced for the purpose of expositing this extension. It is not an actual proposed or final syntax.
Even though we can use @returnsIsolated
to return a value from the Actor's
isolation domain, we have not specified a manner to safely return non-Sendable
values from the internal state of an Actor or GAIT. To do so, we introduce a new
type of field called a disconnected field. A disconnected field of an actor is
an actor isolated region that is separate from the normal actor's region. Since
it is separate from the other region of the actor, it cannot be reachable by the
other fields of the actor... but since it is an actor field, it cannot be
escaped from the actor without doing additional work. In order to escape such a
field, we introduce a new disconnect
operation that consumes the disconnected
field and returns the field's value as a new disconnected region which is safe
to use as a @returnsIsolated
result:
actor MyActor {
disconnected var x: NonSendableType
/// Reinitialize a field, returning the old value.
func reinitField() -> @returnsIsolated NonSendableType {
let result = disconnect x
x = NonSendableType()
return result
}
}
In the above example, we disconnect x
's value into result
, reinitialize x
with a fresh value, and return the result.
NOTE: We may be able to reuse the
consume
operator for this purpose, but for the purposes of framing this as an extension, we introduce a new operator for simplicity.
If the author forgets to update the disconnected field with a new value, a control flow sensitive error will be emitted:
actor MyActor {
disconnected var x: NonSendableType
func reinitField() -> @returnsIsolated NonSendableType {
let result = disconnect x
if booleanTest {
x = newValue
} else {
...
}
return result
} // Error! Must update disconnected field 'x' along all program paths after disconnecting!
}
In the above example, we emit an error since along the else path we do not
provide a new value for x
.
Since a disconnected field can only be initialized with a value from a disconnected region implying that a field cannot be assigned to by a parameter of an actor method unless the parameter is transferred:
actor MyActor {
disconnected var x: NonSendableType
/// Update the internal state to use a new value, returning the old value
func updateValue(_ newValue: transferring NonSendableType) -> @returnsIsolated NonSendableType {
let result = disconnect x
x = newValue
return result
}
}
since the parameter in the above example is transferred, it has a disconnected region and thus can be assigned into the disconnected region.
We could require users to audit all of their non-Sendable
types for
Sendability. This would create a large annotation burden on users that this
approach avoids.
We could require transferred arguments to be explicitly marked with an operator
like consume or transfer. This is not needed since the APIs in question are
already explicitly marked as being a point of concurrency via async
, await
,
or Task
implying that whether or not an API can result in transferring is
already explicitly marked. The only information that requiring an additional
explicit marker would provide the user is that the programmer can know without
reading the API surface that a transfer will occur here, information that can
also be ascertained by just reading the source.
This proposal is based on work from the PLDI 2022 paper A Flexible Type System for Fearless Concurrency.
Thanks to Doug Gregor, Kavon Farvardin for early assistance to Joshua during his internship.
Thanks to Doug Gregor and Holly Borla for our stimulating discussions and to Holly for her help with editing!
The dataflow for computing isolation regions is defined as follows:
-
The lattice of the dataflow consists of graphs where each value is a node and each edge represents a statement that causes two values to be apart of the same region. We partially order our lattice by stating that given a graph
g1
and a graphg2
theng1 <= g2
only ifg1 U g2 = g1
whereU
is a graph union operation. -
Control flow merges are defined by unions of graphs meaning that if there is an edge in between two nodes in any predecessor control flow blocks, there is an edge in the successor control flow block.
-
We consider the top of the dataflow to be the empty graph consisting of values that are all in their own independent regions and the bottom of our dataflow to be a completely connected graph where all values are in the same region.
-
Since the dataflow is a forward optimistic dataflow, we initially treat backedges as propagating the top graph.
-
We can prove that our dataflow always converges since our transfer function can be proven as monotonic since given two sets
g1
,g2
withg1 <= g2
, we know thatF(g1) <= F(g2)
since any edges that we remove fromg1
must also be removed fromg2
and any edges that we add will be added identically tog1
andg2
sinceg1
is a subset ofg2
.