Why can I assign @isolated(any) closure to non isolated closure?

I'm struggling to understand how @isolated(any) works in some cases.

var getString: @isolated(any) @Sendable () -> String

I understood (but correct me if I'm wrong) that this attribute tells that the function needs to be awaited since it's isolated to any actor and therefore I'd need to call it from an async function.

Now let's consider this example

struct Foo: Sendable {
  var getString: @Sendable () -> String
  
  init(getString: @escaping @Sendable () -> String) {
    self.getString = getString
  }
  
  init(getStringIsolated: @isolated(any) @escaping @Sendable () -> String) {
    getString = getStringIsolated
  }
}

@MainActor
var globalString = "hello"

func test() {
  let f1 = Foo(getString: {
    // Main actor-isolated var 'globalString' can not be referenced from a nonisolated context
    globalString
  })
  
  let f2 = Foo(getString: { @MainActor in // Converting function value of type '@MainActor @Sendable () -> String' to '@Sendable () -> String' loses global actor 'MainActor'
    globalString
  })
  
  let f3 = Foo(getStringIsolated: { @MainActor in
    globalString
  })
  let result = f3.getString() // OK

  let f4 = Foo(getStringIsolated: {
    "empty"
  })
}
  1. Why in f3 can I pass an isolated closure in the init(getStringIsolated:) even if the property is not isolated itself?
  2. Why f4 works even if I'm not isolating the function at all? does it mean that @isolated(any) stands for "Any isolation or non-isolated at all"
  3. Moreover if I change getString to var getString: @isolated(any) @Sendable () -> String, I can still have both initializers
1 Like

Not sure if this was reported yet, but I don't think assigning an @isolated(any) to a closure without @isolated(any) should be allowed. If you run this in Xcode with TSAN, it will report a race condition:

struct Foo: Sendable {
    var op: @Sendable () -> Void
    
    init(_ op: @isolated(any) @escaping @Sendable () -> Void) {
        self.op = op
    }
}

var bar = 0

let foo = Foo { @MainActor in
    bar += 1
}

DispatchQueue.concurrentPerform(iterations: 10000) { _ in
    foo.op()
}

Also, if you do this:

let f3 = Foo(getStringIsolated: { @MainActor in
  MainActor.assertIsolated()
  return globalString
})
let result = f3.getString() // OK

... it will assert, if f3.getString() is called from another thread, which is allowed, as you've demonstrated.

For f4, yes, @isolated(any) can cover every case of isolation.

However, I don't think f3 should be valid, the proposal says

This means that the call must be await ed even if the function is synchronous

1 Like

i concur with the other assessments here – specifically case f3 is supposed to be an invalid function conversion per the evolution doc for @isolated(any). in particular it should fall under the third bullet point of the section explaining legal function conversions, which should require that the type being assigned to in the conversion be an async function type.

i encourage you to file a bug when you have a moment.

3 Likes