Skip to content

Commit 73f614e

Browse files
authored
Merge pull request #1540 from kavon/se327-clarifications
SE-327 Clarifications
2 parents a368177 + 8da40bb commit 73f614e

File tree

1 file changed

+78
-64
lines changed

1 file changed

+78
-64
lines changed

proposals/0327-actor-initializers.md

Lines changed: 78 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@
3131
- [Syntactic Form](#syntactic-form)
3232
- [Isolation](#isolation)
3333
- [Sendability](#sendability)
34-
- [Delegation and Sendable](#delegation-and-sendable)
3534
- [Deinitializers](#deinitializers)
3635
- [Global-actor isolation and instance members](#global-actor-isolation-and-instance-members)
3736
- [Removing Redundant Isolation](#removing-redundant-isolation)
@@ -40,6 +39,7 @@
4039
- [Introducing `nonisolation` after `self` is fully-initialized](#introducing-nonisolation-after-self-is-fully-initialized)
4140
- [Permitting `await` for property access in `nonisolated self` initializers](#permitting-await-for-property-access-in-nonisolated-self-initializers)
4241
- [Async Actor Deinitializers](#async-actor-deinitializers)
42+
- [Requiring Sendable arguments only for delegating initializers](#requiring-sendable-arguments-only-for-delegating-initializers)
4343
- [Effect on ABI stability](#effect-on-abi-stability)
4444
- [Effect on API resilience](#effect-on-api-resilience)
4545
- [Acknowledgments](#acknowledgments)
@@ -598,114 +598,96 @@ But, the delegating initializers of an actor have simpler rules about what can a
598598

599599
### Sendability
600600

601-
The delegating initializers of an `actor`, and all initializers of a GAIT, follow the same rules about Sendable arguments as other functions. Namely, if the function is isolated, then cross-actor calls require that the arguments conform to the `Sendable` protocol.
602-
603-
All non-delegating initializers of an actor, regardless of any flow-sensitive isolation applied to `self`, are considered "isolated" from the `Sendable` point-of-view. That's because these initializers are permitted to access the actor's isolated stored properties during bootstrapping.
604-
605-
These two rules force programmers to correctly deal with `Sendable` values when creating a new actor instance. Fundamentally, programmers will have only two options for initializing a non-`Sendable` stored property of an actor:
601+
When passing values to any of an `actor`'s initializers, from outside of that actor, those values must be `Sendable`.
602+
Thus, during the initialization of a new instance, the actor's "boundary" in terms of Sendability begins at the initial call-site to one of its initializers.
603+
This rule forces programmers to correctly deal with `Sendable` values when creating a new actor instance. Fundamentally, programmers will have only two options for initializing a non-`Sendable` stored property of an actor:
606604

607605
```swift
608606
class NotSendableType { /* ... */ }
609607
struct Piece: Sendable { /* ... */ }
610608

611609
actor Greg {
612-
var ns: NonSendableType
610+
var ns: NotSendableType
613611

614-
// Option 1: a non-delegating init can only take
615-
// Sendable values and use them to construct
616-
// a new non-Sendable value.
612+
// Option 1: an initializer that can be called from anywhere,
613+
// because its arguments are Sendable.
617614
init(fromPieces ps: (Piece, Piece)) {
618-
self.ns = NonSendableType(ps)
615+
self.ns = NotSendableType(ps)
619616
}
620617

621-
// Option 2: a delegating and nonisolated-self init
622-
// can take a non-Sendable value and allow you to
623-
// pass the Sendable pieces to a non-delegating init.
624-
init(with ns: NonSendableType) {
618+
// Option 2: an initializer that can only be delegated to,
619+
// because its arguments are not Sendable.
620+
init(with ns: NotSendableType) {
625621
self.init(fromPieces: ns.getPieces())
626622
}
627623
}
628624
```
629625

630-
As shown in the example above, you _can_ construct an actor that has a non-`Sendable` stored property. But, you must be able to create a new instance of that type from `Sendable` pieces of data. The two options above provide ways to either accept the pieces directly in a non-delegating initializer, or to rely on a delegating initializer to start with non-`Sendable` values. This effectively forces programmers to construct a new, fresh instance of the non-Sendable value within the non-delegating initializer.
631-
632-
#### Delegation and Sendable
633-
634-
It's tempting to think that all delegating initializers can accept non-Sendable values from any caller, but that's not true. Whether it is safe to pass a non-Sendable value to a delegating initializer still depends on the isolation of the caller.
635-
636-
For example, an `async` delegating initializer has an `isolated self`, so it has access to the stored properties after delegating. If we were to allow non-Sendable values into this initializer _without_ paying attention to the isolation of its caller, then an invalid sharing of non-Sendable values can happen:
626+
As shown in the example above, you _can_ construct an actor that has a non-`Sendable` stored property. But, you should create a new instance of that type from `Sendable` pieces of data in order to store it in the actor instance. Once inside an actor's initializer, non-Sendable values can be freely passed when delegating to another initializer, or calling its methods, *etc*. The following example illustrates this rule:
637627

638628
```swift
639629
class NotSendableType { /* ... */ }
640630
struct Piece: Sendable { /* ... */ }
641631

642632
actor Gene {
643-
var ns: NonSendableType?
633+
var ns: NotSendableType
644634

645-
init(with ns: NonSendableType) async {
646-
self.init()
635+
init(_ ns: NotSendableType) {
647636
self.ns = ns
648637
}
649638

639+
init(with ns: NotSendableType) async {
640+
self.init(ns) // ✅ non-Sendable is OK during initializer delegation...
641+
someMethod(ns) // ✅ and when calling a method from an initializer, etc.
642+
}
643+
650644
init(fromPieces ps: (Piece, Piece)) async {
651-
let ns = NonSendableType(ps)
652-
await self.init(with: ns) // ✅ OK
653-
assert(self.ns == ns)
645+
let ns = NotSendableType(ps)
646+
await self.init(with: ns) // ✅ non-Sendable is OK during initializer delegation
654647
}
648+
649+
func someMethod(_: NotSendableType) { /* ... */ }
655650
}
656651

657-
func someFunc(ns: NonSendableType) async {
658-
let ns = NonSendableType()
659-
_ = await Gene(with: ns) // ❌ error: cannot pass non-Sendable value across actors
652+
func someFunc() async {
653+
let ns = NotSendableType()
660654

661-
_ = await Gene(fromPieces: ns.getPieces())
655+
_ = Gene(ns) // ❌ error: cannot pass non-Sendable value across actor boundary
656+
_ = await Gene(with: ns) // ❌ error: cannot pass non-Sendable value across actor boundary
657+
_ = await Gene(fromPieces: ns.getPieces()) // ✅ OK because (Piece, Piece) is Sendable
658+
659+
_ = await SomeGAIT(isolated: ns) // ❌ error: cannot pass non-Sendable value across actor boundary
660+
_ = await SomeGAIT(secondNonIso: ns) // ❌ error: cannot pass non-Sendable value across actor boundary
662661
}
663662
```
664663

665-
In the example above, both `Gene.init(with:)` and `Gene.init(fromPieces:)` are both delegating initializers that are `isolated self`. The difference is that `init(with:)` takes a non-`Sendable` argument, whereas `init(fromPieces:)` only takes `Sendable` arguments. It would not be safe to permit a call from `someFunc` to `init(with:)` because that would mean passing a non-`Sendable` value across actors. But, it's OK to delegate from `init(fromPieces:)` to `init(with:)` because the isolation is matching!
664+
For a global-actor isolated type (GAIT), the same rule applies to its `nonisolated` initializers. Thus, upon entering such an initializer from outside of the actor, the values must be `Sendable`. The differences from an `actor` are that:
666665

667-
<!--
668-
Here's another more exhaustive example to show all the different corner-cases:
666+
1. The caller of the first initializer may already be isolated to the global-actor, so there is no `Sendable` barrier (as usual).
667+
2. When delegating from a `nonisolated` initializer to one that is isolated to the global actor, the value must be `Sendable`.
669668

670-
```swift
671-
class NotSendableType { /* ... */ }
669+
The second difference only manifests when a `nonisolated` and `async` initializer delegates to an isolated initializer of the GAIT:
672670

673-
actor George {
674-
var ns: NonSendableType
671+
```swift
672+
@MainActor
673+
class SomeGAIT {
674+
var ns: NotSendableType
675675

676-
init(anyNonDelegating ns: NonSendableType) {
676+
init(isolated ns: NotSendableType) {
677677
self.ns = ns
678678
}
679679

680-
init(delegatingAsync ns: NonSendableType) async {
681-
self.init(anyNonDelegating: ns) // ✅ OK
682-
self.ns = ns // ✅ OK
683-
}
684-
685-
// ^^^ the above can only be delegated to from another init
686-
// ---
687-
// vvv the below can be called from outside the actor
688-
689-
init(delegatingSync ns: NonSendableType) {
690-
self.init(anyNonDelegating: ns) // ❌ error: cannot pass non-Sendable value across actors
691-
self.ns = ns // ❌ error: cannot mutate isolated property from nonisolated context
680+
nonisolated init(firstNonIso ns: NotSendableType) async {
681+
await self.init(isolated: ns) // ❌ error: cannot pass non-Sendable value across actor boundary
692682
}
693683

694-
nonisolated init(delegatingNonIsoAsync ns: NonSendableType) async {
695-
self.init(anyNonDelegating: ns) // ❌ error: cannot pass non-Sendable value across actors
696-
self.ns = ns // ❌ error: cannot mutate isolated property from nonisolated context
684+
nonisolated init(secondNonIso ns: NotSendableType) async {
685+
await self.init(firstNonIso: ns) //
697686
}
698687
}
699-
700-
func someUnrelatedCaller(ns: NonSendableType) async {
701-
_ = George(anyNonDelegating: ns) // ❌ error: cannot pass non-Sendable value across actors
702-
_ = await George(delegatingAsync: ns) // ❌ error: cannot pass non-Sendable value across actors
703-
_ = George(delegatingSync: ns) // ✅ OK
704-
_ = await George(delegatingNonIsoAsync: ns) // ✅ OK
705-
}
706688
```
707-
-->
708689

690+
The barrier in the example above can be resolved by removing the `nonisolated` attribute, so that the initializer has a matching isolation.
709691

710692
### Deinitializers
711693

@@ -773,7 +755,24 @@ var y = x + 2
773755
#### Removing Redundant Isolation
774756

775757
Global-actor isolation on a stored property provides safe concurrent access to the storage occupied by that stored property in the type's instances.
776-
For example, if `pid` is an actor-isolated stored property (i.e., one without an observer or property wrapper), then the access `p.pid.reset()` only protects the memory read of `pid` from `p`, and not the call to `reset` afterwards. Thus, for value types (enums and structs), global-actor isolation on those stored properties fundamentally serves no use: mutations of the storage occupied by the stored property in a value type are concurrency-safe by default, thanks to copy-on-write semantics. So, we propose to remove the requirement that access to those properties are protected by isolation. That is, reading or writing those stored properties do not require an `await`.
758+
For example, if `pid` is an actor-isolated stored property (i.e., one without an observer or property wrapper), then the access `p.pid.reset()` only protects the memory read of `pid` from `p`, and not the call to `reset` afterwards. Thus, for value types (enums and structs), global-actor isolation on those stored properties fundamentally serves no use: mutations of the storage occupied by the stored property in a value type are concurrency-safe by default, because mutable variables cannot be shared between tasks. For example, it is error when trying to capture a mutable var in a Sendable closure:
759+
760+
```swift
761+
@MainActor
762+
struct StatTracker {
763+
var count = 0
764+
765+
mutating func update() {
766+
count += 1
767+
}
768+
}
769+
770+
var st = StatTracker()
771+
Task { await st.update() } // error: mutation of captured var 'st' in concurrently-executing code
772+
```
773+
774+
As a result, there is no way to concurrently mutate the memory of a struct, regardless of whether the stored properties of the struct are isolated to a global actor. Whether the instance can be shared only depends on whether it's var-bound or not, and the only kind of sharing permitted is via copying. Any mutations of reference types stored _within_ the struct require the usual actor-isolation applied to that reference type itself. In other words, applying global-actor isolation to a stored property containing a class type does _not_ protect the members of that class instance from concurrent access.
775+
So, we propose to remove the requirement that access to those properties are protected by isolation. That is, accessing those stored properties do not require an `await`.
777776

778777
The [global actors](0316-global-actors.md) proposal explicitly excludes actor types from having stored properties that are global-actor isolated. But in Swift 5.5, that is not enforced by the compiler. We feel that the rule should be enforced, i.e., the storage of an actor should uniformly be isolated to the actor instance. One benefit of this rule is that it reduces the possibility of [false sharing](https://en.wikipedia.org/wiki/False_sharing) among threads. Specifically, only one thread will have write access the memory occupied by an actor instance at any given time.
779778

@@ -871,6 +870,21 @@ One idea for working around the inability to synchronize from a `deinit` with th
871870

872871
The primary danger here is that it is currently undefined behavior in Swift for a reference to `self` to escape a `deinit` and persist after the `deinit` has completed, which must be possible if the `deinit` were asynchronous. The only other option would be to have `deinit` be blocking, but Swift concurrency is designed to avoid blocking.
873872

873+
### Requiring Sendable arguments only for delegating initializers
874+
875+
Delegating initializers are conceptually a good place to construct a fresh
876+
instance of a non-Sendable value to pass-along to the actor during initialization.
877+
This could only work by saying that only `nonisolated self` delegating initializers
878+
can accept a non-Sendable value from any context. But also, an initializer's
879+
delegation status must now be published in the interface of a type, i.e.,
880+
some annotation like `convenience` is required. Eliminating the need for
881+
`convenience` was chosen over non-Sendable values for a specific kind of delegating
882+
initializer for a few reasons:
883+
884+
1. The rules for Sendable values and initializers would become complex, being dependent on three factors: delegation status, isolation of `self`, and the caller's context. The usual Sendable rules only depend on two.
885+
2. Requiring `convenience` for just one narrow use-case is not worth it.
886+
3. Static functions, using a factory pattern, can replace the need for initializers with non-Sendable arguments callable from anywhere.
887+
874888
## Effect on ABI stability
875889

876890
This proposal does not affect ABI stability.

0 commit comments

Comments
 (0)