Let's say we have an Obj-C method taking a completion handler parameter:
@interface TestProcessor: NSObject
- (void) doProcessing: (void(^)(void)) completionHandler;
@end
The implementation of the method calls the completion handler on an arbitrary thread, simulated by the following implementation:
@implementation TestProcessor
- (void)doProcessing:(nonnull void (^)(void))completionHandler {
dispatch_async(dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{
completionHandler();
});
}
@end
Now, let's write some Swift code that uses the above function, in a class that for some reason (such as a protocol conformance) is MainActor
isolated, such as:
import UIKit
@MainActor var counter = 0
@MainActor let delegate = MyDelegate()
@main
class MyDelegate: UIResponder, UIApplicationDelegate {
override init() {
super.init()
doProcessorSync() // <--- crashes during this call
Task {
await doProcessorAsync()
}
}
func doProcessorSync() {
let processor = TestProcessor()
processor.doProcessing {
// crash is actually here
counter += 1
}
}
func doProcessorAsync() async {
let processor = TestProcessor()
await processor.doProcessing() // <--- no crash here
counter += 1
}
}
For the purposes of this example, I've written out the code to call both translations of the Obj-C function: "synchronous" and "asynchronous".
In this example, doProcessorAsync
executes without error, as does doProcessorSync
in Swift 5 mode. However, in Swift 6 mode only, doProcessorSync
crashes at a run-time sanity check because the code is (obviously) not running on the main thread, and the completion handler's closure was not Sendable
and was therefore inferred to be MainActor
isolated.
What's the actual explanation of this disparity, in both the historical and the semantic sense? For example:
- Is this a bug in the Obj-C code, because that code's author failed to consider that Swift might be expecting the completion handler to really be isolated already? Or is it a bug in the Swift code? Or is it a bug in Swift 6? Or is it a bug in Swift 5, now fixed?
- Did the introduction of concurrency, way back, introduce the inferred
MainActor
isolation, thus changing the meaning of Obj-C translations of callbacks that were essentially just synchronous calls back in pre-concurrency days? Or is that inference something that was added to Swift more recently? - What were the intended semantics of callback closure parameters for translated Obj-C imports? Were they always intended to exhibit isolation behavior? Did that change over historical time?
- Are there some further compile or run time checks that the Swift compiler could have in this area, but doesn't for some pragmatic reason?
- And so on.
Clearly, the author of the closure can avoid the problem by ensuring that the closure is Sendable
, because that disables the MainActor
inference on the closure. In that case, the closure just runs on the whatever thread it was called, and is safe if the closure doesn't depend or mess with assumptions about the caller's thread.
By comparison, in Swift 5 mode, the closure executes as-is but unsafely on the caller's thread, without the crash. That is, as far as the Swift compiler knew, the closure should have been called on the main thread, but it doesn't check, and so the closure may run by luck without any bad effects.
Anyone got any comments or perspective on this?