Pitch #2: Protocol-based Actor Isolation

Hi all,

Thank you for the discussion in the previous pitch thread. I've learned a lot from your feedback and made several major revisions to the proposal, notably:

  • Simplifying the ActorSendable requirement to being a marker protocol, eliminating the possibility of implicit deep copies.
  • Including a ValueSemantic marker protocol as part of the proposal (but I leave it to Dave and other experts to define exactly what that means).
  • Defining away the possibility of "expensive" synthesized cross-actor copies in the face of resilience boundaries and other advanced cases.

Many thanks to Doug and others who helped me understand that these simplification are possible without jeopardizing the core idea of the proposal: reject accidental transfers of invalid reference types, allow user extensibility for advanced cases, and allow marker types for compatibility with legacy reference types.

You can see the second revision of the proposal here.

I'd appreciate additional feedback and comments, thanks!

-Chris

10 Likes

Note that incorrect conformance to [ActorSendable] can introduce bugs in your program (just as an incorrect implementation of Hashable can break invariants). For example, it would be incorrect (and very unwise!) to add to your codebase, because it results in a shared reference to mutable state:

If you want to make sure your Hashable implementation is correct, you have to look only at the Equatable and Hashable implementation. If you want to make sure your implementation of ActorSendable is correct, you have to look at all the properties and all the methods of your type, including the ones in extensions and subclasses, and if your type is open/public it includes extensions and subclasses that aren't written by you. Much harder to do.

That's because ActorSendable is not about what the the type can do, it's about what a type cannot do, which is the opposite of all the protocols in the standard library. It reminds me of negative generic constraints.

Auto-synthesized Struct/Enum ValueSemantic Conformances

What if my type isn't ValueSemantic, even though all the stored properties are? Would there be a way to opt-out from the conformance?

future work

// Capturing a ‘searchName’ string is ok, because it is ActorSendable.
list = await contactList.filteredElements { $0.firstName==searchName }

That closure captures a variable, not the string. Because of that it's referencey, so shouldn't be ActorSendable (unless searchName is a let)

struct S {
    var firstName = "World"
}
var searchName = "Hello"
let f: (S) -> Bool  = { $0.firstName==searchName}
print(f(S())) // false
searchName = "World"
print(f(S())) // true

If you want to capture the string, you have to use { [searchName] in $0.firstName==searchName }

As such, the Swift compiler should implicitly auto-synthesize conformances for structs, enums, and tuples that are compositions of other ValueSemantic types.

Is there a use case for opting out your type of this behavior?

extension String : ValueSemantic {}

I'm a bit sad about losing the word "unsafe" in the definition of the conformance.

These are great improvements, Chris! The addition of ValueSemantic will be broadly helpful and I like how this version of the proposal doesn't allow for potentially unexpected code to be run when calling into another actor context. In retrospect, I think my nitpicking of the previous proposal was related to the latter: I didn't want the system trying to be smart, but it is nice that now it can be helpful in cases where otherwise I might have done something stupid.

Good question. I can't imagine any, can either of you?

If there is, then we have two choices: 1) introduce a new attribute to opt-out (amusingly, this would making opt-out an opt-in decision :slight_smile:), or 2) require an explicit conformance like we do for equatable and hashable.

I'd personally prefer #2 for reasons explained in the alternatives considered section, but I can see how people could be annoyed by the boilerplate.

Me too, but I console myself by reminding myself that Hashable and Equatable auto-conformances are not always correct either, e.g. when a type has a local cache or something that shouldn't be included. If this is a serious issue, we could go back to the first proposal, uglify the protocol name, or invent some magic attribute or something for conformance that includes the unsafe word.

-Chris

Let's say I'm making a fist person shooter, where players are divided randomly into six teams, and every team keeps track of their kills and deaths.

struct TeamRef: CustomStringConvertible {
    static let numberOfTeams = 6
    private static var kills: [Int] = .init(repeating: 0, count: numberOfTeams)
    private static var deaths: [Int] = .init(repeating: 0, count: numberOfTeams)

    private var teamIdx: Array<Int>.Index = Int.random(in: 0..<numberOfTeams)

    var kills: Int {
        get {
            Self.kills[teamIdx]
        }
        nonmutating set {
            Self.kills[teamIdx] = newValue
        }
    }

    var deaths: Int {
        get {
            Self.deaths[teamIdx]
        }
        nonmutating set {
            Self.deaths[teamIdx] = newValue
        }
    }

    var description: String {
        "kd: \(kills)/\(deaths)"
    }
}

let team = TeamRef()
team.kills += 1
let teamCopy = team
teamCopy.deaths += 10
print(team) // kd: 1/10

This type has reference semantics, all the properties (stored and computed) are Int, which is specifically mentioned in the pitch as a type that would be ValueSemantic.

Why didn't I use a class for the Team?

  • Why not? It's correct swift today.
  • If I want to do something with all the kills, it's better for cache for them to be in the same place in memory
  • I can change the stored property type to something like UInt8 and get 8 times smaller size compared to a class
  • it eliminates ARC overhead

Another example - keeping track of preferences for multiple users

import Foundation
struct Settings {
    let user: String
    var lightMode: Bool {
        get {
            UserDefaults.standard.bool(forKey: user+"_lightMode")
        }
        nonmutating set {
            UserDefaults.standard.setValue(newValue, forKey: user+"_lightMode")
        }
    }
    var indentationSize: Int {
        get {
            UserDefaults.standard.integer(forKey: user+"_indentationSize")
        }
        nonmutating set {
            UserDefaults.standard.setValue(newValue, forKey: user+"_indentationSize")

        }
    }
}

let settings = Settings(user: "John")
settings.lightMode = false
let copy = settings
copy.lightMode = true
print(settings.lightMode) // true

I expanded on what this could look like here: thoughts appreciated @gribozavr.

Also @cukr, I included your point about closure captures in the closure section. Thanks!

I think that there are three reasonable ways to disable implicit ValueSemantic conformance:

  1. Switch to work like Hashable and Equatable, requiring an explicit conformance for autosynth.
  2. Introduce an attribute like @noValueSemanticAutoSynthesis.
  3. Introduce an attribute like @disableImplicit(x) where x could be things like ValueSemantic, memberwise init, and/or implicit copy constructor when we have move semantic types.

Any thoughts or opinions on this?

-Chris

2 Likes

What behavior do we want for public types? The choice Swift generally makes is that all public API contracts must be explicitly stated. That convention implies that ValueSemantic conformance must be explicit for public types. If that is the case there is at least some incentive to require explicit conformance everywhere. That said, if we do go with implicit conformance I think option 3 is the best choice.

Most value semantic types already conform to Equatable and usually Hashable. Explicit conformance could be made more convenient if we introduced a couple typealiases:

typealias EquatableValue = Equatable & ValueSemantic
typealias HashableValue = Hashable & ValueSemantic

This is something users could easily add themselves if we don’t’ want to put these in the standard library.

2 Likes

I agree about auto-synthesized Equatable and Hashable conformances potentially being incorrect, but they should be memory-safe.

I like it. This idea mirrors Rust's unsafe traits design.

We could also borrow a page from Rust, specifically the PhantomData type. The type that wants to opt out can add a PhantomData<AnyObject> property -- the authosynthesis would see a non-value type stored var and would avoid synthesizing the conformance (but since the property is zero-size it does not cost anything at runtime). That way we can keep synthesis rules uniform (no opt-out processing).

1 Like

Really great point, brings back all the memories of you and I wrestling with memberwise init. :cry:

I'm generally uncomfortable with either of these being implicit (I'd personally rather them be explicit opt-in like hashable), but I think that scoping them to internal types only makes me a lot more comfortable with the implicit behavior. This is a great suggestion. I'll incorporate it into the proposal.

Yeah, I think we should evaluate something like this as a follow-on. They are synactic sugar and are likely to draw a lot of debate that detracts from the core proposal. I'll mention them in 'future directions' though, thanks!

-Chris

Interesting idea. It isn't really related to this proposal, but something that I would like to explore in the future is an attribute that turns off all autogenerated members -- including the default assignment operator, destructor for structs, and move/moveinit hooks in the basic type witness table. Swift internally supports arbitrary logic here, but we don't allow advanced users to write their own types with their own implementation of these hooks. I'd love to be able to write something like (disclaimer: I made no attempt to pick good names here :slight_smile:):

@explicit
struct MySmartPointer {
   init(_ x: MySmartPointer) { 
     // custom copy ctor 
   }

  init(_ x: ^MySmartPointer) { 
     // move constructor.
  }

  operator=(_ x: MySmartPointer) { 
     // custom copy reassign operator
  }

  operator=(_ x: ^MySmartPointer) { 
     // custom move reassign.
  }

  deinit {
     // custom destructor
  }
}

In addition to being valuable for advanced pure-swift APIs, such a thing is useful when you need to write a C API in Swift that has init/copyctor/dtor hooks. Currently the least-bad thing to do is use a class for this, which adds an extra level of indirection and a memory allocation.

This could also be way to handle move only types and other weird things that C++ can express that Swift can't.

-Chris

10 Likes

This would make the interaction from Swift to the C much less expensive. I wonder what only a simple 'deinit' destructor on structs would do.
Having to wrap heap allocated C handles (which are cheap to just copy around) on classes and incurring in unneeded ref-counting its just taxing the performance without need.

BTW: Given its all LLVM, the abi on both sides are stable, etc.. would be possible to get a move from the C++ world to Swift AND give the possibility for Swift to automatically call the C++ destructor in case the handle is scoped destruct?? (As this combo would turn feasible to avoid unneeded heap allocations on C++ side)

With 'deinit' in structs, moves and C++ object destruction, it would be such a game changer for Swift, giving it would become the best language to interface with C and C++, which we all know, have wonderful codebases that are important to interface with (with LLVM being one of them).

I'm sure you have a much better idea, a clear picture for all this, giving the wizard you are, but i'm just trying to make it clear how important a couple of changes in the Swift language would do to the development of the ecosystem around it.

1 Like

I would add that if we can do this, then "let's" pull the HeapObject single threaded definition inside swift runtime into a separate class, so we can rig non-atomic, non-locking swift allocations to this phantom stuff, and we can build compiler safe concurrency first class into the language. Quite the perf boost!

heapobject in swift runtime

1 Like

Oh yeah, and we need to be able to override malloc too :-)

I've been thinking more about explicit conformances and the explicit "unsafe" annotation you mention. In particular, with explicit conformance being required for public types, we now have no signal to the programmer that they are taking responsibility for correct conformance. I think we need a signal here, especially for public members.

With this in mind, I think it would be good to require @unsafe ValueSemenatic in explicit conformances when the conformance does not follow trivially from composition of stored values. When all stored values conform, a normal ValueSemantic conformance would be accepted (and necessary for public types). This is similar to, but slightly different than, the @unsafeConformance approach you describe.

I think this proposal will have some impact on the "stored properties in extensions" idea that had been discussed many times in the forums. The latest (short) discussion was from 4 months ago:

For example, if String conforms to ActorSendable, then it would be an error to extend it with a stored reference-type property:

extension String {
    let newStoredProperty: NSMutableString = "a mutable string"
}

"Stored properties in extensions" is not a Swift feature, and definitely outside of the scope of this proposal, but I think it might be beneficial to discuss about this proposal's impact on it.

1 Like

On the topic of ValueSemantic vs UnsafeValueSemantic, maybe we can have them both by making ValueSemantic a subset of UnsafeValueSemantic.

As far as I can see, there are 2 situations where conformance to ValueSemantic is unsafe:

  1. When the conforming type is a reference type.

  2. When the conforming type is a value type, but it's not composed of only ValueSemantic types.

Situation 2 can be addressed by an error message when the compiler cannot auto-synthesise the conformance. Situation 1 can be addressed if we have a way to mark value types like how we mark reference types with AnyObject:

Maybe we can introduce another new protocol AnyValue (or ValueType or something else, bikesheddable) opposite to AnyObject, and have all value types (structs, enums, tuples) implicitly conform to it. Then, we can allow UnsafeValueSemantic written as ValueSemantic when the conforming type is a reference type:

typealias ValueSemantic = UnsafeValueSemantic where Self: AnyValue

^ This expression isn't possible in Swift today, though. So some additional compiler magic might be needed.

Alternatively, it might be more workable if ValueSemantic inherits from both AnyValue and UnsafeValueSemantic:

protocol ValueSemantic: AnyValue, UnsafeValueSemantic {}

class Foo: ValueSemantic { // error: Non-value-type 'Foo' cannot conform to value-type protocol 'ValueSemantic'
                           // fix-it: replace 'ValueSemantic' with 'UnsafeValueSemantic'
    let bar: Int
}

Or, a third option, make ValueSemantic a composition of AnyValue and UnsafeValueSemantic:

typealias ValueSemantic = AnyValue & UnsafeValueSemantic

class Foo: ValueSemantic { // error: Inheritance from non-protocol type 'ValueSemantic' (aka 'AnyValue & UnsafeValueSemantic')
                           // fix-it: replace 'ValueSemantic' with 'UnsafeValueSemantic'
    let bar: Int
}

^ These latter 2 options also need some additional compiler magic, but it's precedented by AnyObject.

FWIW, I think that many of the comments in this thread are pushing towards making ValueSemantic conformance always be explicit, instead of ever being implicitly synthesized. The current proposal (implicit synthesis for non-public types) is a bit of a middle ground, but doesn't seem very satisfactory.

It seems that following the precedent of Hashable and Codable synthesization would be cleaner. WDYT?

1 Like

Making conformance explicit would be particularly helpful when making changes to existing types (eg adding a new field). It’s currently very helpful to be told immediately that a new field you’ve just added stops automatic Equatable/Codable conformance. I would imagine it would be the same for ValueSemantic.

Otherwise, I think people will be in the situation where passing a parameter of a particular type to an Actor works (without them doing anything, or maybe even knowing why it works), they make a change and an error appears some distance away.

I think the consistency argument also has a lot of merit.

1 Like