- Proposal: SE-0431
- Authors: John McCall
- Review Manager: Doug Gregor
- Status: Implemented (Swift 6.0)
- Previous revision: 1
- Review: (pitch)(review)(acceptance)
The actor isolation of a function is an important part of how it's used. Swift can reason precisely about the isolation of a specific function declaration, but when functions are passed around as values, Swift's function types are not expressive enough to keep up. This proposal adds a new kind of function type that carries its function's actor isolation dynamically. This solves a variety of expressivity problems in the language. It also allows features such as the standard library's task-creation APIs to be implemented more efficiently and with stronger semantic guarantees.
The safety of Swift concurrency relies on understanding the isolation requirements of functions. The caller of an isolated synchronous function must run it in an appropriately-isolated context or else the function will almost certainly introduce data races.
Function declarations and closures in Swift support three different forms of actor isolation:
- They can be non-isolated.
- They can be isolated to a specific global actor type.
- They can be isolated to a specific parameter or captured value.
A function's isolation can be specified or inferred in many ways.
Non-isolation is the default if no other rules apply, and it can also
be specified explicitly with the nonisolated
modifier. Global actor
isolation can be expressed explicitly with a global actor attribute,
such as @MainActor
, but it can also be inferred from context, such
as in the methods of main-actor-isolated types. A function can
explicitly declare one of its parameters as isolated
to isolate
itself to the value of that parameter; this is also done implicitly to
the self
parameter of an actor method if the method doesn't explicitly
use some other isolation. Closure expressions can be declared with a
global actor attribute, and there is a proposal currently being
developed to also allow them to have an explicit
isolated
capture or to be explicitly non-isolated. Additionally,
when you pass a closure expression directly to the Task
initializer,
that closure is inferred to have the isolation of the enclosing context.1
These rules are fairly complex, but at the end of the day, they all
boil down to this: every function is assigned one of the three kinds of
actor isolation above.
When a function is called directly, Swift's isolation checker can analyze its isolation precisely and compare that to the isolation of the calling context. However, when a call expression calls an opaque value of function type, Swift is limited by what can be expressed in the type system:
-
A function type with no isolation specifiers, such as
() -> Int
, represents a non-isolated function. -
A function type with a global actor attribute, such as
@MainActor () -> Int
, represents a function that's isolated to that global actor. -
A function type with an
isolated
parameter, such as(isolated MyActor) - > Int
, represents a function that's isolated to that parameter.
But there's a very important case that can't be expressed in the type
system like this: a closure can be isolated to one of its captures. In
the following example, the closure is isolated to its captured self
value:
actor WorldModelObject {
var position: Point3D
func linearMove(to finalPosition: Point3D, over time: Duration) {
let originalPosition = self.position
let motion = finalPosition - originalPosition
gradually(over: time) { [isolated self] progressProportion in
self.position = originalPosition + progressProportion * motion
}
}
func updateLater() {
Task {
// This closure doesn't have an explicit isolation
// specification, and it's being passed to the `Task`
// initializer, so it will be inferred to have the same
// isolation as its enclosing context. The enclosing
// context is isolated to its `self` parameter, which this
// closure captures, so this closure will also be isolated
// that value.
self.update()
}
}
}
This inexpressible case also arises with a partial application of an
actor method, such as myActor.methodName
: the resulting function
value captures myActor
and is isolated to it. For now, these are
the only two cases of isolated captures. However, the upcoming
closure isolation control proposal is expected
to give this significantly greater prominence and importance. Under
that proposal, isolated captures will become a powerful general tool
for controlling the isolation of a specific piece of code. But there
will still not be a way to express the isolation of that closure in
the type system.2
This is a very unfortunate limitation, because it actually means that
there's no way for a function to accept a function argument with
arbitrary isolation without completely erasing that isolation. Swift
does allow functions with arbitrary isolation to be converted to a
non-isolated function type, but this comes with three severe drawbacks.
The first is that the resulting function type must be async
so that
it can switch to the right isolation internally. The second is that,
because the function changes isolation internally, it is limited in its
ability to work with non-Sendable
values because any argument or return
value must cross an isolation boundary. And the third is that the
isolation is completely dynamically erased: there is no way for the
recipient of the function value to recover what isolation the function
actually wants, which often puts the recipient in the position of doing
unnecessary work.
Here's an example of that last problem. The Task
initializer receives
an opaque value of type () async throws -> ()
. Because it cannot
dynamically recover the isolation from this value, the initializer has
no choice but to start the task on the global concurrent executor. If
the function passed to the initializer is actually isolated to an actor,
it will immediately switch to that actor on entry. This requires
additional synchronization and may require re-suspending the task.
Perhaps more importantly, it means that the order in which tasks are
actually enqueued on the actor is not necessarily the same as the order
in which they were created. It would be much better --- both semantically
and for performance --- if the initializer could immediately enqueue the
task on the right executor to begin with.
The straightforward solution to these problems is to add a type which is capable of expressing a function with an arbitrary (but statically unknown) isolation. That is what we propose to do.
This proposal adds a new attribute that can be placed on function types:
func gradually(over: Duration, operation: @isolated(any) (Double) -> ())
A function value with this type dynamically carries the isolation of the function that was used to initialize it.
When such a function is called from an arbitrary context, it must be
assumed to always cross an isolation boundary. This means, among other
things, that the call is effectively asynchronous and must be await
ed.
await operation(timePassed / overallDuration)
The isolation can be read using the special isolation
property
of these types:
func traverse(operation: @isolated(any) (Node) -> ()) {
let isolation = operation.isolation
}
The isolation checker knows that the value of this special property
matches the isolation of the function, so calls to the function from
contexts that are isolated to the isolation
value do not cross
an isolation boundary.
Finally, every task-creation API in the standard library will be updated
to take a @isolated(any)
function value and synchronously enqueue the
new task on the appropriate executor.
@isolated(any)
is a new type attribute that can only be applied to
function types. It is an isolation specification, and it is an error
to combine it with other isolation specifications such as a global
actor attribute or an isolated
parameter.
@isolated(any)
is not a concrete isolation specification and cannot
be directly applied to a declaration or a closure. That is, you cannot
declare a function entity as having @isolated(any)
isolation,
because Swift needs to know what the actual isolation is, and
@isolated(any)
does not provide a rule for that.
Let F
and G
be function types, and let F'
and G'
be the corresponding
function types with any isolation specifier removed (including but not
limited to @isolated(any)
. If either F
or G
specifies
@isolated(any)
then a value of type F
can be converted to type G
if a value of type F'
could be converted to type G'
and the
following conditions apply:
-
If
F
andG
both specify@isolated(any)
, there are no further conditions. The resulting function is dynamically isolated to the same value as the original function. -
If only
G
specifies@isolated(any)
, then the behavior depends on the specified isolation ofF
:- If
F
has anisolated
parameter, the conversion is invalid. - Otherwise, the conversion is valid, and the dynamic isolation of
the resulting function is determined as follows:
- If the converted value is the result of an expression that is a closure expression (including an implicit autoclosure), a function reference, or a partial application of a method reference, the resulting function is dynamically isolated to the isolation of the function or closure. This looks through syntax that has no impact on the value produced by the expression, such as parentheses; the list is the same as in SE-0420.
- Otherwise, if
F
is non-isolated, the resulting function is dynamically non-isolated. - Otherwise,
F
must be isolated to a global actor, and the resulting function is dynamically isolated to that global actor.
- If
-
If only
F
specifies@isolated(any)
, thenG
must be anasync
function type.G
may have any isolation specifier, but it will be ignored and the function will run with the isolation of the original value. The arguments and result must be sendable across an isolation boundary. It is unspecified whether the task will dynamically suspend when calling or returning from the resulting value.
In general, all of the isolation semantics and runtime behaviors laid out
here are affected by intermediate conversions to non-@isolated(any)
function types. For example, if you coerce a non-isolated function or
closure to the type @MainActor () -> ()
, the resulting function will
thereafter be treated as a MainActor
-isolated function; the fact that
it was originally a non-isolated function is both statically and
dynamically erased.
Calling a @isolated(any)
function value behaves the same way as a direct
call to a function with that isolation would:
-
If the function is
async
, it will run with its formal isolation. This includes leaving isolated contexts if the function is dynamically non-isolated, as specified by SE-0338. -
If the function is synchronous, it will run with its formal isolation only if it is dynamically isolated. If it is dynamically non-isolated, it will simply run synchronously in the current context, even if that is isolated, just like an ordinary call to a non-isolated synchronous function would.
Values of @isolated(any)
function type have a special isolation
property. The property is read-only and has type (any Actor)?
. The
value of the property is determined by the dynamic isolation of the
function value:
- If the function is dynamically non-isolated, the value of
isolation
isnil
. - If the function is dynamically isolated to a global actor type
G
, the value ofisolation
isG.shared
. - If the function is dynamically isolated to a specific actor reference,
the value of
isolation
is that actor reference.
Function values cannot generally be isolated to a distributed actor
unless the actor is known to be local. When a distributed actor is
local, function values isolated to the actor can be converted to
@isolated(any)
type as above. The isolation
property presents
the distributed actor as an (any Actor)?
using the same mechanism
as #isolation
.
Since the isolation of an @isolated(any)
function value is
statically unknown, calls to it typically cross an isolation boundary.
This means that the call must be await
ed even if the function is
synchronous, and the arguments and result must satisfy the usual
sendability restrictions for cross-isolation calls. The function
value itself must satisfy a slightly less restrictive rule: it must
be a sendable value only if it is async
and the current
context is not statically known to be non-isolated.3
In order for a call to an @isolated(any)
function to be treated as
not crossing an isolation boundary, the caller must be known to have
the same isolation as the function. Since the isolation of an
@isoalted(any)
parameter is necessarily an opaque value, this would
require the caller to be declared with value-specific isolation. It
is currently not possible for a local function or closure to be
isolated to a specific value that isn't already the isolation of the
current context.5 The following rules lay out how @isolated(any)
should interact with possible future language support for functions
that are explicitly isolated to a captured value. In order to
present these rules, this proposal uses the syntax currently proposed
by the closure isolation control pitch, where
putting isolated
before a capture makes the closure isolated to
that value. This should not be construed as accepting the terms of
that pitch. Accepting this proposal will leave most of this
section "suspended" until a feature with a similar effect is added
to the language.
If f
is an immutable binding of @isolated(any)
function type,
then a call to f
does not cross an isolation boundary if the
current context is isolated to a derivation of the expression
f.isolation
.
In the isolated captures pitch, a closure can be isolated to a specific
value by using the isolated
modifier on an entry in its capture list.
So this question would reduce to whether that capture was initialized
to a derivation of f.isolation
.
An expression is a derivation of some expression form E
if:
- it has the exact form required by
E
; - it is a reference to a capture or immutable binding immediately
initialized with a derivation of
E
; - it is the result of
?
(the optional-chaining operator) or!
(the optional-forcing operator) applied to a derivation ofE
; or - it is a reference to a non-optional binding (an immutable binding
initialized by a successful pattern-match which removes optionality,
such as
x
inif let x = E
) of a derivation ofE
.
The term immutable binding in the rules above means a let
constant
or immutable (non-inout
) parameter that is neither weak
nor
unowned
. The analysis ignores syntax that has no effect on the
value of an expression, such as parentheses; the exact set of cases
are the same as described in SE-0420.
For example:
func delay(operation: @isolated(any) () -> ()) {
let isolation = operation.isolation
Task { [isolated isolation] in // <-- tentative syntax from the isolated captures pitch
print("waking")
operation() // <-- does not cross an isolation barrier and so is synchronous
print("finished")
}
}
In this example, the expression operation()
calls operation
,
which is an immutable binding (a parameter) of @isolated(any)
function type. The call therefore does not cross an isolation
boundary if the calling context is isolated to a derivation of
operation.isolation
. The calling context is the closure passed
to Task.init
, which has an explicit isolated
capture named
isolation
and so is isolated to that value of that capture.
The capture is initialized with the value of the enclosing
variable isolation
, which is an immutable binding (a let
constant) initialized to operation.isolation
. As such, the
calling context is isolated to a derivation of operation.isolation
,
so the call does not cross an isolation boundary.
The primary intent of the rules above is simply to extend the
generalized isolation checking rules laid out in
SE-0420 to work with an underlying
expression like fn.isolation
. However, the rules above go
beyond the SE-0420 rules in some ways, most importantly by looking
through local let
s. Looking through such bindings was not especially
important for SE-0420, but it is important for this proposal. In
order to keep the rules consistent, the isolation checking rules from
SE-0420 will be "rebased" on top of the rules in this proposal,
as follows:
-
When calling a function with an
isolated
parametercalleeParam
, if the current context also has anisolated
parameter or capturecallerIsolation
, the function has the same isolation as the current context if the argument expression corresponding tocalleeParam
is a derivation of either:- a reference to
callerIsolation
or - a call to
DistributedActor.asAnyActor
applied to a derivation ofcalleeIsolation
.
- a reference to
As a result, the following code is now well-formed:
func operate(actor1: isolated MyActor) {
let actor2 = actor1
actor2.isolatedMethod() // Swift now knows that actor2 is isolated
}
There is no reason to write this code instead of just using actor1
,
but it's good to have consistent rules.
There are a large number of functions in the standard library that create tasks:
Task.init
Task.detached
TaskGroup.addTask
TaskGroup.addTaskUnlessCancelled
ThrowingTaskGroup.addTask
ThrowingTaskGroup.addTaskUnlessCancelled
DiscardingTaskGroup.addTask
DiscardingTaskGroup.addTaskUnlessCancelled
ThrowingDiscardingTaskGroup.addTask
ThrowingDiscardingTaskGroup.addUnlessCancelled
This proposal modifies all of these APIs so that the task function has
@isolated(any)
function type. These APIs now all synchronously enqueue
the new task directly on the appropriate executor for the task function's
dynamic isolation.
Swift reserves the right to optimize the execution of tasks to avoid
"unnecessary" isolation changes, such as when an isolated async
function
starts by calling a function with different isolation.6 In general, this
includes optimizing where the task initially starts executing:
@MainActor class MyViewController: UIViewController {
@IBAction func buttonTapped(_ sender : UIButton) {
Task {
// This closure is implicitly isolated to the main actor, but Swift
// is free to recognize that it doesn't actually need to start there.
let image = await downloadImage()
display.showImage(image)
}
}
}
As an exception, in order to provide a primitive scheduling operation with stronger guarantees, Swift will always start a task function on the appropriate executor for its formal dynamic isolation unless:
- it is non-isolated or
- it comes from a closure expression that is only implicitly isolated
to an actor (that is, it has neither an explicit
isolated
capture nor a global actor attribute). This can currently only happen withTask {}
.
As a result, in the following code, these two tasks are guaranteed to start executing on the main actor in the order in which they were created, even if they immediately switch away from the main actor without having done anything that requires isolation:3
func process() async {
Task { @MainActor in
...
}
// do some work
Task { @MainActor in
...
}
}
The exception here to allow more optimization for implicitly-isolated
closures is an effort to avoid turning Task {}
into a surprising
performance bottleneck. Programmers often reach for Task {}
just to
do something concurrently with the current context, such as downloading
a file from the internet and then storing it somewhere. However, if
Task {}
is used from an isolated context (such as from a @MainActor
event handler), the closure passed to Task
will implicitly formally
inherit that isolation. A strict interpretation of the scheduling
guarantee in this proposal would require the closure to run briefly
on the current actor before it could do anything else. That would mean
that the task could never begin the download immediately; it would have
to wait, not just for the current operation on the actor to finish, but
for the actor to finish processing everything else currently in its
queue. If this is needed, it is not unreasonable to ask programmers
to state it explicitly, just as they would have to from a non-isolated
context.
Most of this proposal is additive. The exception is the adoption
in the standard library, which changes the types of certain API
parameters. Calls to these APIs should continue to work, as any
function that could be passed to the current parameter should also
be convertible to an @isolated(any)
type. The observed type of
the API will change, however, if anyone does an abstract reference
such as Task.init
. Contravariant conversion should allow these
unapplied references to work in any concrete type context that
would accept the current function, but references in other contexts
can lead to source breaks (such as var fn = Task.init
). This is
unlikely to be an issue in practice. More importantly, I believe
Swift has a general policy of declining to guarantee stable types
for unapplied function references in the standard library this way.
Doing so would prevent a wide variety of reasonable code evolution
for the library, such as generalizing the type of a parameter (as
this proposal does) or adding a new defaulted parameter.
This feature does not change the ABI of any existing code.
The basic functionality of @isolated(any)
function types is
implemented directly in generated code and does not require runtime
support.
Using a type as a generic argument generally requires runtime type
metadata support for the type. For @isolated(any)
function types,
that metadata support requires a new Swift runtime. It will therefore
not possible to use a type such as [@isolated(any) () -> ()]
when
back-deploying code on a platform with ABI stability. However,
wrapping the function in a struct
with a single field will generally
work around this problem. (It also generally allows the function to
be stored more efficiently.)
The task-creation APIs in the standard library have been implemented in a way that allows their signatures to be changed without ABI considerations. Direct enqueuing on the isolated actor does require runtime support, but fortunately that support has present in the concurrency runtime since the first release. Therefore, there should not be any back-deployment problems supporting the proposed changes.
Adopters of @isolated(any)
function types will generally face the
same source-compatibility considerations as this proposal does with
the task-creation APIs: it requires generalizing some parameter types,
which generally should not cause incompatibilities with direct callers
but can introduce problems in the somewhat unlikely case that anyone
is using those function as values.
It is recommended that APIs which take functions that are likely to run
concurrently and don't have a predetermined isolation take those functions
as @isolated(any)
. This allows the API to make more intelligent
scheduling decisions about the function.
Examples that should usually use @isolated(any)
include:
- functions that wrap the creation of a task
- algorithms that call a function multiple times in parallel, such as a
parallel
map
Examples that should usually not use @isolated(any)
include:
- algorithms that preserve the current isolation, such as a non-parallel
map
; these functions should usually take a non-Sendable
function instead - APIs that intend to call the function with a specific isolation, such
as UI frameworks that expect their event handlers to be
@MainActor
or actor functions that run an operation on the actor
It would be convenient in some cases to be able to assert that the
current synchronous context is already isolated to the isolation of
an @isolated(any)
function, allowing the function to be called without
crossing isolation. Similar functionality is provided by the
assumeIsolated
function introduced by SE-0392.
Unfortunately, the current assumeIsolated
function is inadequate
for this purpose for several reasons.
The first problem is that assumeIsolated
only works on a
non-optional actor reference. We could add a version of this API
which does work on optional actors, but it's not clear what it
should actually do if given a nil
reference. A nil
isolation
represents non-isolation, which of course does not actually isolate
anything. Should assumeIsolated
check that the current context
has exactly the given isolation, or should it check that it is safe
to use something with the given isolation requirement from the current
context? The first rule is probably the one that most people would
assume when they first heard about the feature. However, it implies
that assumeIsolated(nil)
should check that no actors are currently
isolated, and that is not something we can feasibly check in general:
Swift's concurrency runtime does track the current isolation of a task,
but outside of a task, arbitrary things can be isolated without Swift
knowing about them. It is also needlessly restrictive, because there
is nothing that is unsafe to do in an isolated context that would be
safe if done in a non-isolated context.7 The second rule is less
intuitive but more closely matches the safety properties that static
isolation checking tests for. It implies that assumeIsolated(nil)
should always succeed. This is notably good enough for @isolated(any)
:
since assumeIsolated
is a synchronous function, only synchronous
@isolated(any)
functions can be called within it, and calling a
synchronous non-isolated function always runs immediately without
changing the current isolation.
The second problem is that assumeIsolated
does not currently establish
a link back to the original expression passed to it. Code such as
the following is invalid:
myActor.assumeIsolated {
myActor.property += 1 // invalid: Swift doesn't know that myActor is isolated
}
The callback passed to assumeIsolated
is isolated because it takes
an isolated
parameter, and while this parameter is always bound to
the actor that assumeIsolated
was called on, Swift's isolation checking
doesn't know that. As a result, it is necessary to use the parameter
instead of the original actor reference, which is a persistent annoyance
when using this API:
myActor.assumeIsolated { myActor2 in
myActor2.property += 1
}
For @isolated(any)
, we would naturally want to write this:
myFn.isolation.assumeIsolated {
myFn()
}
However, since Swift doesn't understand the connection between the
closure's isolated
parameter and myFn
, this call will not work,
and there is no way to make it work.
One way to fix this would be to add some new way to assert that an
@isolated(any)
function is currently isolated. This could even
destructure the function value, giving the program access it to as
a non-@isolated(any)
function. But it seems like a better approach
to allow isolation checking to understand that the isolated
parameter
and the self
argument of assumeIsolated
are the same value.
That would fix both the usability problem with actors and the
expressivity problem with @isolated(any)
. Decomposition could
be done as a general rule that permits isolation to be removed from
a function value as long as that isolation matches the current
context and the resulting function is non-Sendable
.
This is all sufficiently complex that it seems best to leave it for a future direction. However, it should be relatively approachable.
@isolated(any)
function types are effectively an "existential
erasure" of the isolation of the function, removing the type system's
static knowledge of the isolation while dynamically preserving it.
This is directly analogous to how Any
erases the type of the value
you store into it: the type system no longer knows statically what
type is stored there, but it's still possible to recover it dynamically.
This analogy is why this proposal uses the keyword any
in the
attribute name.
Where there's an existential, there's also a generic. The generic
analogue to @isolated(any)
would be a type that expressed that it
was isolated to a specific value, like so:
func delay<A: Actor>(on operationActor: A,
operation: @isolated(to: operationActor) () async -> ())
This is a kind of value-dependent type. Value-dependent types add a
lot of complexity to a type system. Consider how the arguments interact
in the example above: both value and type information from the first
argument flows into the second. This is not something to do lightly,
and we think Swift is relatively unlikely to ever add such a feature
as @isolated(to:)
.
Fortunately, it is unlikely to be necessary. We believe that
@isolated(any)
function types are superior from a usability perspective
for all the dominant patterns of higher-order APIs. The main thing that
@isolated(to:)
can express in an API signature that @isolated(any)
cannot is multiple functions that share a common isolation. It is
quite uncommon for APIs to take multiple closely-related functions
this way, especially @Sendable
functions where there's an expected
isolation change from the current context. When only a single function
is required in an API, @isolated(any)
allows its isolation to bound
up with it in a single value, which is both more convenient and likely
to have a more performant representation.
If Swift ever does explore in the direction of @isolated(to:)
,
nothing in this proposal would interfere with it. In fact, the
features would support each other well. Erasing the isolation of
an @isolated(to:)
function into an @isolated(any)
type would
be straightforward, much like erasing an Int
into an Any
.
Similarly, an @isolated(any)
function could be "opened" into a
pair of an @isolated(to:)
function and its known isolation.
Since the common cases will still be more convenient to express
with @isolated(any)
, the community is unlikely to regret having
added this proposal first.
isolated
and nonisolated
are used as bare-word modifiers in several
places already in Swift: you can declare a parameter as isolated
, and
you can declare methods and properties as nonisolated
. Using @isolated
as a function type attribute therefore risks confusion about whether
isolated
should be written with an @
sign.
One alternative would be to drop the @
sign and spell these function
types as e.g. isolated(any) () -> ()
. However, this comes with its own
problems. Modifiers typically affect a specific entity without changing
its type; for example, the weak
modifier makes a variable or property
a weak reference, but the type of that reference is unchanged (although
it is required to be optional). This wouldn't be too confusing if
modifiers and types were written in fundamentally different places, but
it's expected that @isolated(any)
will usually be used on parameter
functions, and parameter modifiers are written immediately adjacent to
the parameter type. As a result, removing the @
would create this
unfortunate situation:
// This means `foo` is isolated to the actor passed in as `actor`.
func foo(actor: isolated MyActor) {}
// This means `operation` is a value of isolated(any) function type;
// it has no impact on the isolation of `bar`.
func bar(operation: isolated(any) () -> ())
It is better to preserve the current rule that type modifiers are
written with an @
sign.
Another alternative would be to not spell the attribute @isolated(any)
.
For example, it could be spelled @anyIsolated
or @dynamicallyIsolated
.
The spelling @isolated(any)
was chosen because there's an expectation
that this will be one of a family of related isolation-specifying
attributes. For example, if Swift wanted to make it easier to inherit
actor isolation from one's caller, it could add an @isolated(caller)
attribute. Another example is the @isolated(to:)
future direction
listed above. There's merit in having these attributes be closely
related in spelling. Using a common Isolated
suffix could serve as
that connection, but in the author's opinion, @isolated
is much
clearer.
If programmers do end up confused about when to use @
with isolated
,
it should be relatively straightforward to provide a good compiler
experience that corrects misuses.
An earlier version of this proposal made @isolated(any)
imply @Sendable
.
The logic behind this implication was that @isolated(any)
is only
really useful if the function is going to be passed to a different
concurrent context. If a function cannot be passed to a different
concurrent context, the reasoning goes, there's really no point in
it carrying its isolation dynamically, because it can only be used
if that isolation is compatible with the current context. There's
therefore no reason not to eliminate the redundant @Sendable
attribute.
However, this logic subtly misunderstands the meaning of Sendable
in a world with region-based isolation. A type conforming
to Sendable
means that its values are intrinsically thread-safe and
can be used from multiple concurrent contexts concurrently.
Values of non-Sendable
type are still safe to use from different
concurrent contexts as long as those uses are well-ordered: if the
value is properly transferred between contexts,
everything is fine. Given that, it is sensible for a non-Sendable
function to be @isolated(any)
: if the function can be transferred
to a different concurrent context, it's still useful for it to carry
its isolation dynamically.
In particular, something like a task-creation function ought to declare
the initial task function as a non-@Sendable
but still transferrable
@isolated(any)
function. This permits closures passed in to capture
non-Sendable
state as long as that state can be transferred into the
closure. (Ideally, the initial task function would then be able to
transfer that captured state out of the closure. However, this would
require the compiler to understand that the task function is only
called once.)
I'd like to thank Holly Borla and Konrad Malawski for many long conversations about the design and implementation of this feature.
Footnotes
-
Currently, if the enclosing context is isolated to a value, the closure is only isolated to it if it actually captures that value (by using it somewhere in its body). This is often seen as confusing, and the
isolated
captures proposal is considering lifting this restriction by unconditionally capturing the value. ↩ -
Expressing this exactly would require the use of value-dependent types. Value dependence is an advanced type system feature that we cannot easily add to Swift. This is discussed in greater depth in the Future Directions section. ↩
-
The reasoning here is as follows. All actor-isolated functions are inherently
Sendable
because they will only use their captures from an isolated context.4 There is only a data-race risk for the captures of a non-Sendable
@isolated(any)
function in the case where the function is dynamically non-isolated. The sendability restrictions therefore boil down to the same restrictions we would impose on calling a non-isolated function. A call to a non-isolated function never crosses an isolation boundary if the function is synchronous or if the current context is non-isolated. ↩ ↩2 -
Sending an isolated function value may cause its captures to be destroyed in a different context from the function's formal isolation. Swift pervasively assumes this is okay: copies of non-
Sendable
values must still be managed in a thread-safe manner. This is a significant departure from Rust, where non-Send
values cannot necessarily be safely managed concurrently, and it means thatSendable
is not sufficient to enable optimizations like non-atomic reference counting. Swift accepts this in exchange for being more permissive, as long as the code avoids "user-visible" data races. Note that this assumption is not new to this proposal. ↩ -
Technically, it is possible to achieve this effect in Swift today in a way that Swift could conceivably look through: the caller could be a closure with an
isolated
parameter, and that closure could be called with an expression likefn.isolation
as the argument. Swift could analyze this to see that the parameter has the value offn.isolation
and then understand the connection between the caller's isolation andfn
. This would be very cumbersome, though, and it would have significant expressivity gaps vs. an isolated-captures feature. ↩ -
This optimization doesn't change the formal isolation of the functions involved and so has no effect on the value of either
#isolation
or.isolation
. ↩ -
As far as data-race safety goes, at least. A specific actor could conceivably have important semantic restrictions against doing certain operations in its isolated code. Of course, such an actor should generally not be calling arbitrary functions that are handed to it. ↩