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 callingfn
directly. I couldn't make this work because I don't know how to create an instance ofUnownedJob
.- 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.)
- Based on my understanding of Swift's runtime code,
- I tried overriding methods such as
SerialExecutor.checkIsolated
. These methods aren't being called by the Swift runtime. - I tried using both
DispatchQueue.sync
andDispatchQueue.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
}
}