[Pitch] Staging in `Sendable` checking

Hi all!

SE-0302 introduced the Sendable protocol, which is used to indicate which types have values that can safely be copied across actors or, more generally, into any context where a copy of the value might be used concurrently with the original. Applied uniformly to all Swift code, Sendable checking eliminates a large class of data races caused by shared mutable state. Swift 5.5 does not perform complete checking for Sendable because doing so resulted in so many compiler errors and diagnostics that it undermined the usability of the feature.

I think we can stage in Sendable checking to improve data-race safety over time without. We propose two principles to guide the design of staged Sendable checking:

  • Incremental adoption of concurrency should introduce incrementally more Sendable checking.
  • Sendable problems outside of the user's module should not block progress nor produce an undue annotation burden.

Here is a proposal formalizing this staging mechanism. Thoughts?

Doug

16 Likes

Overall this looks pretty good to me. I'm somewhat bothered by the situation in Module-sensitive Sendable checking, though:

// module A (has not adopted concurrency)
public struct Point3D {
  var x, y, z: Double
}

// module B
import A

struct Sphere { // okay: we allow Sphere to be inferred as Sendable
  var center: Point3D // ... because Point3D is from a module that hasn't adopted concurrency yet
  var radius: Double
}

let sphere = getSphere()
Task {
  print(sphere) // okay: Sphere is Sendable
}

First, would the same apply if Point3D were a class (perhaps with all its visible properties being Sendable and immutable)?

But even beyond that question, it feels weird to me that we would infer a completely implicit conformance to Sendable that we have no way of knowing is correct. I have a couple alternatives in mind, which hopefully mitigate the issues with @unchecked Sendable noted in the Motivation section:

  1. Allow types such as Sphere to conform to Sendable explicitly, but without requiring @unchecked. I.e., the snippet above would be an error, but it would be fine to declare Sphere as:

    struct Sphere: Sendable { 
      var center: Point3D
      var radius: Double
    }
    

    I'm guessing this was considered and rejected under the principle that "Sendable problems outside of the user's module should not [...] produce an undue annotation burden," but I'd be curious to know if there's a more concrete idea in mind of what the threshold for "undue" is.

  2. If annotating types like Sphere is considered too burdensome, we could move the same solution one level up—allow the user to declare extension Point3D: Sendable {} without @unchecked when Point3D is from a module that has not yet adopted concurrency. If module A did adopt concurrency without marking Point3D as Sendable, then extension Point3D: Sendable would become an error—module B would have to declare the conformance @unchecked or remove the conformance.

    SE-0302 rejected conformances like extension Point3D: Sendable in a difference source file from Point3D on the following basis:

    This ensures that the stored properties in a struct and associated values in an enum are visible so that their types can be checked for Sendable conformance.

    However, it seems like this proposal is already abandoning the principle that we must be able to check types for Sendable conformance when they come from a pre-concurrency module, so I don't think this approach (2) is any worse in this regard than what the proposal suggests.

1 Like

Yes. I'm suggesting uniform rules for struct/enum/class. We could consider something more heuristic-y: struct/enum types are more likely to be okay than class types, so we could be more strict about class types. But that punishes correctly-synchronized or immutable classes in modules that haven't adopted concurrency yet, so it isn't a clear win.

Interesting idea! It nudges you to think about whether Point3D itself is likely to be Sendable. My main concern here is that you will not need the Sendable annotation when Point3D's module gets updated for Concurrency, so we have an intermediate step that requires more annotation than the final step. We've created poor experiences for developers before where we made their code worse (more annotations) that later became unnecessary, and it didn't go well. SE-0160 and SE-0025 both ended up like this, and I'm not sure we should do that again.

FWIW, I don't have an idea for a concrete threshold for "undue". It may come down to experimenting with apps a bit to see how many errors and warnings we get in an app when we flip the switch.

Also interesting! My concern from above applies, because the extension Point3D: Sendable { } will be extraneous after its module is updated, but I like that this documents the assumption and provides us with a place that we can diagnose when the assumption is known to be wrong.

If a user wanted to get diagnostics about types from modules that haven't adopted concurrency, this would be a way to silence them.

Doug

1 Like

My entirely non-empirical hypothesis is that this doesn't apply to the vast majority of classes in existing Swift codebases, and among the libraries which vend classes that have gone to the trouble of properly self-synchronizing, most of them will be diligent about adopting concurrency. So, that's to say that if we decide to go the annotation-free route, I'd definitely lean toward this "heuristic-y" strategy. Of course, I strongly agree with this for informing our approach :slightly_smiling_face::

I don't love the idea an app which has adopted concurrency and is compiling in the Swift 6 language mode won't receive any indication that the type they're using may or may not be concurrency safe. IMO it undermines the whole idea that in Swift 6, code is "safe by default" from data races. When importing a arbitrary library, I may or may not know whether it has adopted concurrency. Suppose we have a setup such as the one called out in the proposal:

// module A, no concurrency yet
public struct Point3D {
  var x, y, z: Double
  private let changeCounter = Counter() // shared mutable state!
}

// module B
import A

struct Sphere { // OK, Sphere is Sendable
  var center: Point3D
  var radius: Double
}

Now, suppose that the author of module B writes some code that depends on the (completely implicit!) Sendable conformance of Sphere while also interacting with module A in a way that makes Point3D concurrency-unsafe, and they run into a data race bug.

Who in the chain has committed an error? Certainly not the author module A—they wrote their code before concurrency features were available, and did the best they could at the time. I also think the author module B can't really be said to have committed any error—they're using Swift 6, which is "safe by default" from data races, and their code compiled without any errors or ignored warnings. I think it would be a reasonable assumption on their part that their code, as written, was safe from data races.

The only other option for where the error lies, in my view, is in the language/documentation, either for promising "safe by default" or for failing to deliver on that promise.


This is definitely a worthwhile concern. The solution requiring the user to extend Point3D explicitly at least has the advantage that all such conformances that module A requires could be confined to a single file (that later gets deleted), while requiring the annotation of types like Sphere would result in those extraneous annotations being distributed throughout the codebase.

But in either situation, the impact here of the extraneous annotations (if I'm understanding correctly) is notably more limited than the situations resulting from either SE-0160 or SE-0025: both of these potentially resulted in extraneous annotations on a potentially unbounded number of members within each affected type. The extraneous annotations here will be strictly limited to one per type.

"One per type" is, IMO, an acceptably low burden to be able to properly deliver on the "safe by default" promise in Swift 6.

I realize that for most people this transition step will be entirely ephemeral, but it's also worth considering that it's likely the case that there are apps in the wild depending on libraries/frameworks that have been totally abandoned, and might never adopt concurrency. Clients of these libraries, then, would never be able to get the "safe by default" behavior, unless we wanted to adopt even more aggressive rules in Swift 7.

2 Likes

Missed this on the first pass:

Yeah, I like that idea too. I'd be totally fine with a regime where the Swift 6 default was "warn about Sendable assumptions for pre-concurrency modules, silence by declaring an explicit conformance."

1 Like

As an app developer this seems like a great proposal to me.

1 Like

I believe what you have is a “proposal to beat”.

A couple questions on the complexities of transitive dependencies and builds:

Does this multiple annotation approach work the way you intend for libraries which are clients to programs which are also clients of libraries also used by that program?

Does it durably do that across the many ways that happens (vendoring from large corp APIs, cocoapods including ones used in multiple target projects including watch and tvOS, and less esoteric ones as well)?

Eg:

iOS App Timr007:
Dep-> LibJ
Dep-> LibP

WatchOS StandAlone Timr007Wristpanion:
Dep-> LibJ
Dep-> LibP

LibP:
Dep-> LibJ

Like will this all just come out with all of LibP and LibJ’s type as @_unsafeSendable until they eventually drop their pre-concurrency clients?

Will unsafeSendable be hobbled for must have analysis tools, effectively shortening its useful lifespan so much this whole approach becomes relatively expensive for the benefit?

I worry “which compiler flags should we use” is going to be fairly painful for companies using a mix of dependencies. Systems like cocoapods will end up having to figure out how to specify a lot of those, and not just for the “treat warnings as errors” crowd. I also worry about autocomplete’s
ability to handle this kind of issue.

At runtime/other compile times: I am also worried about managing this across test and development targets which have differences in their dependency lists. Think like when they sometimes add analytics or debug tools, or mock libraries, and things like that.

Worst case I could see is some testing dependency inscrutably making behavior semantically different in an app’s test’s behavior due to the fact it included or did not include some pre-concurrency library, and all the tests being useless then, which the team could perhaps not even notice.

Testing with other deps could also have us having to make a bunch of extra declarations that are hand written copies of a bunch of interfaces, AND fight the compiler about it having “heisensendable” protocol conformances in the worst case.

If this proposal does fail to produce good types for the “apps that use libraries which are dependencies of their own libraries”, are there some more user configurable/half measures to get those libs and apps across the gap here?

Can a small amount of code-gen or hand-edited protocol conformances simplify the overall proposal to avoid compiler flags for libraries? Or is there at least a way a person can promote a type from @unsafeSendable to Sendable in client code and have that honored?

Your comment about how you can’t make non-sendable objects sendable makes me worry. I fear we’ll get backed into a corner of dependency hell we cannot program our way out of, or 3rd party APIs that are incredibly hard to make “play nice” for years after we are otherwise ready to live the Swift6 life.

This mostly seems good, the same sort of cross-language cross-module reasoning that normally goes with swift-versions. This part didn’t make sense to me, though:

The @unsafeSendable attribute indicates that the corresponding parameter (of function type) is intended to be @Sendable going forward. For a client that has not yet adopted concurrency, it will be treated as a non-@Sendable function type.

Why would a library author not always use this annotation? And if they would, can that just be the behavior of plain old Sendable?

1 Like

Understood. And it's likely that a class is probably an order of magnitude more likely to have non-Sendable semantics, so the heuristic is probably well-justified.

I agree that Swift 6 should be more noisy about missing Sendable conformances. Perhaps Swift 5.x ignores missing Sendable conformances from Swift 5.x code that has adopted concurrency, but Swift 6 warns about such conformances.

People have a lot of types, though. swift-driver is not very big, but it has over a hundred structs. SwiftUI has almost 600 public structs. I think it's necessary to do these annotations for Swift 6, but there is a cost here and we need to be wary of that.

Agreed. One additional tweak we could make is to produce an error when a module declares a Sendable conformance for a type from another module, e.g,:

import A

// Point3D is from module A
extension Point3D: @unchecked Sendable { }

when that module A adopts concurrency, if Point3D is not declared Sendable there, we should error. That way, if we were wrong about our assumption that this type should be Sendable, we'll learn about it.

Doug

5 Likes

It generally only considers two modules at a time: the module doing the Sendable check, and the module that declared the type that doesn't conform to Sendable, so I think it scales.

@unsafeSendable is generally for closure parameters that need to be @Sendable, but yes---if there are pre-concurrency clients, one should use it.

It'll be treated as @Sendable in any code that adopts concurrency, so I don't think it's lifespan is that short. @unsafeSendable can become @Sendable when pre-concurrency clients go away (or if they can be updated at the same time).

I don't see a reason to worry about autocomplete specifically. However, what I'm proposing is effectively a new dialect in between Swift 5.5 and Swift 6, which does worry me. We've tried fairly hard not to have dialects in Swift other than the language base version, which has been at 5 for ~2.5 years, and we don't have a whole lot of opt-in warning flags. This would be a big one, and I have some concerns that a significant part of the ecosystem could be stuck in this middle dialect.

I think there might be a misunderstanding of Sendable conformances here. You can make a type Sendable even if it has instance data that isn't Sendable using @unchecked, e.g.,

class StoredData {
  // ...
}

struct Data: @unchecked Sendable { // okay! we told the compiler to trust us, it's Sendable
  var stored: StoredData // not Sendable
}

The above can generalize to an UnsafeSendable<T> type (or even property wrapper) that lets you box up a non-Sendable type in an unsafe Sendable shell to work around issues when you're stuck or you have some other kind of synchronization.

Doug

You would only use this annotation for old APIs that have pre-concurrency clients. If you're introducing new API, you should start your clients out right with the concurrency checking.

I'm beginning to dislike @unsafeSendable, because I see it's causing confusion. Instead, I'm starting to prefer that declaration-level @predatesConcurrency approach that will strip out concurrency-related constraints (T: Sendable, @Sendable, @MainActor, etc.) when the API is used in a pre-concurrency client.

Doug

3 Likes

Hello,

The proposal distinguishes two kinds of module:

We propose to phase in checking for Sendable by distinguishing modules that have fully adopted concurrency from those that have not. [...]

Importing a module that does not adopt concurrency introduces some unsafely:

When using a type from a module that has not adopted concurrency, the lack of a Sendable conformance does not produce an error. Instead, the compiler will produce a warning, or even suppress the diagnostic entirely.

The above paragraph is augmented with:

In this context, I can imagine a third stage: the library author wants to delay concurrency adoption in a module (for some good reason), but wants to prevent incorrect Sendable inference.

Is it a scenario worth considering? What about letting the author of such a module annotate types that are known to be non-sendable, in order to prevent the compiler from inferring this conformance?

Yep this is my expectation as well. :+1:

That seems reasonable to me. There's already the expectation that Swift 5.x is not safe by default especially when it comes to Sendable checking.

Ah, yeah, I really only meant this as a comparison to the extraneous annotation situations for @objc and access control modifiers. For Sendable checking, I agree that if the typical annotation burden really was one for every type in either the imported module or the client module, it would be quite large. But since this situation only arises where a type from the client module directly includes a type from the imported module, I expect that the typical project will not need to annotate each and every type.

Hm, so the Swift 6 rule would be "don't conform types you don't own to Sendable"? I don't think that causes major issues for inferring Sendable for aggregates (since the aggregate itself can just be tagged @unchecked Sendable), but it would prevent the direct usage of Point3D as Sendable in the case where I somehow know that it is concurrency-safe and that the author of module A perhaps just forgot to add the conformance.

I suppose you could make a simple @unchecked Sendable wrapper around Point3D in that case though, and it won't always be possible for a client to tell the difference between "non-Sendable because author forgot" and "non-Sendable because it may become concurrency-unsafe in the future," even with access to the source.

I think this came up during the discussion of SE-0302, but the implicit conformance only applies to non-public or frozen types, since non-public types aren't part of the module API and frozen types can't validly add/change members in a way that would break Sendable inference.

This came up in the review of SE-0302, because Sendable is inferred structurally for non-public struct and enum types, and there is currently no way to disable the inference. I'm inclined toward using (un)availability on the conformance to spell "this is intentionally not Sendable", rather than invent something new, e.g.,

public struct MyType : @available(unavailable, *) Sendable {
  var x, y: Double
  // some day we may add some shared state here
}

To your use case, if a module explicitly declares an unavailable Sendable conformance, importing modules should complain if they need MyType to be Sendable.

If every type is explicitly marked as Sendable or having unavailable Sendable conformance, then there's no observable difference in importing modules as to whether the module has adopted concurrency or not.

Oh, that's not what I meant. I don't want to ban this:

import A

// Point3D is from module A
extension Point3D: @unchecked Sendable { }

I want to turn it into an error if A adopts concurrency and doesn't make Point3D Sendable:

// module A adopts concurrency
public struct Point3D { ... } // not Sendable

Doug

3 Likes

But after the concurrency transition period, wouldn't we eventually end up in a world where the rule is "don't conform types you don't own to Sendable"?

Once ~all libraries have adopted concurrency, an arbitrary public type X will either be declared to conform to Sendable in its own module, in which case extension X: @unchecked Sendable should give a "redundant conformance" error, or X won't conform to Sendable in which case you're proposing extension X: @unchecked Sendable still be an error (unless I'm still misunderstanding?).

Yes to all of that.

Doug

1 Like

So for protocols and generics of existing types, how can we stage in the requirements on existing things to require sendable. E.g. all Elements passed in AsyncSequence should be Sendable; but we couldn't put that in due to the fact that hardly any type was Sendable at that point of intro. I am sure this is true for a number of other APIs.

Thank you for clarifying. I'm still personally worried about the pairwise module based checking. Modules are a fine abstraction, but I fear actual library mechanics for how and when code is compiled and by whom will make a hash of this all come transitive dependencies.

Autocomplete has historically had high fragility in and around closures, just giving up. As a good deal of the sendable stuff is related to closure params, I worry about a sendable and non-sendable double entry in an autocomplete data structure gumming up closure autocomplete. It can also get a bit frisky around the combination of generics + closures. Besides auto-complete, I worry about the compiler itself getting bothered by these definitions that may conflict.

If we look at only the context of the "checker" and the "checked", I see deep hierarchies coming out possibly still with both sendable and unsafeSendable interfaces about the same types/closure signatures.

AppGamma:
LibB
LibC
LibD
LibF

LibB:
LibD
LibE
LibC

LibC: // This is preconcurrent code
LibE
LibQ

LibF: // This is a library which explicitly tries to work for preConcurreny code and swift 6.
LibE
LibQ

Given the above situation here is where it seems to be trouble. If LibE had closure taking APIs, or even types used in closures a lot, specific ways of packaging and compiling LibB, LibC, LibF and LibE could all result in "duplicate but slightly different" signatures with respect to the Sendable interface. This seems possible given even the same LibE codebase in each.

Like:

let cc = LibC.Cicle()
let fc = LibF.FracturedCircle() // this of type circle
let bc = LibB.BumpyCircle()

let arrayOfCircles = [cc, fc, bc] //I'm pretty sure y'all would catch it if it didn't compile here during development of the feature

//I'm also pretty sure this situation is going to be handled alright
libF.funcFThatRequiresArrayOfCircles(arrayOfCircles)
libC.funcCThatRequiresArrayOfCircles(arrayOfCircles)
libB.funcBThatRequiresArrayOfCircles(arrayOfCircles)

but when we get to stuff more like this:

//in AppGamma
struct Peg<T:Round>{
 ...
}

SomeVaguelyConcurrencyRelatedApp.doStuff{ 

    fc.checkArea(cc) { peg1
       cc.checkArea(fc) { peg2
         peg1 == peg2
       }
    }

}


This is where I greatly fear we hit weird compilation error messages like "Circle is not of type Circle" compiler errors, even if generics aren't involved.

If you take AppGamma and LibC, LibF or LibE are removed, say in a testing situation, or just because it's no longer needed, do the types change at the AppGamma level?

This all seems like a very large testing matrix for the implementation of your proposal, with a lot of guessing at how various providers of Swift libraries could slightly blow up everything. I'm not talking about falsely marking non-concurrent code here; I'm just talking about the code compiling.

Apologies if I really am missing something there, but this seems like a very real world composition problem that I'm not sure the changes to Error + the function/closure attribute + the type inference + compiler flags would all sail through necessarily. I feel it could strand some apps pre-concurrency due to a hairy library slightly doing something wrong.

--

On another note, of sincere feedback about the parts that are confusing iffy feeling, and in the spirit of frank feedback on what I could clearly understand from the document, not second guessing your decisions:

The slight difference in Sendable the protocol and @Sendable functions is also tricky. They maybe feel like they mean something different. Perhaps this is just the "new taste" of marker protocols, and they are really "protocols that act more like closure parameter attributes". Perhaps it's just calling marker protocols "protocols" is the issue, time will tell probably. Not trying to bike shed, but tell what it feels like from the outside.

Like if you look at @noescape vs @escaping, the second one marks code that "isn't safe" to flexibly use however. One of the reasons in the original proposal to assume @noescape was that humans are likely to fail to notice that they made something escape. This allows the compiler to go "hey person, you are not typing right, and this extra thingy can go up here" via a fixit. You can't as easily do that with @sendable or @unsafeSendable or @predatesConcurrency, for instance. If you said why the "no escape" variant is chosen as the default but not in the escaping situation perhaps it would have just all made sense, but I'm not sure.

Is "Sendibility" the right property to be annotating closures/functions with? I don't know.

It's "dual" doesn't have an obviously better name. I am not sure I'd love the name "@nonSendableAllowed", but it's what I mean. Concurrency is pretty hard for humans, and I'd imagine it's even harder for y'all to not only model concurrency, but how we all will fail repeatedly while trying to do it right. Would the dual better focus us on "how we messed up" so we could better write concurrent code? Or specifically having to mark things "@doesNotCareIfConcurrent", "@acceptsJanky"?

@unsafeSendable feels like it might be okay. @preConcurrency may work but isn't super clear on "why we care", while possibly not accurate from the perspective of a person writing a library at some time in the future. Sorry to be both unhelpful on this front and still confused.

@libraryTryingToDoBothConcurrencyAndHandleOldStuffWellSoPleaseGetOfItsBack is a little wordy, but probably closer to what this is all about. It's really weird to deal with varying answers to "Is this library guaranteeing safe, concurrent types", "Is this library trying to use safe, concurrent types" and "Is this library only able to provide safe concurrent types if it is also provided them".

I do not envy you in both having to figure out a technical answer to all of this and then teach us all how and what to think. Best of luck, this feels most of the way there.

Normally, one could not go back and retroactively add constraints to an associated type, protocol, or generic type. However, marker protocols like Sendable have no ABI impact in those positions, so adding a constraint to (say) make the Element generic parameter of AsyncStream Sendable could break source compatibility, but it won't break binary compatibility. And with the approach to staging in Sendable checking described by this proposal, it won't break source compatibility either until "Swift 6". You can see where we recently added Sendable to the Task APIs. It sounds to me like we need to follow up with at least AsyncStream to make its Element type Sendable (because the closure is @Sendable). I don't think AsyncSequence wants to force its element type to be Sendable, though, because you could certainly have sequences that are asynchronous but never transfer values across actors/concurrency domains.

They shouldn't, no. The notion of "adopts concurrency" is on a module-by-module basis, and does not interact with modules other than the importing module and the imported module.

It's certainly not a trivial undertaking, but we've already been using this general approach in Swift 5.5 in a more narrow way, and it seems to work. For example, the iOS 15 SDK annotates UIView as being on the main actor with the equivalent of "predates concurrency". Let's get the compiler to complain about the type of UIView.addConstraint, like this:

let _: Int = UIView.addConstraint

In existing code, the error will be that a value of type (UIView) -> (NSLayoutConstraint) -> Void is not convertible to Int. However, if you put that into code that uses concurrency, like this:

func g() async {
  let _: Int = UIView.addConstraint
}

the error will be that a value of type (UIView) -> @MainActor (NSLayoutConstraint) -> Void cannot be converted to Int. So, we're already doing a "predates concurrency" type translation similar to what is described in this proposal, for all code out there that's using UIKit and AppKit with the iOS 15/macOS 12/etc. SDKs.

SE-0302 covers this, but a @Sendable function type conforms to Sendable. There are some semantic rules applied to @Sendable functions/closures to ensure that they meet the semantic requirements of Sendable. That's why we use the same name in both places.

@Sendable is designed in a similar manner. The default is that a function is non-@Sendable, which means that you'll get a compiler error or warning if you try to use that function in a manner that requires it to be Sendable, e.g.,

func f(fn: @escaping () -> Void ) {
  Task {
    fn() // warning/error: cannot use parameter 'fn' with a non-sendable type '() -> Void' from concurrently-executed code
         // note: a function type must be marked '@Sendable' to conform to 'Sendable'
  }
}

So, you'll be guided to the right solution by the compiler.

@predatesConcurrency and @unsafeSendable are harder, but they're also more niche features for folks who are providing source compatibility with pre-concurrency clients.

EDIT: we'll have an updated proposal coming in a week or so that will extend the "staging" to also cover global actors (like my @MainActor example above) and work through some additional compatibility issues. Thanks for all of your comments so far, folks.

Doug

4 Likes