Using a custom GlobalActor: "actor-isolated function was not called on the same actor"

My goal: Run some methods on a non-main thread and have the Swift compiler and runtime catch data races.

My solution: Create a GlobalActor/@globalActor (called AudioManagementActor) with a custom SerialExecutor which synchronizes jobs using a DispatchQueue.

My problem: At runtime, when running my program on my iPad, I see warnings such as the following:

warning: data race detected: actor-isolated function at concurrencytest/concurrencytestApp.swift:7 was not called on the same actor

(See the bottom of this post for the program's source code.)

My question: I assume that the program does not have a data race. How do I tell the runtime to not emit these false 'data race detected' messages?

My hypothesis: The Swift concurrency runtime does not know that the callback in audioManagementQueue.sync is called from the correct thread.

Things I tried:

  • I tried calling UnownedJob.runSynchronously instead of calling fn directly. I couldn't make this work because I don't know how to create an instance of UnownedJob.
    • Based on my understanding of Swift's runtime code, UnownedJob.runSynchronously is how I am meant to enter an executor context. (I don't know the correct terminology here, but hopefully you understand what I mean.)
  • I tried overriding methods such as SerialExecutor.checkIsolated. These methods aren't being called by the Swift runtime.
  • I tried using both DispatchQueue.sync and DispatchQueue.async. Both cause the same warning.
  • I ran the program on the iOS Simulator. I didn't see the warning there.

Tools used:

  • Xcode 16.2 (16C5032a)
  • Swift 6 language mode
  • iOS 17.6 build target
  • iPadOS 17.7.5 run target (6th generation iPad)

Test program:

import SwiftUI

@main
struct Main {
    static func main() {
        // Same warning occurs with .sync or .async.
        AudioManagementActor.async {
            _ = AudioManager()
        }
        
        Thread.sleep(forTimeInterval: 10)
    }
}

final class DispatchQueueExecutor: SerialExecutor {
    private let queue: DispatchQueue
    
    init(_ queue: DispatchQueue) {
        self.queue = queue
    }
    
    public func enqueue(_ job: UnownedJob) {
        self.queue.async {
            job.runSynchronously(on: self.asUnownedSerialExecutor())
        }
    }
    
    public func asUnownedSerialExecutor() -> UnownedSerialExecutor {
        UnownedSerialExecutor(ordinary: self)
    }
    
    public func checkIsolated() {
        dispatchPrecondition(condition: .onQueue(self.queue))
    }
}

let audioManagementQueue: DispatchQueue = DispatchQueue(label: "audioManagementQueue", qos: DispatchQoS.userInteractive)

@globalActor
actor AudioManagementActor: GlobalActor {
    typealias ActorType = AudioManagementActor
    static var shared: AudioManagementActor = AudioManagementActor()
    
    nonisolated let executor: any SerialExecutor = DispatchQueueExecutor(audioManagementQueue)
    nonisolated var unownedExecutor: UnownedSerialExecutor {
      self.executor.asUnownedSerialExecutor()
    }
    
    public static func async(_ work: @escaping @Sendable @AudioManagementActor () -> Void) {
        audioManagementQueue.async {
            callUnsafe(work)
        }
    }
    
    public static func sync(_ work: @escaping @AudioManagementActor () -> Void) -> Void {
        audioManagementQueue.sync {
            callUnsafe(work)
        }
    }
    
    // Cast work from (@AudioManagementActor () -> Void) to (() -> Void) so we can call it.
    private static func callUnsafe(_ fn: @escaping @AudioManagementActor () -> Void) -> Void {
        // https://github.com/swiftlang/swift/blob/0b2c160740a80fbb77f9eaa1db67cf53deaf2b04/stdlib/public/Concurrency/MainActor.swift#L143-L148
        return withoutActuallyEscaping(fn) {(_ fn: @escaping @AudioManagementActor () -> Void) -> Void in
            var rawFn: () -> Void = unsafeBitCast(fn, to: (() -> Void).self)
            
            // This call causes the following warning:
            // warning: data race detected: actor-isolated function at concurrencytest/concurrencytestApp.swift:6 was not called on the same actor
            return rawFn()
        }
    }
}

@AudioManagementActor
class AudioManager {
    var foo: Bool
    
    init() {
        self.foo = true
    }
}

The checkIsolated implementation generally should work, but it is only available on macOS from the version 15 and iOS from the version 18:

@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
func checkIsolated()

In your particular case, however, you should be able to just replace your own SerialExecutor implementation with DispatchSerialQueue which already implements that protocol:

...
let audioManagementQueue = DispatchSerialQueue(label: "audioManagementQueue", qos: .userInteractive)
...
  nonisolated let executor: any SerialExecutor = audioManagementQueue

The runtime has a specialised check for DispatchSerialQueues: swift/stdlib/public/Concurrency/DispatchGlobalExecutor.cpp at 012ac5da5d6e43e3461beb8bad46a5d57836dc96 · swiftlang/swift · GitHub

checkIsolated can only be used if on the aproppriate runtime version and compiler -- so yeah it is iOS 18, and it seems you're below that. You need to update to have it work as expected.

There's further improvements to these checks coming soon: [Pitch][SerialExecutor] Improved Custom SerialExecutor isolation checking but they'll also require a new runtime since we need it to be able to detect and invoke these new methods.

Ah, okay. That makes sense. Unfortunately, I cannot use iOS 18 yet; I'm stuck on iOS 17 for now.

Cool! I actually did try this, but my app was targeting iOS 16 at the time and DispatchSerialQueue is only available since iOS 17. Thanks for the reminder.

Unfortunately, even if I target iOS 17, DispatchSerialQueue doesn't seem to work. I still see the same warning ("warning: data race detected: actor-isolated function at concurrencytest/concurrencytestApp.swift:7 was not called on the same actor") when setting AudioManagementActor.executor to audioManagementQueue directly. Here's the updated code:

import SwiftUI

@main
struct Main {
    static func main() {
        // Same warning occurs with .sync or .async.
        AudioManagementActor.async {
            _ = AudioManager()
        }
        
        Thread.sleep(forTimeInterval: 10)
    }
}

let audioManagementQueue: DispatchSerialQueue = DispatchSerialQueue(label: "audioManagementQueue", qos: DispatchQoS.userInteractive)

@globalActor
actor AudioManagementActor: GlobalActor {
    typealias ActorType = AudioManagementActor
    static var shared: AudioManagementActor = AudioManagementActor()
    
    nonisolated let executor: any SerialExecutor = audioManagementQueue
    nonisolated var unownedExecutor: UnownedSerialExecutor {
      self.executor.asUnownedSerialExecutor()
    }
    
    public static func async(_ work: @escaping @Sendable @AudioManagementActor () -> Void) {
        audioManagementQueue.async {
            callUnsafe(work)
        }
    }
    
    public static func sync(_ work: @escaping @AudioManagementActor () -> Void) -> Void {
        audioManagementQueue.sync {
            callUnsafe(work)
        }
    }
    
    // Cast work from (@AudioManagementActor () -> Void) to (() -> Void) so we can call it.
    private static func callUnsafe(_ fn: @escaping @AudioManagementActor () -> Void) -> Void {
        // https://github.com/swiftlang/swift/blob/0b2c160740a80fbb77f9eaa1db67cf53deaf2b04/stdlib/public/Concurrency/MainActor.swift#L143-L148
        return withoutActuallyEscaping(fn) {(_ fn: @escaping @AudioManagementActor () -> Void) -> Void in
            var rawFn: () -> Void = unsafeBitCast(fn, to: (() -> Void).self)
            
            // This call causes the following warning:
            // warning: data race detected: actor-isolated function at concurrencytest/concurrencytestApp.swift:6 was not called on the same actor
            return rawFn()
        }
    }
}

@AudioManagementActor
class AudioManager {
    var foo: Bool
    
    init() {
        self.foo = true
    }
}

Would your proposal fix my spurious warnings? My understanding of that proposal is that it would improve the message of existing warnings and allow code to issue new warnings, but it would not silence the warning that I'm seeing in my program.

Ah, yes, I tried to run your code on a simulator and see the warning, too. I suppose it will still require checkIsolated with your usage pattern. Is using the queue API to add work a requirement? Generally, your AudioManager class should now be isolated to your global actor and, whenever you try to access its members, will require an async context to perform them on the actor's serial executor (unless you are already in the actor's context).

You can also enqueue arbitrary tasks on your executor using Task { @AudioManagementActor in ... } syntax (or using task groups for structured concurrency, with the same closure syntax).

For example, if you replace your main with a code like so, this should give you the desired concurrency checks:

static func main() async {
  let manager = await AudioManager()
  print(await manager.foo)

  // Or enqueue some work on your audioManagementQueue.
  Task { @AudioManagementActor in
    print(manager.foo) // no need to await.
    dispatchPrecondition(condition: .onQueue(audioManagementQueue)) // will not fail.
  }
}
1 Like

checkIsolated already fixes detecting those situations ("on a queue but no on an executor; but it is the same queue that is used for executor of some actor"), so yes, this just further refines the error messages.

1 Like

Ah, this is what I needed! I didn't know about this syntax. Thank you. What is this syntax called? Is it documented somewhere?

No, I only need some way to call code in the AudioManagementActor context. I don't care about using DispatchQueue's methods specifically. The Task { @AudioManagementActor in ... } method works well for what I need.

Hmm, I did notice this in some documentation. I find this behavior of methods switching between sync and async weird.

I don't have any experience using async in Swift. I guess it should learn! :sweat_smile:

Ah, this is what I needed! I didn't know about this syntax. Thank you. What is this syntax called? Is it documented somewhere?

I don't know of a good resource to read about these things, unfortunately, maybe someone else can recommend a comprehensive documentation, but until then I can suggest reading Swift Evolution documents. In particular, the one about global actors (you can find the mention of this syntax in the "Closures" section):
https://github.com/swiftlang/swift-evolution/blob/main/proposals/0316-global-actors.md.
Other Swift Evolution proposals related to actors and concurrency are also worth reading, although they aren't always up-to-date with respect to the actual implementation and may be hard to follow as they aren't a general purpose documentation.

No, I only need some way to call code in the AudioManagementActor context. I don't care about using DispatchQueue's methods specifically. The Task { @AudioManagementActor in ... } method works well for what I need.

To further clarify, you only need to use this syntax if you want to place a batch of work on your executor without tying it to a particular class/function, or if you are not in async context. Your other option is to mark a class/func with the same attribute and it will always execute in the context of your global actor, e.g.:

@AudioManagementActor
func willExecuteOnTheAudioManagementQueue() {
  dispatchPrecondition(condition: .onQueue(audioManagementQueue))
}

// ... elsewhere, outside of the AudioManagementActor context.
await willExecuteOnTheAudioManagementQueue()

In the same fashion, all methods and getters of your AudioManager class are already executed on the serial executor of the AudioManagementActor actor.

Hmm, I did notice this in some documentation. I find this behaviour of methods switching between sync and async weird.

The way actor isolation works is by having all code that touches its state to be executed serially on exact same executor. This is why await is needed when you are calling an actor-isolated method or getter from outside of this actor's isolation context—as this isn't a direct call, it enqueues a job on the actor's queue and awaits its completion.

This is also the reason await is not needed if you are already in the actor's isolation context, since that means you are already executing on the actor's executor and it is safe to perform a direct call.

I don't have any experience using async in Swift. I guess it should learn! :sweat_smile:

It is worth mentioning that async functions are a separate concept, orthogonal to actor isolation, and—I found—some details of their interaction often confuse people. One particular example would be the actor re-entrancy on async function suspension, for example:


@AudioManagementActor
class AudioManager {
  var counter: Int = 0

  func modifyCounter() async throws -> Int {
    counter += 1
    try await Task.sleep(for: .milliseconds(100))
    return counter
  }
}

@main
struct Main {
  static func main() async throws {
    let manager = AudioManager()

    async let counter1 = manager.modifyCounter()
    async let counter2 = manager.modifyCounter()

    try await print((counter1, counter2))
  }
}

Will print (2, 2) meaning both invocations have observed the new state of counter after they resume following the Task.sleep.

You probably will not encounter this if all you need is simple concurrency checking, but worth keeping this in mind.