[proposal] Actor Initializers and Deinitializers

Hi everyone,

I'd like to define how actor initializers work in Swift. The proposed solution to some of the problems described in this proposal are currently reflected in Swift 5.5 through a warning, but it's important to review these changes for Swift 6. In addition, this proposal adds extra capabilities for the deinit of MainActor-isolated class, to make them easier and safer to write. I'll paste the entire proposal below, but for the most up-to-date write-up, go here.


Actor Initializers and Deinitializers

Table of Contents

Introduction

Actors are a relatively new nominal type in Swift that provides data-race safety for its mutable state. The protection is achieved by isolating the mutable state of each actor instance to at most one task at a time. The proposal that introduced actors (SE-0306) is quite large and detailed, but misses some of the subtle aspects of creating and destroying an actor's isolated state. This proposal aims to shore up the definition of an actor, to clarify when the isolation of the data begins and ends for an actor instance, along with what can be done inside the body of an actor's init and deinit declarations.

Background

To get the most out of this proposal, it is important to review the existing behaviors of initializer and deinitializer declarations in Swift.

As with classes, actors support both synchronous and asynchronous initializers, along with a user-provided deinitializer, like so:

actor Database {
  var rows: [String]

  init() { /* ... */ }
  init(with: [String]) async { /* ... */ }
  deinit { /* ... */ }
}

An actor's initializer respects the same fundamental rules surrounding the use of self as other nominal types: until self's stored properties have all been initialized to a value, self is not a fully-initialized instance. This concept of values being fully-initialized before use is a fundamental invariant in Swift. To prevent uses of ill-formed, incomplete instances of self, the compiler restricts self from escaping the initializer until all of its stored properties are initialized:

actor Database {
  var rows: [String]

  func addDefaultData(_ data: String) { /* ... */ }
  func addEmptyRow() { rows.append(String()) }

  init(with data: String?) {
    if let data = data {
      self.rows = []
      // -- self fully-initialized here --
      addDefaultData(data) // OK
    }
    addEmptyRow() // error: 'self' used in method call 'addEmptyRow' before all stored properties are initialized
  }
}

In this example, self escapes the initializer through the call to its method addEmptyRow (all methods take self as an implicit argument). But this call is flagged as an error, because it happens before self.rows is initialized on all paths to that statement from the start of the initializer's body. Namely, if data is nil, then self.rows will not be initialized prior to it escaping from the initializer. Stored properties with default values can be viewed as being initialized immediately after entering the init, but prior to executing any of the init's statements.

Determining whether self is fully-initialized is a flow-sensitive analysis performed by the compiler. Because it's flow-sensitive, there are multiple points where self becomes fully-initialized, and these points are not explicitly marked in the source program. In the example above, there is only one such point, immediately after the rows are assigned to []. Thus, it is permitted to call addDefaultData right after that assignment statement within the same block, because all paths leading to the call are guaranteed to have assigned self.rows beforehand. Keep in mind that these rules are not unique to actors, as they are enforced in initializers for other types like structs and classes.

Motivation

While there is no existing specification for how actor initialization and deinitialization should work, that in itself is not the only motivation for this proposal. The de facto expected behavior, as induced by the existing implementation, is also problematic. In summary, the major problems are:

  1. Initializers can exhibit data races due to ambiguous isolation semantics.
  2. Initializer delegation requires the use of a convenience keyword, which does not have meaning without inheritance.
  3. Deinitializers are run without obtaining access to an actor's executor, yet in some instances can access actor-isolated state.

The following subsections will discuss these three high-level problems in more detail.

Initializer Races

Unlike other synchronous methods of an actor, a synchronous (or "ordinary") init is special in that it is treated as being nonisolated from the outside, meaning that there is no await (or actor hop) required to call the init. This is because an init's purpose is to bootstrap a fresh actor-instance, called self. Thus, at various points within the init's body, self is considered a fully-fledged actor instance whose members must be protected by isolation. The existing implementation of actor initializers does not perform this enforcement, leading to data races with the code appearing in the init:

actor StatsTracker {
  var counter: Int

  init(_ start: Int) {
    self.counter = start
    // -- self fully-initialized here --
    Task.detached { await self.tick() }
    
    // ... do some other work ...
    
    if self.counter != start { // 💥 race
      fatalError("state changed by another thread!")
    }
  }

  func tick() {
    self.counter = self.counter + 1
  }
}

This example exhibits a race because self, once fully-initialized, is ready to provide isolated access to its members, i.e., it does not start in a reserved state. Isolated access is obtained by "hopping" to the executor corresponding to self from an asynchronous function. But, because init is synchronous, a hop to self fundamentally cannot be performed. Thus, once self is initialized, the remainder of the init is subject to the kind of data race that actors are meant to eliminate.

If the init in the previous example were only changed to be async, this data race still does not go away. The existing implementation does not perform a hop to self in such initializers, even though it now could to prevent races. This is not just a bug that has a straightforward fix, because if an asynchronous actor init were isolated to the @MainActor:

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
    // --- self fully-initialized here ---
    
    // ... connect ...
    self.status.connectionEstablished()
  }
}

then which executor should be used? Should it be valid 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 example above serves as a possible use case for that capability: being able to perform the initialization while on @MainActor so that the ConnectionStatusDelegate can be updated without any possibility of suspension (i.e., no await needed).

The existing implementation makes it impossible to write a correct init for the example above, because the init is considered to be entirely isolated to the @MainActor. Thus, it's not possible to initialize self.status at all. It's not possible to await and hop to self's executor to perform an assignment to self.status, because self is not a fully-initialized actor-instance yet!

Initializer Delegation

All nominal types in Swift, except actors, explicitly support initializer delegation, which is when one initializer calls another one to perform initialization. For classes, initializer delegation rules are complex due to the presence of inheritance. So, classes have a required and explicit convenience modifier to make, for example, a distinction between initializers that must delegate and those that do not. In contrast, value types do not support inheritance, so the rules are much simpler: any init can delegate, but if it does, then it must delegate or assign to self in all cases:

struct S {
  var x: Int
  init(_ v: Int) { self.x = v }
  init(b: Bool) {
    if b {
      self.init(1)
    } else {
      self.x = 0 // error: 'self' used before 'self.init' call or assignment to 'self'
    }
  }
}

Actors, which are reference types (like a classes), do not support inheritance. But, currently they must use the convenience modifier on an initializer to perform any delegation. Is this modifier still needed?

Deinitializer Isolation

A user-defined deinit plays an important role in programming idioms such as RAII. In Swift, only reference types support such a deinit and it is automatically called whenever the last reference to the object is destroyed, which can happen virtually anywhere. The implicit contract of a deinit is that, at the beginning of the deinit, no other references to self exist. In addition, after deinit has finished executing, any copies of self created during the deinit are not valid.

The single-reference nature of self in a deinit means that, in theory, one should not need to await or synchronize with an actor's executor in order to access its isolated state. This is because any task that is still awaiting access to an actor-instance must also hold a reference to it. Thus, when there are no references to the instance left, then the executor should also be idle.

There are two important exceptions to this, because in some cases, the executor is not exclusively owned by the instance. The first exception comes from the sharing of executors, which has been proposed as part of Swift's custom executors. The second exception is when a class is isolated to a global actor, such as the MainActor:

@MainActor
class NetworkSessionManager {
  let sessions: [NetworkSession]
  
  func closeSessions() {
    print("start of main thread method 'closeSessions'")
    for session in sessions {
      session.close()
    }
    print("end of method")
  }

  deinit() {
    self.closeSessions() // ❌ error: self is not isolated to the MainActor.
    _ = self.sessions // ❌ error: self is not isolated to the MainActor.
  }
}

Global actors are singleton actor instances whose executor is used by multiple instances of some type. In the example above, synchronization with the MainActor's executor would be required to correctly execute the closeSessions method, because the method must be run by the main thread. The contract of the MainActor is that it is a serial executor, so only one function isolated to it can be actively running at any point in time. Thus, if the deinit were allowed to invoke closeSessions without synchronization, there is the possibility of observing this contract being violated:

@MainActor
func fn() {
  Task.detached {
    NetworkSessionManager() // init and then immediately deinit.
  }
  print("actually, 'fn' is on the main thread!")
}

When run, the above can result in the following output:

start of main thread method 'closeSessions'
actually, 'fn' is on the main thread!
end of method

This limitation on the deinit of MainActor-isolated classes is a pain-point for users that are migrating code, because they cannot access stored properties to invoke the appropriate clean-ups. This can lead to unsafe workarounds, such as spawning a new task in the deinit to perform the clean-up, and thus extending the lifetime of self beyond the deinit.

Proposed solution

The previous sections described problems with the current state of actor initialization and deinitialization, as listed in the introduction of the Motivation section. The remainder of this section details the proposed solution to those problems.

Problem 1: Initializer Data Races

This proposal aims to eliminate data races through the selective application of a usage restriction on self in an actor's initializer. For this discussion, an escaping use of self means that a copy of self is exposed outside of the actor's initializer, before the initializer has finished. By rejecting programs with escaping uses of self, there is no way to construct the data race described earlier.

NOTE: Preventing self from escaping the init directly resolves the data race, because it forces the unique reference self to stay on the current thread until the completion of the init. Specifically, the only way to create a race is for there to be at least two copies of the reference self. Since a secondary thread can only gain access to a copy of self by having it "escape" the init, preventing the escape closes the possibility of a race.

An actor's initializer that obeys the escaping-use restriction means that the following are rejected throughout the entire initializer:

  • Capturing self in a closure.
  • Calling a method or computed property on self.
  • Passing self as any kind of argument, whether by-value, autoclosure, or inout.

The escaping-use restriction is not a new concept in Swift: for all nominal types, a very similar kind of restriction is applied to self until it becomes fully-initialized.

Applying the Escaping-use Restriction

If an actor's non-delegating initializer is synchronous or isolated to a global-actor, then it must obey the escaping-use restriction. This leaves only the instance-isolated async actor initializer, and all delegating initializers, as being free from this new restriction.

For a synchronous initializer, we cannot reserve the actor's executor by hopping to it from a synchronous context. Thus, the need for the restriction is clear: the only way to prevent simultaneous access to the actor's state is to prevent another thread from getting a copy of self. In contrast, an instance-isolated async initializer will perform that hop immediately after self is fully-initialized in the init, so no restriction is applied.

For a global-actor isolated initializer, the need for the escaping-use restriction is a bit more subtle. In Swift's type system, a declaration cannot be isolated to two actors at the same time. Because the programmer has to opt-in to global-actor isolation, it takes precedence when appearing on the init of an actor type and will be respected. In such cases, protection for the self actor instance, after it is fully-initialized, is provided by the escaping-use restriction. This means that, within an init isolated to some global-actor A, the stored properties of self belonging to a different actor B can be accessed without synchronization. Thus, the ConnectionManager example from earlier will work as-is, because only stored properties of the actor-instance self are accessed.

Problem 2: Initializer Delegation

Next, one of the key downsides of the escaping-use restriction is that it becomes impossible to invoke a method in the time after self is fully-initialized, but before a non-delegating init returns. This pattern is important, for example, to organize set-up code that is needed both during initialization and the lifetime of the instance:

actor A {
  var friends: [A]

  init(withFriends fs: [A]) {
    friends = fs
    self.notifyAll()  // ❌ disallowed by escaping-use restriction.
  }

  @MainActor
  init() {
    friends = ...
    self.notifyAll()  // ❌ disallowed by escaping-use restriction.
  }

  func verify() { ... }
  func notifyAll() { ... }
}

Another important observation is that an isolated initializer that performs delegation is not particularly useful. A delegating initializer that is synchronous would still need to obey the escaping-use restriction, but now they also must first call some other init on all paths. But, because an init must be called first on all paths of a delegating init, such an initializer has an explicit point where self is fully-initialized. This provides an excellent opportunity to perform follow-up work, after self is fully-initialized, but before completely returning from initialization. To do the follow-up work in a delegating init, we must be in a context that is not isolated to the actor instance, because the initialized instance's executor starts in an unreserved state. In addition, because all initializers are viewed as nonisolated from the outside, an entire body of the delegating initializer can be cleanly treated as nonisolated!

For ABI compatibility reasons with Swift 5.5, and to make the implicit nonisolated semantics clear, this proposal keeps the convenience modifier for actor initializers, as a way to mark initializers that must delegate. If a programmer marks a convenience initializer with nonisolated, a warning will be emitted that says it is a redundant modifier, since convenience implies nonisolated. Global-actor isolation of a convenience init is allowed, and will override the implicit nonisolated behavior. Rewriting the above with this new rule would look like this:

// NOTE: Task.detached is _not_ an exact substitute for this.
// It is expected that Custom Executors will provide a capability
// that implements this function, which atomically enqueues a paused task
// on the target actor before returning.
func spawnAndEnqueueTask<A: AnyActor>(_ a: A, _ f: () -> Void) { ... }

actor A {
  var friends: [A]

  private init(with fs: [A]) {
    friends = fs
  }

  // Version 1: synchronous delegating initializer
  convenience init() {
    self.init(with: ...)
    // ✅ self can be captured by closure, or passed as argument
    spawnAndEnqueueTask(self) {
      await self.notifyAll()
    }
  }

  // Version 2: asynchronous delegating initializer
  convenience init(withFakeFriends f: Double) async {
    if f < 0 {
      self.init()
    } else {
      self.init(with: manufacturedFriends(count: Int(f)))
      await self.notifyAll()
    }
    await self.verify()
  }

  // Version 3: global-actor isolated inits can also be delegating.
  @MainActor
  convenience init(alt: Void) async {
    self.init(with: ...)
    await self.notifyAll()
  }

  init(bad1: Void) {
    self.init() // ❌ error: only convenience initializers can delegate
  }

  nonisolated init(bad2: Void) {
    self.init() // ❌ error: only convenience initializers can delegate
  }

  // warning: nonisolated on a synchronous non-delegating initializer is redundant
  nonisolated init(bad3: Void) {
    self.friends = []
    self.notifyAll()  // ❌ disallowed by escaping-use restriction.
  }

  nonisolated init(ok: Void) async {
    self.friends = []
    self.notifyAll()  // ❌ disallowed by escaping-use restriction.
  }

  func verify() { ... }
  func notifyAll() { ... }
}

An easy way to remember the rules around actor initializers is, if the initializer is just async, with no other actor isolation changes, then there is no escaping-use restriction. Thus, if any one of the following apply to an initializer, it must obey the escaping-use restriction to maintain data-race safety for self:

  1. not async
  2. nonisolated
  3. global-actor isolated

Problem 3: Deinitializers and Executors

Without access to the stored properties of a MainActor-isolated class during deinit, it is difficult to clean-up state because a deinit is implicitly nonisolated. We observe that, when an actor-isolated stored property is accessed, only the operation that loads the value from the instance is synchronized with (and performed on) the executor. Any further method calls or accessed on the loaded value are subject to the isolation rules of that loaded value's members. Thus, we propose to allow access to isolated stored properties within any deinit, whether it is a class or actor. In this example:

@MainActor
class NetworkSessionManager {
  var sessions: [NetworkSession] = []

  // func f() {}

  deinit {
    // ✅ no MainActor syncronization to access `sessions`
    NetworkSessionManager.closeSessions(self.sessions)
  }

  nonisolated static func closeSessions(_: [NetworkSession]) { /* ... */ }
}

there is no danger of a data race when accessing the sessions property of a NetworkSessionManager instance during its deinit. Furthermore, there are no observable side-effects of performing that access on an arbitrary executor. The only danger arises from access to isolated code that can have arbitrary side-effects, such as methods and computed properties. Thus, such accesses will follow ordinary isolation rules that guard their use, given that a deinit is implicitly nonisolated.

Summary

The following table summarizes the capabilities and requirements of actor initializers in this proposal:

Initializer Kind / Rules Has escaping-use restriction Delegation
Not isolated to self Yes No
Isolated to self + synchronous Yes No
Isolated to self + async No No
convenience + anything No Yes (required)

Source compatibility

The following are known source compatibility breaks with this proposal:

  1. The escaping-use restriction.
  2. nonisolated is ignored for async inits.

Breakage 1

There is no simple way to automatically migrate applications that use self in an escaping manner within an actor initializer. At its core, the simplest migration path is to mark the initializer async, but that would introduce async requirements on callers. For example, in this code:

actor C {
  init() {
    self.f() // ❌ now rejected by this proposal
  }

  func f() { /* ... */}
}

func user() {
  let c = C()
}

we cannot introduce an async version of init(), whether it is delegating or not, because the async must be propagated to all callers, breaking the API. Fortunately, Swift concurrency has only been available for a few months, as of September 2021.

To resolve this source incompatibility issue without too much code churn, it is proposed that the escaping-use restriction turns into an error in Swift 6 and later. For earlier versions that support concurrency, only a warning is emitted by the compiler.

Breakage 2

In Swift 5.5, if a programmer requests that an async initializer be nonisolated, the escaping-use restriction is not applied, because isolation to self is applied regardless. For example, in this code:

actor MyActor {
  var x: Int

  nonisolated init(a: Int) async {
    self.x = a
    self.f() // permitted in Swift 5.5
    assert(self.x == a) // guaranteed to always be true
  }

  func f() {
    // create a task to try racing with init(a:)
    Task.detached { await self.mutate() }
  }

  func mutate() { self.x += 1 }
}

the nonisolated is simply ignored, and isolation is enforced with a hop-to-executor anyway. Fixing this bug to match the proposal is very simple: remove the nonisolated. Callers of the init will not be affected, since no synchronization is needed to enter the init, regardless of its isolation. The compiler will be augmented with a fix-it in this scenario to make upgrading easy.

Alternatives considered

This section explains alternate approaches that were ultimately not chosen for this proposal.

Deinitializers

One workaround for the lack of ability to synchronize with an actor's executor prior to destruction is to wrap the body of the deinit in a task. If this task wrapping is done implicitly, then it breaks the expectation within Swift that all tasks are explicitly created by the programmer. If the programmer decides to go the route of explicitly spawning a new task upon deinit, that decision is better left to the programmer. It is important to keep in mind that it is undefined behavior in Swift for a reference to self to escape a deinit, such as through task creation. Nevertheless, a program that does extend the lifetime of self in a deinit is not currently rejected by the compiler; and will not be if this proposal is accepted.

Flow-sensitive actor isolation

The solution in this proposal focuses on having an explicit point at which an actor's self transitions to becoming fully-initialized, by leaning on delegating initializers.

If actor-isolation were formulated to change implicitly, after the point at which self becomes initialized in an actor, we could combine some of the capabilities of delegating and non-delegating inits. In particular, accesses to stored properties in an initializer would be conditionally asynchronous, at multiple control-flow sensitive points:

actor A {
  var x: Int
  var y: Int

  init(with z: Int) {
    self.y = z
    guard z > 0 else {
      self.x = -1
      // `self` fully initialized here
      print(self.x) // ❌ error: must 'await' access to 'x'
      return
    }
    self.x = self.y
    // `self` fully initialized here
    _ = self.y // ❌ error: must await access to 'y'
  }
}

This approach was not pursued for a two reasons. First, it is likely to be confusing to users if the body of an initializer can change its isolation part-way through, at invisible points. Second, the existing implementation of the compiler is not designed to handle conditional async-ness. In order to translate the program from an AST to the SIL representation, we need to decide whether an expression is async. But, the existing control-flow analysis, to determine where self becomes fully-initialized, must be run on the SIL representation of the program. Performing control-flow analysis on an AST representation would be painful and become a maintenance burden. SIL is a normalized representation that is specifically designed to support such analyses.

Removing the need for convenience

The removal of convenience to distinguish delegating initializers will create an ABI break. Currently, the addition or removal of convenience on an actor initializer is an ABI-breaking change, as it is with classes, because the emitted symbols and/or name mangling will change.

If we were to disallow nonisolated, non-delegating initializers, we could enforce the rule that nonisolated means that it must delegate. But, such semantics would not align with global-actor isolation, which is conceptually the same as nonisolated with respect to an initializer: not being isolated to self. In addition, any Swift 5.5 code with nonisolated or equivalent on an actor initializer would become ABI and source incompatible with Swift 6.

Thus, is not ultimately worthwhile to try to eliminate convenience, since it does provide some benefit: marking initializers that must delegate. While a nonisolated synchronous initializer is mostly useless, the compiler can simple tell programmers to remove the nonisolated, because it is meaningless in that case. Note that nonisolated does provide utility for an async initializer, since it means that no implicit executor synchronization is performed, while allowing other async calls to happen within the initializer.

Effect on ABI stability

This proposal does not affect ABI stability.

Effect on API resilience

Any changes to the isolation of a declaration continues to be an ABI-breaking change, but a change in what is allowed in the implementation of, say, a nonisolated member will not affect API resilience.

18 Likes

Awesome work! :+1:

There was a single word that was confusing me:

The "unless" should be removed, right?

(Oh, sorry, I just noticed that this has already been corrected in the linked-to version :grinning:)

2 Likes

Yes the "unless" should be removed and I have now also updated the original post to reflect this. Thanks!

1 Like

What is the interaction between Sendable-ness and actor init/deinit? Do we need additional restrictions to prevent non-Sendable values from being implicitly shared between execution contexts, e.g. by storing them to an actor property in an init or loading them from an actor property in a deinit?

FWIW, the implementation already requires that the arguments to an actor initializer be Sendable.

Doug

1 Like

Hmm. That's unnecessarily strict, but it's certainly the easiest rule. Okay, so all we need is a deinit restriction, then.

1 Like

The current implementation also tries to prevent initialization of non-sendable stored properties, due to how the isolation of the initializer's body is viewed:

// A non-sendable type, to serve as an example.
class Counter {
  var state: Int = 0

  init(with initialState: Int) {
    self.state = initialState
  }
}

actor StateProtectingActor {
  let counter: Counter

  init() {
    self.counter = Counter(with: 0) 
    // warning: cannot use property 'counter' with a non-sendable type 'Counter' across actors
  }
}

So, I'll update this proposal to reflect this as a bug, since there should only be a restriction on the arguments to a designated initializer.

@John_McCall can you elaborate on the need for a deinit restriction in the presence of an init that has a Sendable-argument restriction?

2 Likes

As we discussed offline, having exclusive access to storage (e.g. from having a guaranteed-unique reference in an init or deinit) means that it's safe to access that storage independent of normal actor restrictions, but doesn't necessarily make it safe to do arbitrary work with non-Sendable values that may be stored in that storage. The concurrency safety of those values may depend on isolation to a specific concurrency domain, and it isn't necessarily true that having a unique reference implies that we're within that concurrency domain. Now, generally non-Sendable-ness is transitively infectious in a way that does guarantee that: a class can't have a normal stored property that holds a non-Sendable value without being non-Sendable itself, which means we can't have shared the class reference between domains. However, there's a big ol' exception for actor-restricted storage, both the stored properties of an actor or just global-actor-qualified storage, which don't restrict the containing reference. We cannot safely access non-Sendable values held in such storage from code that isn't known to be part of the given concurrency domain unless we're certain that no other code from that domain might be concurrently running. We do not currently allow deinit to be restricted to an actor. Therefore, we basically can never access non-Sendable global-actor-isolated storage from a deinit, despite having exclusive access to it. For storage isolated to an actor instance, it depends on whether we ever formalize things like multiple actors sharing a single concurrency domain: if the compiler allows non-Sendable values to be shared between certain actors because they're known to share a domain, then we cannot safely access non-Sendable actor-isolated storage during the deinit of any of those actors.

In all of these cases, we assume that copying or destroying a value is safe to do outside the concurrency domain; it's just arbitrary other things that aren't. That is, Swift's Sendable is a weaker restriction than Rust's and cannot be used to e.g. allow non-atomic reference counting; if we want to allow that, we'll have to introduce a second tier of Sendable-ness.

5 Likes

I’m sorry, I’m coming into this late so I might have missed the connection, but what prevents the actor’s destructor from scheduling one final task on the queue, a task that would be responsible for running the actual deinit and freeing the memory? Without this I fear a number of actors would grow close() messages that just reimplement manual memory management.

EDIT: I missed that this was in the Alternatives section. I believe it is a good alternative. (Today, escaping self in a deinit may not be checked by the compiler, but it is checked by the runtime.)

5 Likes

Ah, that makes sense. I was able to craft an example that demonstrates this problem for global-actor isolation, even with exclusive access to the instance member:

// a non-sendable type
class Box {
  var state: Int = 0
  func mutate() {
    state += 1
  }
}

@MainActor
var globalBoxes: [Box] = []

class Example1 {
  @MainActor
  var localBox: Box

  init() {
    localBox = Box()
  }

  @MainActor
  func share() {
    globalBoxes.append(localBox) // have globalBoxes alias this localBox
  }

  func read() async {
    // Warning in Swift 5.5, error in Swift 6+, stating:
    // cannot use property 'localBox' with a non-sendable type 'Box' across actors
    await self.localBox.mutate()
  }

  deinit {
    // !! currently, no diagnostics emitted for:
    self.localBox.mutate()
  }
}

I will update the proposal to reflect the fact that isolated instance members can only be accessed during a deinit if they are Sendable values. This will cover the case for actor-instance isolation while in the actor's deinit too, to make the rule simple and allow for this:

By this, do you mean that running the entire body of the actor's deinit on the executor in a new task is a good alternative? My main worry about that is efficiency: there should be a way to opt-out or opt-in to that behavior. Perhaps it could be called a detached deinit. There may also be some runtime changes needed to make this work correctly; I don't know if it would be ABI-breaking or not though.

Could the opt-out/opt-in not just be "if you access self in a way that requires actor isolation, the deinit is detached, otherwise it isn't"?

Thanks for the great discussion, folks. Kavon's been revising this proposal based on more experience and discussion, and we're going to schedule it for a review starting next Monday.

Doug

7 Likes

what is the status of this issue? non-Sendable types are rare in a Swift codebase that follows value semantics, but a few key standard library types (AsyncStream<T> and AsyncThrowingStream<T, Failure> !) are notably non-Sendable, and this bug means actors cannot be used to isolate them. so far, this has been a major problem for me since the typical solution for achieving state isolation is to put the non-Sendable stuff in an actor, and this obviously breaks that.

so far, i’m working around this issue by just marking AsyncStream and AsyncThrowingStream as @unchecked Sendable, but that is not a good solution…

For the updated proposal under review, it's still considered a bug to get a warning when assigning to a non-Sendable property in the initializer like I mentioned.

Right, so the proposed solution that provides safety is to prevent preexisting non-sendable values from entering the non-delegating initializer of an actor. Until we have something like move-only types, the compiler has no way to guarantee that a reference is unique. Placing the restriction on the arguments to an initializer effectively limits the reference-sharing danger to globals, but we haven't addressed globals yet within the model. Plus, it matches up with how actor methods work with Sendable.

By the way, I specifically address this situation in the updated proposal. Feel free to leave a review and/or your thoughts about this in the review thread. :slight_smile:

1 Like

great, any suggestions on how to suppress this warning, preferably without having to lie about extension AsyncStream:@unchecked Sendable?

The (admittedly annoying) way around it would be to do a property wrapper in the parameter declaration that does the “unsafe Sendable” wrapping.

I also was annoyed at this at first, because an actors purpose in life is often to “take ownership of some mutable thing and then protect it”… but as we talked through it, this pattern today is only doable via a pattern and the compiler cannot make it safe… it will though one the “move” semantics land and we’d declare the parameter as “move ownership to the actor” and then we properly expressed the intent in the type system and it is actually safe.

Given Joes write up of the upcoming steps regarding the move semantics I agreed that it’s fine to have the painful wrapper for a while and get the moves very soon and it’ll be great then :slight_smile:

4 Likes