import Dispatch
actor Example {
final class DispatchQueueExecutor: SerialExecutor {
private let queue: DispatchQueue
init(queue: DispatchQueue) {
self.queue = queue
}
func enqueue(_ job: UnownedJob) {
self.queue.async {
job.runSynchronously(on: self.asUnownedSerialExecutor())
}
}
func asUnownedSerialExecutor() -> UnownedSerialExecutor {
UnownedSerialExecutor(ordinary: self)
}
}
private let executor: DispatchQueueExecutor
init(queue: DispatchQueue) {
self.executor = DispatchQueueExecutor(queue: queue)
}
func bridge() {
print("Sadly, I never get my moment.")
}
nonisolated var unownedExecutor: UnownedSerialExecutor {
executor.asUnownedSerialExecutor()
}
}
let queue = DispatchQueue(label: "Example")
let example = Example(queue: queue)
queue.sync {
example.assumeIsolated {
$0.bridge()
}
}
Fatal error: Incorrect actor executor assumption; Expected same executor as Example.
lldb confirms that it is in fact running in the correct Dispatch queue when assumeIsolated is invoked (and that, if I instead spawn up an intermediary Task and await into the actor that way, that the actor is using the given Dispatch queue for its execution).
Implementing isSameExclusiveExecutionContext makes no difference (whether or not I change it to use UnownedSerialExecutor(complexEquality:)).
This seems like it can only be a bug?
Is there a workaround, to allow me to efficiently (and synchronously) invoke the actor's methods from inside the Dispatch queue?
I'm using Swift 6 (swiftlang-6.0.0.3.300 clang-1600.0.20.10).
Just got to the same crash today. As far as I understand from @ktosopitch and its subsequent proposal, this issue will be solved by implementing checkIsolated(). However that function seems to only be used by runtimes from macOS 15+. Is this right @ktoso ? Any way to use assumeIsolated(_:) in OSes lower than 15?
My current executor looks similar to @wadetregaskis with slight differences since I need to support macOS 14:
public import Dispatch
public extension DispatchQueue {
final class Executor: SerialExecutor {
public let queue: DispatchQueue
public init(label: ReverseDNS, qos: DispatchQoS, autoreleaseFrequency: AutoreleaseFrequency = .inherit) {
self.queue = DispatchQueue(label: label.rawValue, qos: qos, attributes: [], autoreleaseFrequency: autoreleaseFrequency, target: nil)
}
}
}
public extension DispatchQueue.Executor {
@inlinable func enqueue(_ job: consuming ExecutorJob) {
let unownedJob = UnownedJob(consume job)
let unownedSerialExecutor = self.asUnownedSerialExecutor()
#if compiler(>=6.0)
if #available(macOS 15, *) {
let unownedTaskExecutor = self.asUnownedTaskExecutor()
return queue.async {
unownedJob.runSynchronously(isolatedTo: unownedSerialExecutor, taskExecutor: unownedTaskExecutor)
}
}
#else
queue.async {
unownedJob.runSynchronously(on: unownedSerialExecutor)
}
#endif
}
@inlinable func asUnownedSerialExecutor() -> UnownedSerialExecutor {
UnownedSerialExecutor(ordinary: self)
}
}
#if compiler(>=6.0)
@available(macOS 15, *)
extension DispatchQueue.Executor: TaskExecutor {
@inlinable public func asUnownedTaskExecutor() -> UnownedTaskExecutor {
UnownedTaskExecutor(ordinary: self)
}
@inlinable public func checkIsolated() {
dispatchPrecondition(condition: .onQueue(queue))
}
}
#endif
Is there any way to backdeploy TaskExecutors as well?
actor Foo {
let queue = DispatchSerialQueue(label: "My Foo")
nonisolated var unownedExecutor: UnownedSerialExecutor {
queue.asUnownedSerialExecutor()
}
private nonisolated func assumeIsolatedHack<T>(
_ block: (isolated Foo) throws -> T
) rethrows -> T where T: Sendable {
// Before Swift 6, dispatch didn't work well with `Actor.assumeIsolated`.
// It can report false negatives - where you are actually on the correct queue but 'assumeIsolated'
// doesn't know it. That was fixed in SE-0424:
// https://github.com/swiftlang/swift-evolution/blob/main/proposals/0424-custom-isolation-checking-for-serialexecutor.md
//
// This feature requires a new version of the Swift runtime, so we need to perform an OS version check
// and on older systems we need an ugly hack to emulate the new 'assumeIsolated' for Dispatch.
if #available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, *) {
return try self.assumeIsolated(block)
} else {
dispatchPrecondition(condition: .onQueue(self.queue))
return try withoutActuallyEscaping(block) {
let isolationStripped = unsafeBitCast($0, to: ((Foo) throws -> T).self)
return try isolationStripped(self)
}
}
}
}
Then you can use actors for Obj-C delegates who always get messages posted on the queue you configure them with.
I was aware of the assumeIsolatedHack(_:). In my codebase, I have it as an extension from Actor called unsafeIsolated(_:). However, this produces the following warning in Xcode 16 betas for macOS 14 (and lower):
warning: data race detected: actor-isolated function at MyFile.swift:N was not called on the same actor
The problem is not the warning per se, which I can simply ignore (although it doesn't look good). The problem is that (if my understanding is correct from reading the evolution proposals), the Swift runtime dynamically tracks the current executor of a running task in thread local storage. Meaning that dispatching something directly to the queue doesn't forward the actor info. You can easily replicate by running the following test in macOS 14, Xcode 16 and using my previous DispatchQueue.Executor helper.
actor CustomActor {
let executor = DispatchQueue.Executor(label: "myQueue", qos: .default)
@inlinable nonisolated var unownedExecutor: UnownedSerialExecutor {
executor.asUnownedSerialExecutor()
}
}
internal import XCTest
final class FileSystemTests: XCTestCase {
func testWarning() async throws {
let actor = CustomActor()
actor.executor.queue.async {
// Entering this closure will produce a warning (and not forward actor info)
actor.unsafeIsolated { _ in
print("This has been unsafely isolated")
}
// Entering this closure will crash your program
actor.assumeIsolated { _ in
print("This has been assumed isolated")
}
}
try await Task.sleep(for: .seconds(1))
}
}
@ktoso, is there any way for me to unsafely forward the actor information for the "unsafely isolated" context?
I wonder if Xcode should really be enabling that warning if it produces different results on macOS 14 vs 15.
Yeah this would be good. I think the current situation could really use some adjustment prior to 6.0 final, if that's still possible.
Users will try to isolate data using actors and dispatch queues, which in theory is compatible with the Apple SDK practice of registering delegate objects and queues to call them on. Because delegate methods have to be callable from Objective-C with their existing signatures, they have to be formally nonisolated and we dynamically check isolation using assertIsolated in the body. This is not too difficult to grasp and not an extremely uncommon use-case, I don't think.
So developers will do that, test their code on iOS 18 or macOS 15, and everything will work. Then they'll ship it and it'll crash for customers on older OSes. It won't make intuitive sense, and they won't be able to reproduce it until they notice that it's OS version-dependent. Even when they do, they'll see in the debugger that they're on the correct queue, but assumeIsolated is giving an unexpected result, and I bet a lot of them will blame themselves and feel that they just don't understand Swift Concurrency.
Even if they get past that hurdle and identify assumeIsolated as the culprit, there's no good answer right now for what they should do about it. There's the unsafeBitCast trick I used, but it's a very valid point that some of the runtime bookkeeping may be in an unexpected state because of that. But if that is no longer an option, I don't think developers will have any choice but to forgo actors with dispatch executors for these use-cases entirely until they can drop support for all but the not-even-released-yet round of Apple OS releases.
If it is truly not feasible to back-port the current behaviour, an unsafeAssumeIsolated method to replace the unsafeBitCast (and update the runtime bookkeeping) should be looked at as a high-priority addition, IMO. We could combine that with our own versions of the executor 'complex equality' checks to effectively write our own back-ports for the executors we use.
I think the warning is correct and should be kept. The runtime is detecting no actor information and it is letting us know. It is a pity that Xcode 15 doesn't have this behavior as well, though.
That is a very good point that I didn't foresee. I can easily imagine people wasting hours/days trying to figure this out.
Looking at the runtime implementation, even the new assumeIsolated behaviour doesn't seem to actually set anything. And actually, I recall from when these functions were proposed it was very clear that the runtime won't use this information to make scheduling decisions.
So the unsafeBitCast trick seems safe to me.
(And since this is only for when we run on an existing OS, we don't need to worry that the runtime code might change. When we run on a newer OS, we won't need to do any of this)
Still, it's not great that developers will encounter this issue at all, and I still think it's worth packaging up the unsafeBitCast thing for them in a back-deployed API so they can at least work around the issue with a bit more peace of mind (and some documentation reminding them to check for isolation manually).
You wouldn't expect _taskIsCurrentExecutor to modify any current executor bookkeeping, but we can take a look at its implementation just to be sure:
Abbreviating, here's the part we care about:
if (!current) {
// We have no current executor, i.e. we are running "outside" of Swift
// Concurrency. We could still be running on a thread/queue owned by
// the expected executor however, so we need to try a bit harder before
// we fail.
<snip>
// We cannot use 'complexEquality' as it requires two executor instances,
// and we do not have a 'current' executor here.
// Otherwise, as last resort, let the expected executor check using
// external means, as it may "know" this thread is managed by it etc.
if (isCurrentExecutorMode == Swift6_UseCheckIsolated_AllowCrash) {
swift_task_checkIsolated(expectedExecutor); // will crash if not same context
// checkIsolated did not crash, so we are on the right executor, after all!
return true;
}
swift_task_checkIsolated just calls in to your protocol witness - so if your implementation says the isolation is correct, _taskIsCurrentExecutor returns true, and assumeIsolated performs the bit-cast. No modifying of runtime-internal data AFAICT.
The 5.9 implementation didn't modify anything, either. The difference is quite clear - when we don't have a current executor tracked, the old implementation had one hard-coded check for the main thread but lacks the new checkIsolated fallback. But it had its own same-executor hook (requires 2 executors, so unsuitable for this problem) and it worked basically the same way.
@ktoso can say for sure, but to me it looks like a plain unsafe bit-cast is sufficient as a back-deployable fallback.
Thank you for taking the time spelunking in swiftlang codebase and pointing out the targeted places, @Karl . I've learned a lot and your analysis seems correct to me. I'd love to hear a bit more input from @ktoso if possible.
If you know for sure both actors are on a specific dispatch queue, you can write the dispatchPrecondition yourself -- this is what DispatchSerialQueue does inside its checkIsolation and do the cast.
Please be VERY careful with this and only do the unsafe cast trick when you're ABSOLUTELY sure (right after the dispatchPrecondition and you're sure that all involved queues are the same due to that).
Note that it's impossible to write an API like "am I on the right dispatch queue -> true/false", all APIs will either return true or crash. Even ones in Dispatch which look like they would return false never will. (There's some ancient hidden APIs there you might find, but they will never return false)
The problem is that checkIsolation() is only called if you are running in macOS 15+. If I have an actor with a custom executor implementing checkIsolation() running in macOS 14, the program will crash.
Well, yeah. I'm not sure if that's a reply to my post or to what? Yes, this feature requires the new runtime and building against new SDK as we said earlier.
I was confirming the "horrible cast" can be done if you are absolutely certain the isolation is correct due to some other means/checks, because checkIsolated is only a recent OS feature -- that's why I mention manually calling dispatchPrecondition and then casting.
There might have been a bit of a misunderstanding. First, thank you for taking the time to answer. I do appreciate it.
To establish some common points:
We are aware of the existence of dispatchPrecondition.
We are aware that there are no APIs to check whether "we are on the right dispatch queue".
We are aware of "unsafe cast" and that we must be absolutely certain before using it (casting in general should never be done if possible).
We are aware that checkIsolated is only a recent OS feature and it seems to be impossible to backdeploy.
The topic of this whole post was to figure out why assumeIsolated was crashing with custom executors, and once that we figured that out, is to see whether there is any way to jump from a DispatchQueue to an actor in macOS 14- without "asyncing it" (i.e. using Task) and without producing a warning every single time we use the unsafe casting (e.g. my unsafeIsolated(_:) function).
Ok this helps understand the question. So you're asking about the runtime warning, this was a bit hidden in all those messages here tbh and I missed it
That's a runtime warning that is gone in Swift 6 (well, in Concurrency library shipping in those OSes that contain Swift 6+), so in before Swift 6 which you care about here... I believe you can disable it with the env var SWIFT_UNEXPECTED_EXECUTOR_LOG_LEVEL=0:
Here's some notes about it:
/// Logging level for unexpected executors:
/// 0 - no logging -- will be IGNORED when Swift6 mode of isCurrentExecutor is used
/// 1 - warn on each instance -- will be IGNORED when Swift6 mode of isCurrentExecutor is used
/// 2 - fatal error
///
/// NOTE: The default behavior on Apple platforms depends on the SDK version
/// an application was linked to. Since Swift 6 the default is to crash,
/// and the logging behavior is no longer available.
It's worth documenting some more perhaps in the migration guide, would you mind filing an issue about it or contributing a section? GitHub - apple/swift-migration-guide
All this is a very "have the cake and eat it too" situation to be honest. The entire reason this warning happens is because the "old" runtime cannot detect the queues properly - because as you now know, there's no APIs to do so.
The fix for this detecting is checkIsolated basically and turning off the runtime warning into strict checks which are entirely correct.
In other words, the same functionality that was powering the warning is what we've improved in 6.0, so if you're going to do 6.0 assume... style APIs, the previous runtime just has no idea about how that's correct and to the best of its knowledge is warning you about it.
For development I think it's fine to try to disable that logging mode, but be super careful with the assumptions, and then try to move to 6.0 runtimes as soon as you can.
I wasn't aware of the existence of this. Thank you. If I understand correctly this will be project-wide (or at least SPM module-wide), which I am uneasy setting since there are more developers working in the codebase with "less concurrency knowledge". I can always make a very small target/module with the custom executor code, I guess.
I would love to just upgrade the requirements and force users to only use the Swift 6.0 runtime (i.e. macOS 15+). We would have mass-riots if we do that
Yeah, I understand and applaud the new development It is great and I can't wait to use it. Sadly, it seems, it will be quite some years before we can use checkIsolated.