Skip to content

Instantly share code, notes, and snippets.

@kavon
Created May 27, 2021 22:36
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save kavon/b096dfac5637540f73c90e36fb07e35b to your computer and use it in GitHub Desktop.
Save kavon/b096dfac5637540f73c90e36fb07e35b to your computer and use it in GitHub Desktop.
On Actor Initializers

On Actor Initializers

Authors: Kavon Farvardin, John McCall, Konrad Malawski

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:

  1. Synchronous init.
  2. Synchronous init isolated to a global actor.
  3. Async init.
  4. Async init isolated to a global actor.
  5. 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, because self has not yet been returned from the init. The programmer would need to manually create a second task in the init 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-isolated init. Does not work for global-actor isolated init.
  • 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 by init once afterInit is enqueued on the actor, if a second task is enqueued on the actor and afterInit reaches an await, the second task gets to run before afterInit has finished.
  • 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 any await at all.
    • Has the same pros and cons of an async afterInit.

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.
  • ⚠️ 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, and should be directed to the Swift Forums post.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment