[Pitch] Inferring @Sendable for methods

[Pitch] Inferring @Sendable for methods

Introduction

This proposal is focused on a few corner cases in the language surrounding functions as values when using concurrency. The goal is to improve flexibility, simplicity, and ergonomics without significant changes to Swift.

Motivation

The partial application of methods and other first-class uses of functions have a few rough edges when combined with concurrency.

Let’s look at partial application on its own before we combine it with concurrency. In Swift, you can create a function-value representing a method by writing an expression that only accesses (but does not call) a method using one of its instances. This access is referred to as a "partial application" of a method to one of its (curried) arguments - the object instance.

struct S {
  func f() { ... }
}

let partial: (() -> Void) = S().f 

When referencing a method without partially applying it to the object instance, using the expression NominalType.method, we call it "unapplied."

let unapplied:(T) -> (() -> Void) = S.f 

Suppose we want to create a generic method that expects an unapplied function method conforming to Senable as a parameter. We can create a protocol P that conforms to the Sendable protocol and tell our generic function to expect some generic type that conforms to P. We can also use the @Sendable attribute, introduced for closures and functions in SE-302, to annotate the closure parameter.

protocol P: Sendable {
  init()
}

func g<T>(_ f: @escaping @Sendable (T) -> (() -> Void)) where T: P {
  Task {
    let instance = T()
    f(instance)()
  }
}

Now let’s call our method and pass our struct type S . First we should make S conform to Sendable, which we can do by making S conform to our new Sendable type P .

This should make S and its methods Sendable as well. However, when we pass our unapplied function S.f to our generic function g, we get a warning that S.f is not Sendable as g() is expecting.

struct S: P {
  func f() { ... }
}

g(S.f) // Converting non-sendable function value to '@Sendable (S) -> (() -> Void)' may introduce data races

We can work around this by wrapping our unapplied function in a Sendable closure.

// S.f($0) == S.f()
g({ @Sendable in S.f($0) })

This is a lot of churn to get the expected behavior. The compiler should preserve @Sendable in the type signature instead.

Proposed solution

For a function, the @Sendable attribute primarily influences the kinds of values that can be captured by the function. But methods of a nominal type do not capture anything but the object instance itself. Semantically, a method can be thought of as being represented by the following functions:

// Pseudo-code declaration of a Nominal Type:
type NominalType {
  func method(ArgType) -> ReturnType { /* body of method */ }
}

// Can desugar to these two global functions:
func NominalType_method_partiallyAppliedTo(_ obj: NominalType) -> ((ArgType) -> ReturnType) {
  let inner = { [obj] (_ arg1: ArgType) -> ReturnType in
    return NominalType_method(obj, arg1)
  }
  return inner
}
func NominalType_method(_ self: NominalType, _ arg1: ArgType) -> ReturnType {
  return self.method(arg1)
}

Thus, the only way a partially-applied method can be @Sendable is if the inner closure were @Sendable, which is true if and only if the nominal type conforms to Sendable.

type NominalType : Sendable {
  func method(ArgType) -> ReturnType { /* body of method */ }
}

For example, by declaring the following type Sendable, the partial and unapplied function values of the type would have implied Sendabilty and the following code would compile with no errors.

struct User : Sendable {
  func updatePassword (new: String, old:String) -> Bool { /* update password*/ return true}
}

let unapplied: @Sendable (User) → ((String, String) → Bool) = User.updatePassword // no error

let partial: @Sendable (String, String) → Bool = User().updatePassword // no error

Detailed design

This proposal includes five changes to Sendable behavior.

The first two are what we just discussed regarding partial and unapplied function values.

struct User : Sendable {
  var address
  var password
  
  func changeAddress () {/*do work*/ }
}
  1. The inference of @Sendable for unapplied references to methods of a Sendable type.
let unapplied: @Sendable (User) → ((String, String) → Void) = User.changeAddress // no error
  1. The inference of @Sendable for partially-applied methods of a Sendable type.
let partial: @Sendable (String, String) → Void = User().changeAddress // no error

The next two are:

  1. The inference of @Sendable when referencing non-local functions.

Unlike closures, which retain the captured value, global functions can't capture any variables - because global variables are just referenced by the function without any ownership. With this in mind there is no reason not to make these, Sendable by default.

Currently, trying to start a Task with the global function doWork will cause an error complaining that the function is not Sendable. This should compile with no issue.

func doWork() -> Int {
    Int.random(in: 1..<42)
}

Task<Int, Never>.detached(priority: **nil**, operation: doWork) // Converting non-sendable function value to '@Sendable () async -> Void' may introduce data races
  1. Prohibition of marking methods @Sendable when the type they belong to is not Sendable.

If we move the previous work we wanted to do into a class that stores the random number we generate as a mutable value, we could be introducing a data race by marking the function responsible for this work @Sendable . Doing this should be prohibited by the compiler.

 class C {
            var random: Int = 0 // random is mutable so `C` can't be checked sendable
            
            @Sendable func generateN() async -> Int { //error: adding @Sendable to function of non-Senable type prohibited
                 random = Int.random(in: 1..<100)
                 return random 
            }
        }
        
        Task.detached {
           let num = C()
           let n = await num.generateN()
           num.random = 42 // accessing the `random` var while generateN is mutating it
        }

Since @Sendable attribute will be automatically determined with this proposal, you don’t have to explicitly write it on function declarations.

Source compatibility

No impact.

Effect on ABI stability

This would impact the mangling of function names.

Effect on API resilience

No effect on ABI stability.

Future Directions

Accessors are not currently allowed to participate with the @Sendable system in this proposal. It would be straight-forward to allow getters to do so in a future proposal if there was demand for this.

Alternatives Considered

Swift could forbid explicitly marking function declarations with the @Sendable attribute, since under this proposal there’s no longer any reason to do this.

@Sendable func alwaysSendable() {}

However, since these attributes are allowed today, this would be a source breaking change. Swift 6 could potentially include fix-its to remove @Sendable attributes to ease migration, but it’d still be disruptive. The attributes are harmless under this proposal, and they’re still sometimes useful for code that needs to compile with older tools, so we have chosen not to make this change in this proposal. We can consider deprecation at a later time if we find a good reason to do so.

13 Likes

Can you expand on this? Since unapplied function types are just sugar for closure expressions in the type checker I would expect anything to change.

1 Like

I believe the "this" in that sentence is referring to explicitly writing @Sendable on a function or method declaration:

struct S {
  @Sendable func f() {}
}

If a resilient library had previously annotated a method @Sendable, it cannot be removed (even if @Sendable is now inferred), because the mangling will change without some hack like @_silgen_name.

3 Likes

The problem addressed has been a pain point for me. So I would welcome this change :slightly_smiling_face:

1 Like

Since SE-0367 Conditional compilation for attributes this should be very easy to adopt while still supporting older Swift compilers. In addition, I have very rarely needed to add these attributes while adopting Sendable in the swift-nio family of projects. We should at least make this a warning and can think of making it an error in the future or with strict concurrency checking enabled.

1 Like

Please correct me if I'm wrong, but in the provided example a data race can't occur, though the lack of escapability rules for non-function types prevents us to express this invariant in the language.

class C {
  var random: Int = 0 // random is mutable so `C` can't be checked sendable

  // Doesn't escape self
  @Sendable func generateN() async -> Int {
    random = Int.random(in: 1..<100)
    return random
  }
}

Task.detached {
  let num = C() // doesn't escape `num` to the outside
  let n = await num.generateN()
  num.random = 42 // assignment will happen after `generateN` disownes `num`
}

Great improvements overall!

I am a little worried about rule 3, however:

This one made me a bit uneasy, but mostly in terms of worrying about side effects of this rule, for example:

How would we spell "this global function is NOT Sendable"? I'm at a loss how we'd spell that? With member methods we can have them on not sendable types and therefore we'd not infer the sendability. Though here there is no outer type to mark as "not sendable" nor a way to mark the method as such, right?

For example, given the inference rule, how would I make sure this is never considered sendable:

func errno() { touches horrible global state }

? Do we need @~Sendable...? :face_with_raised_eyebrow:

In other words, I worry about treating all global functions as "all bets are off", especially since globals are often at risk of touching global or other state we may have to be careful about :thinking:

2 Likes

Love that we tackle those holes!

  1. & 2. Make total sense to me and I have ran into them myself
  1. The inference of @Sendable when referencing non-local functions.

I am with @ktoso on this one and I am worried that we are opening a new thread safety hole with this. While it is true that they cannot capture any variables they sure can still reference global variables like you said. This code here is not thread safe but would be inferred to be with the new rule

var globalCounter = 0 

func incrementCounter() {
    globalCounter += 1
}

func doSomeWork() {
    Task {
        incrementCounter()
    }
    Task {
        incrementCounter()
    }
}

IMO, we should not implement rule 3. The only way I see that we could safely implement those is if we could detect that a function is pure and doesn't touch any global state.

  1. Prohibition of marking methods @Sendable when the type they belong to is not Sendable.

Yes please! This is a hole in the current design where you can just escape any Sendable checking. Every time where the compiler suggested me to add @Sendable to method has been a mistake by me and I either had to make the type Sendable or make sure that thread safety is upheld by using @unchecked Sendable (which was totally fine in my cases)

Swift could forbid explicitly marking function declarations with the @Sendable attribute, since under this proposal there’s no longer any reason to do this.

Source compatibility aside. I think we should 100% do this on types. It doesn't make sense to mark method on types as @Sendable since the self parameter must be Sendable anyhow.

Since, I am worried about the proposed change for global methods. This is the only place where I still see @Sendable on functions as being useful.

1 Like

@ktoso @FranzBusch Why do you worry about global functions only? Why should any function's sendability be inferred from the outer context at all?

  • Type member functions can mutate globals
  • Type member functions can be independent of self
  • Global functions can be pure
  • Global functions can mutate globals
var globalCounter = 0 
struct Foo {
  func incrementCounter() { // Non-sendable function of a sendable type
    globalCounter += 1
  }
}
class Bar {
  var field: Int
  func getNumber() { // Sendable function of a non-sendable type
    42
  }
}

An unapplied method is effectively a static function, so its sendability should be inferred from the context rather that from the enclosing type.

I'd propose to rethink the idea in a much broader way.

Operating on global state in a way that enables data races must also be banned in Swift 6. @Sophia is working on a proposal to lock down the Sendable/isolation semantics of global and static variables under strict concurrency checking. That said, if we introduce an unsafe opt-out for isolation checking, we would probably also need a way to explicitly annotate a function as not @Sendable. But we don't have those opt-outs today, and I'm not sure they belong in this proposal.

4 Likes

Dima rephrased my worry nicely: if we're just promoting functions to @Sendable, with the semantics as proposed we'll be promoting unsafe functions to @Sendable as well.

So I'm trying to see what our strategy to rectify this might be, as at least as-written there are reasons to not promote to Sendable by default that the proposal doesn't mention. At least I think it would be nice for this proposal to try to sketch out some future direction how we expect this to rely on other parts of the language to make these promotions safe eventually.

I worry about making things implicitly Sendable when they can still result in crashes - won't that undermine the trust into the Sendable checking mechanisms?

I look forward to @Sophia's upcoming proposal! It will definitely help in clarifying how we treat globals, but without it I feel that this proposal at hand is missing some safety properties...?

let global: Mut
func doWork() -> Int {
    global.mutate()
}

Task<Int, Never>.detached(operation: doWork) 
Task<Int, Never>.detached(operation: doWork) // boom
// Converting non-sendable function value to '@Sendable () async -> Void' may introduce data races

So, with Sophias isolation / global values proposal... would we have to "undo" that such function actually will not anymore be automatically promoted to @Sendable because we noticed it touches global state;

Or, now that I write and think of it, maybe since doWork() as declared would be illegal to begin with under upcoming rules about globals and not compile at all... So if doWork compiled, this proposal should assume it's fine to infer @Sendable on it? It's a bit speculative on what we'll be doing with globals, but that's one way that would be possible I guess.

I'm sure there's a good way to frame these proposals that will stack onto each other, just wanted to see how we plan to puzzle them together, thanks!

5 Likes

Yeah, based on my memory of discussions in the early days of concurrency about what safety for globals might mean this is what I would expect, though not sure precisely how the thinking has evolved more recently. Also looking forward to Sophia’s proposal!

Just to make sure we speak the same language and to make things clear.
What does it actually means for a function to be Sendable?
We can decompose a function type as a pseudo protocol of the following definition:

protocol Function<SelfType, Arguments, ReturnType, Throws> {
  // An always opaque type describing the captured context of the function.
  // It contains all dynami entities referenced by the function.
  // The actual type doesn't matter, but we could think of it like it's a tuple (e.g. structural non-nominal type)
  associatedtype ContextType

  // type of self
  associatedtype SelfType

  // Parameter pack of args
  associatedtype Arguments

  associatedtype ReturnType

  // Indicates whether a function throws or not.
  // Either Error or Never.
  associatedtype Throws: Error
}

With this representation we can say, that a function is @Sendable when its ContextType conforms to Sendable.
Type of ContextType behaves pretty much like tuples: implicitly conforms to Sendable when all its elements conform to Sendable.
SelfType may or may not be included in ContextType. It depends on whether a function captured self or was invoked on it.
Please correct me if I'm wrong.


With this definition we can introduce type qualifiers (I'll use made up names) for elements of ContextType. These qualifiers change rules of inferring Sendable conformance:

  • x: let Type - x refers to a value captured by value. x is Sendable when Type conforms to Sendable.
  • x: inout Type - x refers to a mutable binding. If Type conforms to Sendable this conformance is suppressed. x is never Sendable.
  • x: noinvoke Type, where Type is a function-type - x is used as a value, but never invoked. x is always Sendable.

As the @Sendable attribute is a part of the function's interface, it could only be inferred when the function's body is known to all users (e.g. for stable synthesized functions, and for functions whose implementation is a part of the interface as well (inlinable/transparent)).
Having the @Sendable attribute on a function should imply restrictions on the function's body. It should be a compilation error when the ContextType of the function doesn't conform to Sendable, but the @Sendable attribute is applied to the function's signature.


With the given rules the only thing we can take from the proposal is the first point:

But this probably should be extended to all unapplied methods. And only because they always are syntesized.

In this example inner is a function which refers to closure#1, and whose ContextType contains obj: let NominalType.
But inner is only used as a value, and never invoked.
So ContextType of NominalType_method_partiallyAppliedTo is:
(closure#1: noinvoke F),
where F is a function type of signature (ArgType) -> ReturnType with whatever sendability,
and F.ContextType is (obj: let NominalType)

1 Like

We're not opening a new safety hole with this rule. The proposed rule is based on the definition of a @Sendable function, from SE-302:

  1. A function can be marked @Sendable. Any captures must also conform to Sendable.
  2. Closures that have @Sendable function type can only use by-value captures. Captures of immutable values introduced by let are implicitly by-value; any other capture must be specified via a capture list:

A reference to a global var/let is not a capture of that value, whether that global var is referenced in a closure or a global function:

var globalCounter = 0 

func f() {
  var localCounter = 0
  Task.detached { @Sendable in 
    globalCounter += 1
    localCounter += 1 // error: mutation of captured var 'localCounter' in concurrently-executing code
  }
}

The reference to localCounter in the Sendable closure here is considered a capture. References to local vars/lets are treated as captures, because the closure/function can possibly outlive the lifetime of the local it refers to. That's not the case for a global, so global vars/lets aren't "captured" by closures/functions in Swift.

If we crank up typechecker with -strict-concurrency=complete we can see how the Swift 5.8 compiler protects this mutation or reference to that global, wherever it may be accessed:

var globalCounter = 0 

func g() {
  Task.detached { @Sendable in 
    globalCounter += 1 // error: main actor-isolated var 'globalCounter' can not be mutated from a non-isolated context
  }
}

func justRead() {
   print(globalCounter)  // error: main actor-isolated var 'globalCounter' can not be referenced from a non-isolated context
}

These errors are coming from the inferred isolation of globalCounter and is not about Sendability, since I haven't annotated the justRead function with @Sendable here.

That isolation implicitly being applied to the global var is one way of plugging the hole with values shared among functions. But I don't know if that's the only piece of the story around globals and defer to future work for that. (Notably, we get a different warning when building with complete checking and -parse-as-library which treats that var as a different kind of global)

Thus, this proposal's rule here is not creating a new hole. It's just recognizing that the existing rule for @Sendable functions means that all global functions can have @Sendable added to it. That's because a global function cannot refer to a non-global var/let defined outside of its body; so it can never capture anything.

As a sort of TLDR: Since this is already permitted in Sendable closures and global functions marked @Sendable with the default level of concurrency checking, this is really a different problem about globals in general, not just the inference rule in this proposal.

10 Likes

Thanks for chiming in @kavon!

Okey your note in parenthesees is actually most important here and what had added to my confusion, because as you say:

(Notably, we get a different warning when building with complete checking and -parse-as-library which treats that var as a different kind of global)

So if I plop the snippet you shared into a swift package, we get the following warnings (not errors!):

// 5.9 and -strict-concurrency=complete
var globalCounter = 0

func g() {
  Task.detached { @Sendable in
    globalCounter += 1 // warning: Reference to var 'globalCounter' is not concurrency-safe because it involves shared mutable state
  }
}

func justRead()
  print(globalCounter) // warning: Reference to var 'globalCounter' is not concurrency-safe because it involves shared mutable state
}

So, you're right that complete checking does indeed trigger here. Though confusingly the parse-as-library handles this differently than without and only as warnings–which I wasn't aware of actually–but you're right that that's a separate topic. If we could get those to be errors consistently that'll be excellent and "fill that hole" in the model :+1:

Yes agreed that in the current default mode that's just permitted because we permit all kinds of unsafe things in this mode still.

The more interesting question I wanted to focus on is what this would mean in Swift 6's complete/strict checking mode. And I think the errors/warnings we just discussed implicitly answer this: it is that–regardless of sendability–that we'd like to error out on those global accesses. I was unsure if that was at all implemented or not and it seems it is at least partially so.

That really answers my concern as far as the "how will this become safe?" since we'd fail out compiling on accesses, regardless of the implicit Sendability AFAICS.

That seems fine to me, thanks for bringing up the existing warnings/errors, that definitely helped clarify the intent -- some notes on this could be useful in the proposal as for why we're not concerned about "adding" more holes.

4 Likes

Thanks. I thought Sendable is a broader thing and implies a guarantee of no race conditions. It feels like there should be a stricter requirement, something like @NoDataRaces.

I agree on this now in general, but still in doubt if this inference should be implemented before the issue about globals is addressed.
By requiring an explicit @Sendable the compiler forces a programmer to think about what will happen when a function is sent somewhere.
Maybe the inference rule should work only with -strict-concurrency=complete.

2 Likes

Thanks @kavon for reiterating the current global diagnostic behaviour. You are totally right that this is orthogonal and something we already diagnose.

The more I think about this I really think we should remove the ability to annotate a method with @Sendable. Right now it is an unchecked escape hatch where the compiler doesn't check the parameters and the method body anymore. This also applies to inline functions where the compiler just blindly trusts you

func foo() {
    @Sendable
    func bar() {
      // No checking happens here
    }
}

I am thinking should we allow @Sendable methods at all and just apply the same rules that we do with closures where we are able to infer it from the parameters?

2 Likes

Wow. +1 then.
But why is it unchecked?

Does this mean that you feel that methods should all be @Sendable or that they should all be NOT @Sendable.

Updated pitch here: [Pitch] Inferring @Sendable for methods and key path literals!