For the most up-to-date and complete version of this post's write-up, go here.
Hi everyone,
I'd like to start a discussion on a problem with how actor initializers are currently implemented, which permits a data race, and ways to solve it.
Here's the initial version of the write-up to kick off the discussion:
On Actor Initializers
Authors: Kavon Farvardin, John McCall, Konrad Malawski
Table of Contents
- On Actor Initializers
- Synopsis
- Problems
- Ordinary Initializers
- Async Initializers
- Deinitializers
- Solutions
- Solution A: maintain unique ownership of
self
- Calling
self
methods after being fully initialized- Option 1: Flow-sensitive Rule
- Option 2: Initialization Hooks
- Summary
- Calling
- Solution B: add special semantics to executors
- Solution A: maintain unique ownership of
- Conclusion
Synopsis
The actors proposal (SE-0306) does not go into sufficient detail about how actor initializers and deinitializers work. There is a need for concrete details about them, because the creation and destruction of actor-instances has complex trade-offs surrounding two important aspects of actors: data-race safety and the availability of the actor's executor.
This post summarizes the problems with the current implementation of actor-instance init
and deinit
methods and proposes a number of solutions, in order to solicit feedback and discussion in the process of developing an amendment to SE-0306 to resolve this ambiguity.
Problems
As with classes, actors support both synchronous and asynchronous initializers, along with a user-provided deinitializer, like so:
actor Database {
init() { /**/ }
init(_ rows: [Data]) async { /**/ }
deinit { /**/ }
}
This section provides an overview of the challenges in implementing initialization for actors.
Ordinary Initializers
The synchronous init()
is special in that it is not treated as a cross-actor call, like it would be if it were an actor method, because there is not yet an actor or executor to "hop" to before entering the synchronous init
method. In addition, deinit
must be synchronous so that an actor-instance can be deallocated from anywhere.
The fact that the body of these initializing methods are synchronous means that the actor-instance's executor is not used during the method's execution, because it's not possible to suspend and switch executors from a synchronous context. This fact creates a problem, because both of these special methods are currently allowed to use self
in any way they wish, once all of the stored properties have been initialized (i.e., when self
is fully initialized). Thus, all of the actor's protected state is exposed once self
is fully initialized in the actor's init
method, but without synchronization with its executor!
This problem has real consequences, like in the following example
actor StatsTracker {
var counter: Int
init(_ start: Int) {
self.counter = start
// -- actor's `self` is fully initialized at this point --
Task.detached { await self.tick() }
sleep(5)
if self.counter != start { // 💥 race
fatalError("actor state changed!")
}
}
func tick() {
self.counter = self.counter + 1
}
}
where a task that mutates the actor's state is created and races with the actor's initializer. This example will reach this example's fatalError
statement in the existing implementation of actors. The purpose of actors is to prevent such races, so this functionality is considered a bug.
Async Initializers
Unlike its ordinary synchronous counterpart, an async init
could implicitly hop to the actor self
's executor once it is fully initialized.
But, there's another problem. It is both valid and desirable to be able to isolate an actor's init
to a global actor, such as the @MainActor
, to ensure that the right executor is used for the operations it performs. The problem is that it becomes unclear which executor is running for a global-actor isolated async init
, which initializes an actor-instance object, if we were to do an implicit hop. Consider this example,
class ConnectionStatusDelegate {
@MainActor
func connectionStarting() { /**/ }
@MainActor
func connectionEstablished() { /**/ }
}
actor ConnectionManager {
var status: ConnectionStatusDelegate
var connectionCount: Int
@MainActor
init(_ sts: ConnectionStatusDelegate) async {
// --- on MainActor --
self.status = sts
self.status.connectionStarting()
self.connectionCount = 0
// --- actor `self` fully-initialized here ---
// ... connect ...
self.status.connectionEstablished()
}
}
where we would expect to have exclusive access to self
, since this is its initializer, but we'd also like to perform the initialization while on another actor so that the ConnectionStatusDelegate
can be updated without any possibility of suspension (i.e., no await
needed). Currently, not even the assignment to self.status
is considered valid in this example, even though the actor-instance self
has not been fully initialized. That's because it's currently treated as a cross-actor assignment:
error: actor-isolated property 'status' can not be mutated from context of global actor 'MainActor'
self.status = sts
^
note: mutation of this property is only permitted within the actor
var status: ConnectionStatusDelegate
^
Deinitializers
When an actor-instance's deinit
is called, its reference count has dropped to zero and it is not safe to use self
after its deinit
finishes execution. But, it is currently possible to break this rule in unexpected ways using tasks. Consider this deinitializer:
actor StatsTracker {
var count: Int = 0
func tick() { count += 1 }
deinit {
Task.detached { await self.tick() }
}
}
which captures self
in an escaping closure and enqueues it as part of a task on self
's own executor, which might have already been destroyed by the time the task is executed! In fact, this example currently causes a crash in the actor runtime system. This same problem with keeping self
alive beyond the lifetime of its deinit
is faced by classes as well, but there is no reason to preserve past mistakes for actors, since they are a new nominal type.
Solutions
In total there are five kinds of actor initializers that we need to consider solutions for:
- Synchronous
init
. - Synchronous
init
isolated to a global actor. - Async
init
. - Async
init
isolated to a global actor. -
deinit
.
to fix the bugs discussed earlier. There are several high-level solutions to the problem, which will be discussed in detail.
Solution A: maintain unique ownership of self
Within an initializer, uses of self
are already restricted: the initializer can only access the instance's stored properties until the instance is fully initialized. Other uses of self
could potentially observe uninitialized memory. Swift takes advantage of the fact that an initializer starts off with a unique reference to self
to guarantee memory safety. The restriction on early uses of self
helps maintain that uniqueness of reference. As long as we have this uniqueness of reference, we also know it's safe to continue accessing the stored properties of the actor, even from a different actor.
A similar property applies to deinit
. The fact that deinit
has started executing means that there are no remaining references to self
. Therefore, it is once again safe to access the stored properties of the actor.
One simple way of allowing init
and deinit
to take advantage of uniqueness would be to ensure that self
remains a unique reference for the entire duration of the function. That is, the only uses of self
that would be allowed would be accesses to the stored properties. This would mean that init
and deinit
would not be allowed to call any other method on self
, since the method wouldn't necessarily obey the same restriction, and so it could make the reference non-unique and therefore introduce a race.
Calling self
methods after being fully initialized
There are two strategies for augmenting Solution A to handle the scenario when the programmer needs to execute code involving self
after it has been fully-initialized in an init
. This type of code presumably needs to modify some of the actor's state further, before any other uses of the actor happen, so it would be an error to not run this code after initialization. These uses of self
in this type of code can be as mundane as calling a helper method on self
, but because that method can do arbitrary things with self
, the uniqueness restriction would not allow that method call.
Option 1: Flow-sensitive Rule
We could make the uniqueness restriction on self
flow-sensitive within an asynchronous init
that is not isolated to a global-actor. That is, we could allow an async init
to do anything it wants with self
after it is fully initialized. This is OK because the implementation is able to switch over to the self
executor at the flow-sensitive point of full initialization. A flow-sensitive point just like this is already used to prevent calling a class method before all stored properties of the class are initialized.
For an async init
that is is isolated to a global-actor, switching to the self
actor half-way through the function would be confusing. It would mean that, in our ConnectionManager
actor defined earlier, that calls to the @MainActor
sometimes requires async
, and sometimes does not, within the same function body:
@MainActor
init(_ sts: ConnectionStatusDelegate) async {
// --- on MainActor --
self.status = sts
if someCondition {
self.connectionCount = 1
// --- switch to `self` actor ---
await self.status.connectionStarting()
} else {
self.status.connectionStarting()
self.connectionCount = 0
// --- switch to `self` actor ---
}
// ...
}
Simply reordering an innocuous assignment to a stored property can change whether you must await the call to a @MainActor
method or not. Thus, it does not make sense to switch to the self
actor in a global-actor isolated init
, even with the flow-sensitive rule.
For synchronous initializers, it is still not possible to switch to the self
actor. Even with an an asynchronous convenience initializer to act as a wrapper around a synchronous init
, you're just propagating the problem. You will no longer be able to initialize the actor from a sync context, because it will need to go through some async initializer so that we can hop to the self
actor. Currently, actors do not support convenience initializers, but we could support them if going this route.
Option 2: Initialization Hooks
Another way to ensure that the actor's state is modified after it is fully-initialized is to launch a task that performs those modifications as the last action within the init
. For example, you could try to do this:
actor StatsTracker {
var counter: Int
init(_ start: Int) {
self.counter = start
// -- actor's `self` is fully initialized at this point --
Task {
assert(self.counter == 0) // 💥 race
await self.establishInvariants()
}
}
func establishInvariants() { ... }
// ...
}
As long as no code appears in the initializer after that task, then there is no chance of clobbering actor state that the initializer, which assumes that it has exclusive-access to self
. Once self
is returned from init
, the usual mutual-exclusion and protection rules apply to that actor-instance.
But, this explicit task-launching pattern above suffers from a different race: the task launched in the init
is racing to gain exclusive access to the actor, in order to establish a crucial initialization invariant. It needs to gain access to the actor before anyone else, but if the code that gets self
back from the init
immediately uses it, then it may gain access before the invariant-establishing task was scheduled:
let a = StatsTracker()
await a.tick()
So, the solution here is to integrate this task-launching pattern into the implementation of actors, to ensure that the task is always enqueued as the first one to gain access to self
. The syntax for this "initialization hook" would look like something like this:
actor StatsTracker {
var counter: Int
init(_ start: Int) {
self.counter = start
}
afterInit {
assert(self.counter == 0)
self.establishInvariants()
}
func establishInvariants() { ... }
// ...
}
where afterInit
is a synchronous function that takes no arguments (other than self
implicitly) and has exclusive access to the actor. Due to actor re-entrancy, it is not a good idea to allow the afterInit
to be async
. Any await
appearing in this afterInit
would provide an opportunity for some other task to take away access to the actor, before having completed the afterInit
routine.
If there is a strong argument in favor of allowing async
post-init code for an actor, here are three possible ways to support this, each with its own pros and cons:
- Allow a limited form of Option 1 to apply only to
async
inits that are not global-actor isolated.-
Pro: this makes it harder for actor re-entrancy to allow another task to take over
self
before post-init code is done, becauseself
has not yet been returned from theinit
. The programmer would need to manually create a second task in theinit
to make that mistake. -
Con: makes the language less uniform: there is a flow-sensitive rule that loosens
self
restrictions only for an async, non-global-actor-isolatedinit
. Does not work for global-actor isolatedinit
.
-
Pro: this makes it harder for actor re-entrancy to allow another task to take over
- Allow for
afterInit() async { ... }
-
Pro: keeps the language uniform: there is no flow-sensitive rule at all. Plus, this works for global-actor isolated async
init
too. -
Con: Because
self
is returned byinit
onceafterInit
is enqueued on the actor, if a second task is enqueued on the actor andafterInit
reaches anawait
, the second task gets to run beforeafterInit
has finished.
-
Pro: keeps the language uniform: there is no flow-sensitive rule at all. Plus, this works for global-actor isolated async
- Don't provide any built-in mechanism for this. Programmers can use the manual but race-prone task-launching solution, because there is no way to guarantee that the
afterInit
code has fully-completed if it contains anyawait
at all.- Has the same pros and cons of an async
afterInit
.
- Has the same pros and cons of an async
Summary
So, we have an overall approach called Solution A that relies on uniqueness of reference to self
to prevent races in the initializer. There are two options for implementing such a solution so that post-initialization code can still be specified:
- Option 1 uses convenience initializers and a flow-sensitive rule that matches how classes work.
-
Option 2 uses a new
afterInit
declaration (called an initialization hook) in the actor to define a piece of code that is enqueued on the actor before returning from initialization.
To summarize whether you can use self
arbitrarily after it is fully-initialized (or before it's deinitialized), but before self
is accessible to others (or before self
is deallocated), here is a table:
Flow-sensitive | Initialization hooks | |
---|---|---|
Sync init
|
||
Sync init + global-actor iso |
||
Async init
|
or w/ flow-sensitive | |
Async init + global-actor iso |
||
deinit |
Where
-
means that you cannot do it in a robust way purely with initializers / deinitializers.
- For example, you would need to hide the
init
and use a static,async
factory method to produce the actor-instance.
- For example, you would need to hide the
-
means it can be done with just initializers, but it would require defining an
async
convenience initializer. - means that you can simply write the code directly in the initializer / deinitializer.
- means that you can write the code in an initialization hook.
Solution B: add special semantics to executors
An alternative to the above is to try to make the body of init
and deinit
actually behave like an actor function for self
. Actors typically have a dedicated executor, and at the start of init
(and deinit
) this executor is known to be idle. In an init
, we can safely allow arbitrary references to self
to be introduced as long as we stop work from running on the actor's executor concurrently with the init
. To do this, we could simply start up the executor in a running state instead of an idle state, then update the executor to no longer be running when the init
is complete (potentially requiring some other thread to be scheduled to take it over, if jobs were added). In deinit
, we could simply check that no new work was added to the actor during deinit
.
The problem with this is that it makes a lot of assumptions about the actor's executor. Actors that customize their executor may not be able to support doing any of the above. For example, an actor might re-use an existing serial executor that might already be running work; we do not want to allow a synchronous initializer to block waiting for such an executor to become available. So this alternative would at best only be available for certain kinds of executors. Moreover, supporting this in custom executors would significantly complicate the SerialExecutor
protocol just to enable a relatively obscure capability. It would probably only be reasonable to offer this for actors that do not use custom executors, and that would introduce an unfortunate semantic difference between different kinds of actors.
Conclusion
This post makes an effort to remain as objective as possible in laying out the problem and possible solutions. But, at least Kavon believes that Solution A with Option 2, which uses an initialization hook (afterInit
) in combination with a uniqueness restriction on the init
, would be the best way to solve this problem. Feedback is greatly appreciated.