[Pitch] Isolated Function Values and Sendable

For the complete and most up-to-date text, visit here.


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 major 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. For example, today you can create a function-value representing an actor's method by writing an expression that only accesses (but does not call) a method using one of its instances. More precisely, this access is referred to as a "partial application" of a method to one of its (curried) arguments, the object instance. One can think of StatefulTransformer.transform's type signature as being (isolated StatefulTransformer) -> ((Data) -> Data) to support partial applications:

typealias Data = Int

extension StatefulTransformer: AnyActor {   
  func transform(_ x: Data) -> Data { /* ... */ }
         
  func insideIsolationExample() -> [Data] {
    let f: (Data) -> Data = self.transform 
    //                      ^~~~~~~~~~~~~~
    //                      a partial-application of `self` to `transform`
    return [4, 8, 15, 16, 23, 42].map(f)
  }
}

Partial-applications of object methods are allowed almost anywhere, but this is no ordinary method. As of today, the partial-application of an actor-isolated method is not allowed if it appears in a context outside of that actor's isolation:

extension StatefulTransformer {
	// a nonisolated method trying to partially-apply an isolated method
  nonisolated func outsideIsolationExample() -> [Data] {
    let _: (Data) -> Data = self.transform
    //                      ^~~~~~~~~~~~~~
    // error: actor-isolated instance method 'transform' can not be partially applied
	}
}

Part of the need for this limitation is that knowledge of the isolation is effectively erased from the type signature when partially-applying the actor instance. That knowledge is required for non-async isolated methods, as their runtime representation cannot encode isolation enforcement alone. But, the limitation is not a fundamental barrier. It is feasible to create a @Sendable version of a partially-applied non-async actor method, by representing it with an async function value.

Conceptually, a similar limitation comes up when performing a conversion that removes the @MainActor from a function type. Since it is generally unsafe to do so, the compiler emits a warning, such as in this example:

@MainActor func dropMainActor(_ f: @MainActor (Data) -> Data) -> [Data] {
  return [4, 8, 15, 16, 23, 42].map(f)
  // warning: converting function value of type '@MainActor (Data) -> Data' to '(Data) throws -> Data' loses global actor 'MainActor'
}

But not all situations that drop the global-actor from a function value are unsafe. In the example above, the conversion that loses the global-actor happens while already on the same actor. By the same logic as our actor-method example, this should be safe if we do not allow that casted function to later leave the MainActor's isolation.

These and other aspects of how Sendable and actor isolation interact with function values are the focus of this proposal.

Proposed solution

This section provides a summary of the solutions and changes proposed for Swift. For complete details and caveats, see the Detailed design.

Inferring @Sendable for methods

In SE-302, the @Sendable attribute was introduced for both closures and named functions/methods. Beyond allowing the function value to cross actor boundaries, the attribute primarily influences the kinds of values that can be captured by the function. But methods of a nominal type cannot capture anything but the object instance itself. Furthermore, a non-local (i.e., a global or static) function cannot capture anything, because a reference to a global declaration is not considered a capture of that value. Thus, the proposed simplifications are:

  1. the inference of @Sendable on all methods of a type that conforms to Sendable.
  2. the inference of @Sendable on all non-local functions.
  3. the prohibition of marking a method @Sendable if the object type does not conform to Sendable.

Introducing @isolated function types

After SE-338, an async function that has no actor isolation is said to be nonisolated and within a distinct isolation domain. That distinct domain has an effect on whether a non-Sendable value can be passed to the function. For example, Swift marks the following function call as invalid because it would break the isolation of ref:

@Sendable func inspect(_ r: MutableRef) async { /* ex: creates a concurrent task to update r */ }

actor MyActor {
  var ref: MutableRef = MutableRef()  // MutableRef is not Sendable

  func check() async {
    await inspect(ref) // warning: non-sendable type 'MutableRef' exiting actor-isolated context in call to non-isolated global function 'inspect' cannot cross actor boundary
  }
}

When used as a first-class value, the type of inspect will be @Sendable (MutableRef) async -> (). If inspect were bound in a let, we can still accurately determine whether to reject the call based on the type. But as discussed previously, the true isolation of an actor instance cannot be accurately represented in a function's type, as it is dependent upon a dynamic value in the program. Without any way to distinguish these kinds of values, type confusions can happen:

extension MyActor {
  func update(_ ref: MutableRef) { /* ... */}
  
  func confusion(_ g: @Sendable (MutableRef) async -> ()) async {
    let f: (MutableRef) async -> () = self.update
    
    let ref = MutableRef()
    await f(ref) // Want this to be OK,
    await g(ref) // but this to be rejected.
  }
}

func pass(_ a: MyActor) async {
  await a.confusion(inspect)
}

In the example above, the call to g should raise an error about passing a non-Sendable value from an actor-isolated domain into a non-isolated one. That fact is inferred purely based on the async in the type of g, which has no other isolation listed. But if we raise that error based on the types, then f would also be an error, despite not actually crossing actors! As of today, this example raises no diagnostics in Swift.

To solve this type confusion, a new type-level attribute @isolated is proposed to distinguish functions that are isolated to the actor whose context in which the value resides. Any uses of an @isolated function will rely on the location in which that value is bound to determine its isolation.

Let's see how this can be helpful for our example. The partial-application self.update will now yield a value of type @isolated (MutableRef) -> (), which can then be converted to @isolated (MutableRef) async -> () by the usual subtyping rules. Now the type checker can then correctly distinguish the two calls when performing Sendable checking on the argument:

extension MyActor {
  func update(_ ref: MutableRef) { /* ... */}
  
  func distinction(_ g: @Sendable (MutableRef) async -> ()) async {
    let f: @isolated (MutableRef) async -> () = self.update
    
    let ref = MutableRef()
    await f(ref) // OK because `@isolated`
    await g(ref) // rejected.
  }
}

Notice that g is bound as a parameter of distinction, which is isolated to the actor instance. That means the @isolated in g's type refers to the actor instance in that method. When checking whether it is safe to pass ref, we now know f is safe and g is not. We could also store an @isolated function in the instance's isolated storage, like this:

actor Responder {
  private var takeAction : @isolated (Request) -> Response

  func denyAll(_ r: Request) -> Response { /* ... */ }

  func changeState() {
    // ...
    takeAction = denyAll
  }
  
  func handle(_ r: Request) -> Response {
    return takeAction(r)
  }

  func handleAll(_ rs: [Request]) -> Response {
    return rs.map(takeAction)
  }
}

Because the declaration takeAction is isolated and itself serves as the location of a value of type @isolated, any values stored there have the same isolation as the declaration. The rules about @isolated are as follows:

  • The isolation of a value of type @isolated matches the isolation of the declaration in which it resides. A value bound to an identifier is said to reside in the context of that binding, otherwise, an expression producing a value resides in the context that evaluates the expression.
  • A function value with @isolated type is produced when a function is used as a first-class value and all of the following apply:
    • The function is isolated to an actor.
    • The isolation of the function matches the evaluation context of the first-class use.
  • An function type that is @isolated is mutually exclusive with the following type attributes:
    • @Sendable
    • any global-actor
  • A function value whose type contains global-actor @G, and appears in G's isolation domain can be cast to or from @isolated.
  • An @isolated type cannot appear on a binding that is not in an isolated context.
  • An @isolated value cannot cross isolation domains without performing that removes the attribute.

Rationale: The purpose of @isolated is to track first-class functions that are isolated to the same actor as the context in which the value appears. It ensures the function has never left the isolation. That is why @Sendable must be mutually-exclusive with @isolated. Once an @isolated function tries to leave the isolation domain, it must lose the @isolated attribute and cannot gain it back.

Going further, @isolated provides the needed capability to share partially-applied methods and isolated closures across actors, when it is safe to do so:

func okSendable(_ y: @Sendable (V) async -> ()) {}
func badSendable(_ x: @Sendable (MutableRef) async -> ()) {}
func asPlainValueFunc(_ y: (V) -> ()) {}
func asPlainMutableRefFunc(_ y: (MutableRef) -> ()) {}

extension MyActor {
  func shareThem(_ takeValue: @isolated (V) -> (), _ changeRef: @isolated (MutableRef) -> ()) {
    okSendable(takeValue)
    badSendable(changeRef) // error: cannot cast '@isolated (MutableRef) -> ()' to '@Sendable (MutableRef) async -> ()' because 'MutableRef' is not Sendable.
    
    asPlainValueFunc(takeValue)
    asPlainMutableRefFunc(changeRef)
  }

  func packageIt(_ changeRef: @isolated (MutableRef) -> ()) -> (@Sendable () async -> ()) {
    let ref = MutableRef()
    let package: @isolated (MutableRef) -> () = {
      changeRef(ref)
    }
    return package
  }
}

Here, we know both takeValue and changeRef are isolated to the actor-instance because of MyActor.shareThem's isolation. In fact, the compiler can determine specifically which instance of that type the functions must be isolated: it's the implicit parameter (_ self: isolated MyActor) currently in scope! The example above shows a number of ways we share first-class isolated functions with contexts that are not isolated to that same actor. Whenever we share an @isolated value with a context that is not isolated to the same actor, the @isolated attribute must be dropped according to a set of rules.

Removing @isolated from a function's type

The primary function of @isolated is to track function values from their origin point, which is a context isolated to some actor, until it reaches a boundary of that isolation. That is when @isolated must be removed. These boundaries are not only cross-actor. A plain function not-otherwise-isolated is considered a boundary:

func process(_ e: Element, _ f: (Element) -> ()) { /* ... */ }

func crunchNumbers(_ a: isolated MyActor, 
                   _ es: [Element],
                   _ cruncher: @isolated (Element) -> ()) {
  for e in es {
    process(e, es, cruncher)
  }
}

Here, because arguments passed to process do not cross actors, there's no need for it to become @Sendable. Since it is not @Sendable, the cruncher does not need any additional conversions and the @isolated can simply be dropped.

Now, had cruncher been async, an additional requirement that the Element conforms to Sendable. The reason is subtle: without specifying the isolation of an async function, it can only be inferred to be nonisolated, which is a distinct isolation domain. Thus, whenever we drop isolation from an async function, we must enforce Sendable conformance for the input and output types, even if the function is not @Sendable. Thus, we have the following rule:

Rule for Dropping @isolated:

  • If the function is async, then its argument and return types must conform to Sendable.
  • Otherwise, the attribute can be dropped.

Once an attempt is made to pass an @isolated method across isolation domains, it must become @Sendable. This follows directly from rules about actor-isolation: whether an argument passed to an actor-isolated function is required to be Sendable depends on the isolation of the context in which the call appears. Since we cannot statically reason about the context in which a @Sendable function will be invoked, we have the following rule:

Rule for Exchanging @isolated for @Sendable:

  • The function becomes async.
  • If the function is isolated to a distributed actor, then it also gains throws.
  • The function's argument and return types must conform to Sendable.

Rationale: For a normal call to a non-async method of an actor from a different isolation domain, you must await as it is implicitly treated as an async call (and throws for distributed actors) because actors are non-blocking. To maintain isolation principles once isolation information is lost, the argument and return types of an actor-isolated function must also conform to Sendable.

Global-actors and function types

For methods that are isolated to a global-actor, partial applications of those methods can more simply state their exact isolation in a way that is independent of the context in which the value resides. A @Sendable partially-applied method that is isolated to a global-actor is not required to be async. Nor is it required to have Sendable input and output types.

But, as discussed in the Motivation, there are still situations where losing or dropping the global-actor isolation from a function's type is useful. In addition, there are scenarios where adding a global actor is unsafe or misleading.

Dropping global actors from function types

When there is a desire to drop a global-actor from a function type, then the same exact rules behind dropping @isolated apply. That is, whenever the function value qualifies for being converted to an @isolated function, and then the rules for dropping or exchanging the @isolated are used.

Adding global actors to function types

On the other side, there are some issues with casts that add a global actor to an async function type. Consider this function, which attempts to run an async function on the MainActor:

@MainActor func callOnMainActor(_ f: @escaping (Data) async -> Data) async -> Data {
  let d = Data()
  let withMainActor: @MainActor (Data) async -> Data = f
  return await withMainActor(d)
}

If Data is not a Sendable type, then the conversion is unsafe and must be prohibited. Otherwise, it would allow non-Sendable values to leave the MainActor into a nonisolated domain. The only safe conversions of async functions that add a global actor are those whose argument and return types are Sendable. But then, that conversion serves no purpose: the executor or underlying actor of an async function cannot be changed through casts due to SE-338.

This proposal aims to clear up this confusion and make it safe by proposing two rules that, when combined with the rest of this proposal, yield an overall safety improvement and simplification of the language:

  1. Casts that add a global actor to an async function type are prohibited.
  2. Given an async function type where its argument and return types are Sendable, writing a global-actor on the type is considered redundant.

Keep in mind that async functions with Sendable argument/return types are still allowed to be isolated to a global actor. The isolation attribute can simply be dropped from its type whenever it is desired. Take this for example:

extension SendableData: Sendable {}

@MainActor func mainActorFetcher() -> SendableData async { /* ... */ }

func processRequests(withFetcher fetchData: @MainActor () async -> SendableData) async {
  //                                        ^~~~~~~~~~
  // proposed change: the global-actor in this type is not required because SendableData is Sendable

  let next = await fetchData()
}

func doWork() async {
  // this cast to drop the MainActor is now allowed by this proposal,
  // and is only explicitly written here for clarity.
  let obtainer: () async -> SendableData = mainActorFetcher
  await processRequests(withFetcher: obtainer)
}

Swift will suggest removing the @MainActor in the type of the withFetcher parameter, since its argument and return types are Sendable. Any function such as mainActorFetcher passed to it will be implicitly cast to drop its @MainActor, despite not being in a context isolated to that actor (i.e., @isolated does not apply).

9 Likes

I wish for @Sendable inference on methods of Sendable types all the time — it seems like a bug without it.

Does it need to be pitched together with @isolated function types?

Yes, because all actor types conform to Sendable, but their methods cannot be made @Sendable without the other components of this proposal. Specifically, this:

2 Likes

I’ll probably have further thoughts on this pitch when it’s had some time to sink in, but I do have some feelings about @isolated. It feels to me quite unlike other type attributes/modifiers that we have today which are all, AFAICT, roughly semantically independent from their enclosing context. E.g., the meaning of @escaping doesn’t really change depending on where it’s used, but the same doesn’t go for @isolated because it’s meaning is intentionally dependent on the implicit self parameter.

I wonder if it would be worth using a spelling that made this explicit, perhaps something like @isolated(self). This would also potentially allow generalization to, say, other actor parameters, so you could do something like:

func f(_ a: SomeActor, _ g: @isolated(a) () - Void) async {
  await a.takesIsolatedFunc(g)
}

Another reason a slightly more explicit spelling here might be good is that as it stands, @isolated on its own is a plausible spelling for the currently-underscored @_inheritActorContext attribute. I’m not sure if there’s an expectation that this attribute eventually make its way into first-class Swift, and I realize that currently (IIRC) it’s written as an attribute on the parameter rather than the type, but they have IMO somewhat similar meanings (roughly, “ensure some function matches the isolation of some actor instance”) so I think it would be good to try to distinguish them.

1 Like

You're right, in fact, @isolated is a simple dependent type and I do not think Swift has those. In this pitch, I tried to define the attribute in such a way that users do not need to think about the value the type refers to, since it should be possible to infer it in all cases, but after thinking about it....

Yes, I think this should be spelled out. At least in its initial implementation. It does help make things more clear in all cases, whether the isolated parameter is explicit or not. By design, the actor-instance to which the function is isolated should always be "in scope" or otherwise available whenever the value of that type is accessed, but the names for those instances will not always match. To make this concrete, first consider this case:

actor Responder {
  // note that this says `self`
  private var takeAction : @isolated(self) (Request) -> Response
}

func changeAction(_ r: isolated Responder, 
                  _ f: @isolated(r) (Request) -> Response) {
  // compiler must unify `@isolated(r)` and `@isolated(self)`
  r.takeAction = f
}

If we mentally model the expression that writes to takeAction as happening via a function such as:

/// r.takeAction = f   <== same as ==>  _set_takeAction(r, f)
func _set_takeAction(_ self: isolated Request, 
                     _ newValue: @isolated(self) (Request) -> Response) {
  self.takeAction = newValue
}

Then the unification of @isolated(self) and @isolated(r) is simple: we determine r == self using standard variable substitution rules in Swift for arguments and parameters. This does not mean I only intend for @isolated to appear in the type of a parameter; I just find it helpful to think of it that way for stored properties, since access to any value with that type must happen in the scope of some isolated instance.

Returning an @isolated value should work without much trouble either. Consider this example:

func getAction(_ t: isolated Responder) 
               -> (@isolated(t) (Request) -> Response) {
  return t.takeAction
}

func getAndCall(_ q: isolated Responder) {
  // can determine whether q == t by the signature of `getAction` alone.
  let g: @isolated(q) (Request) -> Response = getAction(q)
  g(Request())
}

Since there can only be one isolated parameter for a function, we can determine from the argument position and type signature alone that the function returned by getAction will be isolated to the q we passed to it. Thus, we can prove g has the type that is written on it through inference.

For these simple and very common cases, we should be able to do unification in the type checker. That's important, because we need to determine the types prior to generating SIL. Consider this situation where unification is not possible:

func tougherSituations(_ r: isolated Responder, 
                       _ f: @isolated(r) (Request) -> Response) {
  let tricky: Responder
  if { 
    tricky = r
  } else {
    tricky = Responder()
  }

  // is this an async function?
  let actionFunc = tricky.takeAction
  actionFunc(Request())
}

What is the type of actionFunc? Well, that depends on whether tricky is always equal to r. If they are not equal, then the rules for dropping @isolated apply and actionFunc is an async function, etc. Otherwise, it is a non-async function with @isolated(r) in its type. We can see by inspection that tricky is not always equal to r, so the type checker will be correct when failing to unify the types. But what about a simple case like the following?

func harmlessLet(_ r: isolated Responder) {
  let s = r
  
  // inferred type: @isolated(r) (Request) -> Response
  let fromR = r.takeAction

  // should have same type as fromR, since s == r.
  let fromS = s.takeAction
}

While we could have the type-checker do a simplistic search to prove that s == r, I don't currently think that capability is needed to justify its added complexity in the type checker. Today, we already don't account for simple rebindings when doing actor-isolation checking for actor instances:

class NonSendableType { var x: Int = 0 }

actor ValueProtector {
  var val = NonSendableType()

  func existingLimits() { 
    let alt = self

    _ = self.val  // OK
    _ = alt.val // error: actor-isolated property 'val' can not be referenced on a non-isolated actor instance
  }
}

Thus, I believe we can implement this very simplistic dependent type @isolated(...) purely in the type checker, without much complexity in comparison to the utility it provides. For example, it provides the ability to write a type for a partially-applied, actor-instance isolated async method.


As of today that feature is called @_unsafeInheritExecutor and it's on my list of things I'd like to make an official part of Swift. There are some surrounding aspects of Sendability that needs to be worked out, so I deferred it for a separate proposal.

For an attribute on a declaration, I think @isolated would not make sense to describe @_unsafeInheritExecutor, since I think we need users to understand it's isolation comes from its caller (so the term "inherits" is something that would make sense). But, to describe the type of the @ _unsafeInheritExecutor function, say taking and returning (), perhaps we could use a plain, non-specific @isolated () async -> (). The non-specific @isolated could embody the notion that it's isolation is unspecified because its inherited. If that seems plausible, then that provides more motivation for specifying the value (or global-actor) an @isolated type depends on.

2 Likes