Good morning everyone,
I'm starting this thread to collect some input about an opt-in behavior change when using NonisolatedNonsendingByDefault in objc to async swift func bridging I'm thinking about introducing.
We have attempted to introduce this behavior change without opt-in, however it may cause slight unexpected differences in behaviors, so we're now considering staging it in with an UpcomingLanguageFeature.
Previously
The behavior previously was:
When obj-c code calls an async swift function, it appears as a function with a callback.
- Swift then starts a new
Task {}to call into theasyncSwift function, - and eventually the completion handler is invoked:
// Objective-C interface with completion handler
@interface MyService : NSObject
- (void)compute:(NSInteger)x
completionHandler:(void (^ _Nonnull)(NSInteger))handler;
@end
// Swift subclass overrides using async
class SwiftService: MyService {
// func is either explicitly nonisolated(nonsending),
// or implicitly from NonisolatedNonsendingByDefault
override nonisolated(nonsending) func compute(_ x: Int) async -> Int {
// objc callback method implemented with Swift `async`
print("main thread? \(Thread.isMainThread)") // main thread? false
return x + 1
}
}
// E.g. call from ObjC, from the MainThread/Actor
SwiftService *service = [[SwiftService alloc] init];
[service compute:128 completionHandler:^(NSInteger result) {
NSLog(@"result: %ld", (long)result);
}];
Calling the async Swift method is effectively done like this:
// Simplified bridging thunk (generated by the compiler)
Task { // always runs on global executor (unless executor preference active)
let result = await swiftSelf.compute(x)
completionHandler(result)
}
New opt-in behavior
The new behavior, which I'm proposing to gate under -enable-upcoming-feature ObjCAsyncBridgeImmediateTask would be:
- Swift then starts a new
Task.immediate {}to call into theasyncSwift function, - and eventually the completion handler is invoked:
// Simplified bridging thunk (generated by the compiler)
Task.immediate {
let result = await swiftSelf.compute(x)
completionHandler(result)
}
The change to Task.immediate may mean that the observed threading is now different, we stay on the calling thread, and avoid scheduling the Task{} on the global concurrency pool unless necessary:
// assume we call compute from the main thread in obj-c:
class SwiftService: MyService {
// func is either explicitly nonisolated(nonsending),
// or implicitly from NonisolatedNonsendingByDefault
override nonisolated(nonsending) func compute(_ x: Int) async -> Int { // no specific isolation
print("main thread? \(Thread.isMainThread)") // main thread? true
// since Task.immediate runs synchronously on the caller
return x + 1
}
}
There is no change in behavior if the function/code was actually isolated to something:
class MainSwiftService: MyService {
@MainActor
override func compute(_ x: Int) async -> Int { // always on MainActor
}
}
actor SomeOtherActor: MyService {
override func compute(_ x: Int) async -> Int { // always on SomeOtherActor
}
}
So the the difference is that when possible we avoid scheduling the task on the global executor, and therefore can avoid un-necessary executor hopping and decrease scheduling delays for calling such functions. The exact scheduling semantics are explained in in the Task.immediate proposal.
We'd like to collect input about this behavior, and I'm proposing to introduce this with the ObjCAsyncBridgeImmediateTask upcoming feature flag, and disabled by default to allow modules to opt into it.
This is already implemented here on main, though awaiting input before merging.