Skip to content

Instantly share code, notes, and snippets.

@ktoso
Last active September 28, 2023 05:32
Show Gist options
  • Save ktoso/bfd9b61274903c9c466932411c1f6697 to your computer and use it in GitHub Desktop.
Save ktoso/bfd9b61274903c9c466932411c1f6697 to your computer and use it in GitHub Desktop.
isolation-notes.md

Isolation

Notes

that closure will inherits the isolation of the surrounding context by default. Often this is desirable, but if it isn't, there's no way to turn it off2.

Great to acknowlage that! Yes detach is too big of a gun to solve this.


Third, there is new special default argument expression, #isolation, which expands to the static actor isolation of the caller. This can be used for any parameter, but when the parameter is specifically declared isolated, this has the effect of implicitly propagating the static isolation of the caller to the callee.

extension Collection {  func sequentialMap<R>(isolated isolation: (any Actor)? = #isolation,                        transform: (Element) async -> R) async -> [R] {

Distributed complicates this a bit... the simplest solution for today might be to allow

extension Collection {  func sequentialMap<R>(isolated isolation: (any DistributedActor)? = #isolation,                        transform: (Element) async -> R) async -> [R] {

since AnyActor is a marker protocol we can't use it to express isolation... unless we'd like to re-consider that for Swift 6 and then make AnyActor a real protocol in which case we could:

extension Collection {  func sequentialMap<R>(isolated isolation: (any AnyActor)? = #isolation,                        transform: (Element) async -> R) async -> [R] {

I forget why we didn't make it a real protocol to begin with though... I'm sure there was a good reason that perhaps we have to stick to still.


The existing type-checking rule for isolated parameters can be summarized as "the type must be convertible to any Actor; the new rule can be summarized as "the type must be convertible to (any Actor)?.

If spelling out a formal rule we should acknowlage DistributedActor I guess here. Or consider the AnyActor change mentioned above.

So it'd become:

... the new rule can be summarized as "the type must be convertible to (any Actor)? or (any DistributedActor)?".

Unless we change AnyActor from:

@_marker
@available(SwiftStdlib 5.1, *)
public protocol AnyActor: AnyObject, Sendable {}

into:

@available(SwiftStdlib 5.1, *)
public protocol AnyActor: AnyObject, Sendable {
  // yes, both Actor and DistributedActor have the same requirement like this, so we "lift" this requirement
  nonisolated var unownedExecutor: UnownedSerialExecutor { get }
}

  • references to let bindings formed from the value (e.g. if let a = optA or guard let a = optA). For example, if a function is isolated to optA: A?, then the method call optA?.run() is known to not cross an isolation boundary.

This seems to imply that this would work? But I'm not sure I'm not reading too much into this sentence?

actor D {
  func work() {}
}
func daDoRunRunRunDaDooRunRun(worker: isolated Worker?) async {
  guard let worker else { return }
  // assume isolated!
  worker.work()
  worker.work()
}

We propose that global actors be semantically limited to be singleton.

Absolutely agree on that! In original proposals I thought we implied that it should be the same actor underlying but I guess we have not strictly required so...?


This permits Swift to assume that functions isolated to a non-optional value of a global actor type are actually isolated to the global actor exactly as if they were annotated with the attribute.

Yes, that's good and what I'm after for isolating Task.init's closure to a global actor right away...

We'd combine this with the inherit feature to be able to express–what we're unable to express today–in the form of:

@MainActor func hi() {}

Task(on: MainActor.shared) { 
  hi() // no await
}

// where the init is:
init<Act: Actor>(
  on actor: isolated Act, 
  // ...
  operation: __owned @inheritActorContext @Sendable @escaping () async -> Success)

Note that the operation closure is @Sendable.


The grammar of a closure expression's capture list is modified to allow the isolated keyword:

capture-list-item → capture-specifiers identifier  capture-list-item → capture-specifiers identifier = expression  capture-list-item → capture-specifiers self-expression capture-specifiers → 'isolated'? capture-strength-specifier?

Missing 'nonisolated'? here?


#isolated

This is awesome and the "what actor is calling me" I've longed for for tracing surprisingly!

This really helps us in distributed-tracing in two ways:

  • so the withSpan's closure can be isolated to the calling actor
  • we'll finally get the ability to log which actor is making a call, this is huge (!)
func withSpan(..., 
              isolation: (any Actor)? = #isolation,
             _ operation: (any Span) async throws -> T) {
  if let actor = isolation, 
     let identifiable as? Identifiable {
    span["actor.id"] = "\(identifiable.id)"
  }
}

and since the isolation paramter isn't isolated


@inheritsActorIsolation (important)

If the corresponding argument to a parameter with @inheritsActorIsolation in a direct use of the declaration is a closure expression that does not include an isolation specification, then the static isolation of the closure is the same as the static isolation of the calling context unless the calling context is isolated to a value of a non-global-actor type that is either not captured or captured weak by the closure (in which case the closure is not isolated). (This rule is the same as is used by the Task initializer.)

I think this is all good, could use an example perhaps to add to the text:

actor Mailer {
  func mail() {
    Task {} // existing init; no parameters -> infer from static isolation of calling context
  }
}

and the new semantic:

Task(on: Mailer()) { ... isolated to that mailer ... }

where we'd declare it as:

init(
    on actor: isolated some Actor,
    // ... 
    operation _operation: __owned @inheritActorContext  @Sendable @escaping () async -> Success)

right?

We could combine it with the #isolation actually I guess, to end up with:

init(
    on actor: isolated (some Actor)? = #isolation,
    // ... 
    operation _operation: __owned @inheritActorContext  @Sendable @escaping () async -> Success)

which gives exactly the existing Task{} semantics but allows overriding it by passing an actor...

That looks very good!


Future? Promoting to isolation thanks to unwrapping optional isolated parameter

Task { [weak self] in 
  // not isolated... we don't know if self is .some() or .none
  guard let self = self else { return }
  // assume `isolated self` from here onwards 
}

This is the inverse of what happens in initializers where the isolation deteriorates -- Kavon implemented that logic in initializer safety work.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment