@_inheritActorContext does not inherit the actor context in certain situations

Hello. In the code below, I'm using @_inheritActorContext . In this code, the presence or absence of _ = self results in different Executors and Queues within the Block. The same issue occurs even when using Task.init instead of foo_2(_:) .

Is this a bug?

import Foundation

@_silgen_name("swift_task_isOnExecutor")
public func _taskIsOnExecutor<Executor: SerialExecutor>(_ executor: Executor) -> Bool



final class MySerialExecutor: SerialExecutor {
    private let queue: DispatchQueue = .init(label: "Hello")
    
    func enqueue(_ job: consuming ExecutorJob) {
        let job: UnownedJob = .init(job)
        
        queue.async {
            job.runSynchronously(on: self.asUnownedSerialExecutor())
        }
    }
    
    func isSameExclusiveExecutionContext(other: MySerialExecutor) -> Bool {
        queue == other.queue
    }
}



actor MyActor {
    private let executor: MySerialExecutor = .init()
    nonisolated var unownedExecutor: UnownedSerialExecutor {
        executor.asUnownedSerialExecutor()
    }
    
    func foo_1() async {
        let executor: any SerialExecutor = self.executor
        //        await Task {
        await foo_2 {
            // ??? 
            _ = self
            
            // true or false, depending on whether `_ = self` is commented out or not
            print(_taskIsOnExecutor(executor))
            
            let libdispatch: UnsafeMutableRawPointer = dlopen("/usr/lib/system/introspection/libdispatch.dylib", RTLD_NOW)!
            let symbol: UnsafeMutableRawPointer = dlsym(libdispatch, "dispatch_get_current_queue")!
            
            typealias Function = @convention(c) () -> dispatch_queue_t
            
            let dispatch_get_current_queue: Function = unsafeBitCast(symbol, to: Function.self)
            let queue: DispatchQueue = dispatch_get_current_queue()
            
            // "Hello" or "com.apple.root.default-qos.cooperative", depending on whether `_ = self` is commented out or not
            print(queue.label)
            
        }
        //        }.value
    }
    
    private func foo_2(@_inheritActorContext _ block: @Sendable () async -> Void) async {
        await block()
    }
}



@main
struct MyScript {
    static func main() async {
        let myActor: MyActor = .init()
        await myActor.foo_1()
    }
}

I'm not familiar with executors but is capturing the executor here required for your setup?

let executor: any SerialExecutor = self.executor

Note that if you remove that line, the custom executor is used, despite the presence of _ = self

Capturing the executor is not related to @_inheritActorContext behaving unexpectedly. I just captured the executor to call _taskIsOnExecutor.

I am talking about the custom executor not being triggered when _ = self is not present.

import Foundation

final class MySerialExecutor: SerialExecutor {
    private let queue: DispatchQueue = .init(label: "Hello")
    
    func enqueue(_ job: consuming ExecutorJob) {
        let job: UnownedJob = .init(job)
        
        queue.async {
            job.runSynchronously(on: self.asUnownedSerialExecutor())
        }
    }
    
    func isSameExclusiveExecutionContext(other: MySerialExecutor) -> Bool {
        queue == other.queue
    }
}



actor MyActor {
    private let executor: MySerialExecutor = .init()
    nonisolated var unownedExecutor: UnownedSerialExecutor {
        executor.asUnownedSerialExecutor()
    }
    
    func foo_1() async {
        await foo_2 {
//            _ = self
            
            let libdispatch: UnsafeMutableRawPointer = dlopen("/usr/lib/system/introspection/libdispatch.dylib", RTLD_NOW)!
            let symbol: UnsafeMutableRawPointer = dlsym(libdispatch, "dispatch_get_current_queue")!
            
            typealias Function = @convention(c) () -> dispatch_queue_t
            
            let dispatch_get_current_queue: Function = unsafeBitCast(symbol, to: Function.self)
            let queue: DispatchQueue = dispatch_get_current_queue()
            
            // "com.apple.root.default-qos.cooperative", expected "Hello"
            print(queue.label)
            
        }
    }
    
    private func foo_2(@_inheritActorContext _ block: @Sendable () async -> Void) async {
        await block()
    }
}



@main
struct MyScript {
    static func main() async {
        let myActor: MyActor = .init()
        await myActor.foo_1()
    }
}
import Foundation

final class MySerialExecutor: SerialExecutor {
    private let queue: DispatchQueue = .init(label: "Hello")
    
    func enqueue(_ job: consuming ExecutorJob) {
        let job: UnownedJob = .init(job)
        
        queue.async {
            job.runSynchronously(on: self.asUnownedSerialExecutor())
        }
    }
    
    func isSameExclusiveExecutionContext(other: MySerialExecutor) -> Bool {
        queue == other.queue
    }
}



actor MyActor {
    private let executor: MySerialExecutor = .init()
    nonisolated var unownedExecutor: UnownedSerialExecutor {
        executor.asUnownedSerialExecutor()
    }
    
    func foo_1() async {
        await Task {
//            _ = self
            
            let libdispatch: UnsafeMutableRawPointer = dlopen("/usr/lib/system/introspection/libdispatch.dylib", RTLD_NOW)!
            let symbol: UnsafeMutableRawPointer = dlsym(libdispatch, "dispatch_get_current_queue")!
            
            typealias Function = @convention(c) () -> dispatch_queue_t
            
            let dispatch_get_current_queue: Function = unsafeBitCast(symbol, to: Function.self)
            let queue: DispatchQueue = dispatch_get_current_queue()
            
            // "com.apple.root.default-qos.cooperative", expected "Hello"
            print(queue.label)
            
        }.value
    }
}



@main
struct MyScript {
    static func main() async {
        let myActor: MyActor = .init()
        await myActor.foo_1()
    }
}

There's quite a lot going on here, so let me point out a few things:


Yeah the closing over the self actually matters -- only then does the "inherit actor context" happen. To be honest this is very subtle and I'm sure people just had no idea about this until executor assertions appeared.

Before that people had no real way to "notice"; since if you're not using self you're not going to observe self really... But yeah, it is somewhat confusing.

We should clarify this in the language documentation or change to something less confusing if we don't like this. I'm on the fence tbh.

cc @hborla since I think you looked into this recently.


Relatedly, please don't call internal runtime APIs: _taskIsOnExecutor. These methods may start to crash rather than return boolean values, there's no promises made about their exact behavior -- they're underscored for a reason.

There's official APIs for this: swift-evolution/proposals/0392-custom-actor-executors.md at main · apple/swift-evolution · GitHub

Using the official APIs would be just: executor.assertIsolated() or actor.assertIsolated()

But yeah, I get that you perhaps wanted to poke at the runtime to debug this particular issue. I did want to warn against doing so in general though.


By the way:

final class MySerialExecutor: SerialExecutor {
    private let queue: DispatchQueue = .init(label: "Hello")

on Apple platforms you can just:

actor MyActor {
    let queue: DispatchSerialQueue = ...

    nonisolated var unownedExecutor: UnownedSerialExecutor {
        queue.asUnownedSerialExecutor()
    }

the conformance has not arrived on corelibs dispatch though...


@_inheritActorContext doesn't actually do what people think it does... Avoid touching it when you can. It doesn't actually "inherit" syntactically etc, but instead just prevents any actor hops from happening... this can yield correctness issues.

~~A proper replacement is arriving in the form of this proposal: https://github.com/apple/swift-evolution/pull/2273~~

Note: I confused that we're talking about @_unsafeInheritExecutor, more details below. The @_inheritActorContext is ok.


The executor impl is slightly wrong, as the isSameExclusiveExecutionContext will never be used, because you didn't opt your executor into complex equality:

You must do so by returning UnownedSerialExecutor(complexEquality:) from func asUnownedSerialExecutor().

We document this here: init(complexEquality:) | Apple Developer Documentation though arguably we should explicitly say on the isSameExclusiveExecutionContext that you must do this...

I'll follow up with a docs improvement PR on this.

Do note however that Dispatch's implementation is more advanced than that -- on apple platforms it can check if the queues are targeting the same queue etc.


Hope this helps,

3 Likes

Just to clarify, @_inheritActorContext does not have this problem; you're thinking of @_unsafeInheritExecutor. @_inheritActorContext is just an isolation inference tool for closures passed to parameters that have @_inheritActorContext on the parameter declaration, and it does not eliminate hops inside the closure when the inference is applied.

2 Likes

Oh whoops, misread which one we're talking about -- thanks for correcting @hborla !

// Edited post for clarity