Non-Sendable types should be able to conform to Sendable protocols

One evolving concurrency best practice is that adding a Sendable (or actor) requirement to a protocol is something of a last resort because it imposes undo requirements on conforming types.

To the consumer of the interface, they should not care about the underlying type's isolation, but rather if the method/property requirements of the protocol are callable against the required isolation.

If a conforming type is not fully Sendable, but can provide a Sendable implementation of the protocol, this is sufficient. The non-Sendable data in that type are completely opaque to the consumer anyway. Same goes for MainActor, etc. The compiler should be able to diagnose if the non-Sendable type is implementing the protocol only with Sendable data.

In this very contrived example, SomeFeature cannot conform to IdRequirement, even though it is able to satisfy the protocol requirement using only Sendable data (of course, this example could be refactored in different ways, but it does come up in non-trivial situations often).

protocol IdRequirement: Sendable {
     func provideId() -> String
}

struct IdProvider: Sendable {
    let reqs: [IdRequirement]
}

final class SomeFeature: NSObject {
    let identifer = UUID()

    // ERROR: Stored property 'otherNonSendableParts' of 'Sendable'-conforming class 'SomeFeature' is mutable
    var otherNonSendableParts: Data?
    var otherNonSendableParts2: Data?
}

extension SomeFeature: IdRequirement {
    func provideId() -> String { self.identifer.uuidString }
}
2 Likes

I would argue that SomeFeature should not conform to the protocol. The solution would be to create a pared-down type that only includes the identifier or anything needed to construct it that can be Sendable. Being able to conform non-Sendable types to Sendable-conforming protocols would mean they inherently are Sendable, so they would be both Sendable and non-Sendable, which is nonsensical.

6 Likes

I understand there are ways to refactor this contrived example, but for many problems (and codebases) creating a pared-down type is not a solution.

In the general sense, protocols are about requirements, and similar to RBI, if the compiler can guarantee that the requirements can be safely satisfied by a non-Sendable type, it is a huge QoL improvement here.

As is oft repeated, "protocols are not just bags of syntax". They define semantic requirements — IdRequirement should not require Sendable conformance unless it's a semantic part of what it means to be IdRequirement-conformant. That aspect is just as much a part of the protocol as the members that it defines, and should not be ignored (by the compiler, or otherwise)

If IdRequirement requires Sendable for the author's convenience of being able to pass IdRequirement types around, that's a mistake: the author of IdRequirement should be using IdRequirement & Sendable where they require both aspects.

10 Likes

Assume it is possible. Does the conforming SomeFeature to the protocol makes it Sendable too? How type should behave in that case? What if provideId() mutates other property as a side effect in more complex cases?

Or, what happens with

// Package A
func process<T: Sendable>(_ t: T) {
    // Passes `t` around isolation domains
}

// Package B
protocol IdRequirement: Sendable { /* ... */ }

func doSomethingWithId<T: IdRequirement>(id: T) {
    process(id)
}

// Package C
struct NonSendable { /* ... */ }
extension NonSendable: IdRequirement { /* ... */ }

doSomethingWithId(id: NonSendable()) // oops

Either:

  1. We can't allow NonSendable to conform to IdRequirement
  2. We can't allow doSomethingWithId to call process
    • (Then what does it mean to have IdRequirement: Sendable?
  3. We can't allow process to pass t between isolation domains
    • (Then what does it mean to declare T: Sendable if it doesn't do anything?)

But this is exactly why I pitched this. From experience adopting Swift 6 you run into exactly these sorts of protocols which you may not own to change. Swift 6 is already nigh-impossible to enable on existing codebases, we need proposed features like this to get anywhere.

It wouldn't, because the compiler would disallow it since it's used to satisfy a Sendable protocol requirement.

No, you should be fine to pass NonSendable around isolation domains in this scenario. Everything mutable on the class is hidden from any caller, guaranteed by the compiler.

A type does not need to publicly expose mutable state in order to not be safe to pass around isolation domains. Types could have references to thread-local data, or references to objects which are themselves mutable in a way that isn't safe to pass between isolations.

The compiler can't guarantee this on its own, which is why Sendable exists in the first place.

If a type does not conform to Sendable, that is an explicit message to the compiler that the type is not safe to pass between isolation domains (regardless of reason); passing it around anyway leads to undefined behavior.

2 Likes

Perhaps this part was unclear, but you would only be able to implement the protocol methods using Sendable data on the type. The compiler can indeed guarantee that locally.

There would be no way to call "thread-local data, or references to objects which are themselves mutable", because the consumer only has a reference to the protocol. Maybe there's something else happening at runtime that I'm missing, but I don't see how this isn't both safe and compiler-guaranteed.

1 Like

This is a pretty interesting idea, and kind of reminds me of the isolated conformances concept from the concurrency vision document.

I think in all cases where it would be possible to conform to the Sendable protocol, it would also be possible to implement a standalone type. Is that not the case?

Also, just off the top of my head, it would be necessary for casts back to the non-Sendable type to fail. The compiler must be able to guarantee that you cannot recover access to the non-Sendable data. And I have a feeling this would be difficult to pull off. However, it does remind me of what isolated conformances may need to do, so perhaps there's a path that way.

let value = NonSendable()
let safe = value as SendableProtocol // this will succeed
let unsafe = safe as! NonSendable // this now must fail
1 Like

The issue is that the data types being accessed being Sendable isn't sufficient to ensure the type is being accessed correctly in aggregate. You can implement plenty of unsafe behavior using purely Sendable types.

A toy example:

Let's say that I'm working with extremely large datatypes in my app — structs which have to represent lots of data, and are expensive to compute and pass around. Instead of incurring large copies, I may decide to write a ThreadLocalDataStorage type which stores the data, and allows me to access individual fields on those structs via thread-local ID and keypath:

struct ThreadLocalDataStorage {
    // Every thread gets its own pool of IDs.
    // IDs from one pool are not safe to use from another thread.
    nonisolated static func get<T>(_ property: KeyPath<MyDataType, T>, forId: Int) -> T
}

To make access a little nicer, I might write a small DataProxy struct which makes those fields easier to access:

@dynamicMemberLookup
struct DataProxy {
    let id: Int

    nonisolated subscript<T>(dynamicMember keyPath: KeyPath<MyDataType, T>) -> T
        ThreadLocalDataStorage.get(keyPath, forId: id)
    }
}

extension ThreadLocalDataStorage {
    nonisolated static func insert(_ data: MyDataType) -> DataProxy
}

Now, even though DataProxy contains only Sendable data, and ThreadLocalDataStorage is safe to access from any thread, the Int identifiers only identify data accessible to the thread that inserted the data. As such, I don't want DataProxy to be Sendable, because that might cause the data to be accessed from the wrong thread:

/// DataProxy is only safe to access on the thread it was created on!!
@available(*, unavailable)
extension DataProxy: Sendable {}

Later, I see that I need to interface MyDataType with an API requiring IdRequirement, and I don't notice IdRequirement: Sendable, so I write

extension DataProxy: IdRequirement {
    func provideId() -> String {
        // MyDataType has an `id` field itself.
        self[\.id]
    }
}

If the compiler doesn't prevent this access, because all of the data types are Sendable and functions are nonisolated... Then I'm in for a fun surprise if I pass a DataProxy as an IdRequirement to somewhere that then shuffles it across threads.


At a higher level, again: protocol conformances in Swift are intended to mean something at the type level. Either a whole type is Sendable, or it isn't, because of what it means to be Sendable; it doesn't make sense to say "if I look only at the parts of a type which are Sendable then it's Sendable" any more than it would to say "if I look only at the parts of a type which are Comparable then it's Comparable":

// Not Comparable because the operation wouldn't make sense.
struct WebPage {
    let url: URL
    let lastUpdated: Date
    let contents: Data
    // ...
}

protocol URLProvider: Comparable {
    func getURL() -> URL
}

// Conforming only because I can provide a URL
extension WebPage: URLProvider {
    func getURL() -> URL { url }
}

let googleDotCom: WebPage = /* ... */
let amazonDotCom: WebPage = /* ... */

// Now this is possible based on URL?
if googleDotCom < amazonDotCom { ... }

The semantics of a protocol need to apply to the whole type, as defined by the author.

6 Likes

Ok, this puts certain restrictions on this, I guess.

But you’ve skipped other questions. What about class that has conformed to such protocol? Does it treaded as Sendable? And if not, how then compiler be able to distinguish whether conformance is partial and doesn’t make whole type sendable or the whole type is thread safe?

Sendable is a strong guarantee of thread safety, this selective applicability seems to break it. And compiler struggles with much simpler cases right now to diagnose (just as a fact here, amount of work and research to support current capabilities and edge concepts like RBI is clearly tremendous).

1 Like

I apologize in advance, too, if I come across as not sympathetic to your predicament, because I understand the pain. Unfortunately, I think the right solution to

is filing bug reports for incorrectly-written code, instead of changing the semantics of the language. (And in the meantime, writing wrappers for your types that make it safe, where possible, to conform to nonsensical requirements.)

2 Likes

So you won’t be able to call any other method on the type as well, right? I mean, this other method might mutate state, making it unsafe, therefore prohibited?

Even if theoretically this would be possible to diagnose transitively, then none of these methods can access any mutable state and be useful. In such case, this can form new type, which can freely conform to the protocol and used via composition anywhere.

2 Likes

I think that is great example of the world of issues this would open up! Sendable requirement or its lack communicates certain expectations and design considerations, that otherwise become blurry.

I agree that the idea of this providing “real” conformances doesn’t work, but maybe it’s more interesting at least as a thought exercise to think of this as a “newtype” style macro or keyword (ignoring for a moment all the prior debate about newtype).

In other words, it “feels” possible that given a non-Sendable type and a Sendable protocol that it otherwise conforms to, the compiler could either generate a new type that wraps (or whatever) the original type and thunks out to it to implement the protocol, or diagnose why it cannot do so.

This gets around the substitutability problem we would otherwise encounter with conforming the original type, and would essentially amount to the compiler automatically doing what we are advocating doing by hand (extracting the relevant sendable values).

At least, this helps me wrap my head around what this proposal actually would mean. I think the question is, if this magic feature existed, would it be useful? And I think I agree, it feels like it would be only useful in the most trivial of cases.

2 Likes