Discussion: Opt-in obj-c to Swift async bridging with Task.immediate

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 the async Swift 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 the async Swift 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.

11 Likes

I'm a bit confused as to how this would actually change the existing execution semantics. If the bridging thunk is effectively this:

Task { // hop to concurrent executor
    let result = await swiftSelf.compute(x) // hop to... wherever the callee should run?
    completionHandler(result)
}

Then shouldn't the call to compute() in this construction require execution on the concurrent executor[1]? Or does the existing bridging code actually already have something like "effective nonisolated(nonsending) semantics" in the case that the callee is non-isolated?

On another matter: given that Task.immediate is a relatively recent API, how would this change/feature work for applications that deploy to OS versions that predate the introduction of Task.immediate?


  1. assuming no executor preferences, and no NonisolatedNonsendingByDefault ↩︎

This seems like similar enough behavior to NonisolatedNonsendingByDefault, such that I wonder if this behavior should just be folded into that feature flag?

3 Likes

Sorry I wasn't clear in the opening post, this is specifically when NonisolatedNonsendingByDefault is adopted, or when code explicitly has written nonisolated(nonsending) but really the surprising behavior change happens for users writing apps with NonisolatedNonsendingByDefault and assuming things about threads.

As not-annotated not-isolated async methods then become nonisolated(nonsending) and inherit the caller context, we may see this change then.

I've updated the post for clarity

1 Like

I don't think this is really the same as the nothing-to-do-with-objc NonisolatedNonsendingByDefault language feature, it probably should be separate.

Part of the reasons I'm bringing this up as new option is because when this became the new default behavior, we've seen applications using NonisolatedNonsendingByDefault blow up with wrong thread expectations, so it feels like we have to stage this in somehow.

2 Likes

I’m 100% in favor of this change. We recently started adopting nonisolated(nonsending) widely in our codebase, and the discovery that it basically just doesn’t work for @objc functions was very disappointing. It means that for now, I basically have to ban any @objc nonisolated(nonsending) functions. This proposal would change that.

Details about our use case: we have a lot of existing Core Data code that passes around subclasses of NSManagedObject. These objects are not sendable; in general they must not leave their originating thread. When async was first introduced (but before the nonsending behavior was available), this meant that we basically could not write any async functions on these managed objects, and we could not pass them as parameters to any async function. This significantly limited the utility of async functions in our codebase.

With nonisolated(nonsending), we can now safely use async functions with managed objects. However, the current behavior of @objc functions is still dangerous; if we have an @objc nonisolated(nonsending) async function and call it from ObjC, it will hop to the global executor, which means that ObjC cannot pass managed objects to such a function.

I’m curious under what scenarios this would not be the desired behavior. It is a change in behavior, of course, but to me this seems more like a bug fix.

1 Like

Thank you for clarifying. If this change is only to apply to bridging calls to swift functions that are nonisolated(nonsending), then as Harlan suggested, shouldn't it (ideally) apply unconditionally in such circumstances? Otherwise it seems like the calls from the objc side are kind of broken currently since the "logical" caller execution context isn't actually inherited.[1]

Just to clarify – here you're talking about making your proposed change the new default behavior, correct?

That's a bit unfortunate, but I suppose unsurprising. Out of curiosity, can you share an example of the type of code for which making this change unconditionally would be problematic? Or are the snippets shared earlier reasonably characteristic examples if we just replace the print(Thread.isMainThread) with precondition(!Thread.isMainThread)?[2]


On the general question – the proposed change makes sense to me since it seems like the current way things behave undermines the desired language semantics.


  1. Though perhaps not technically – not sure if it was ever explicitly specified how that interaction is supposed to work. ↩︎

  2. I.e. the code effectively enforces some invariant that it should not be called on a particular executor, and the current (arguably incorrect) bridging logic is load-bearing in upholding that when objc callers in fact do call it from that particular executor. ↩︎

1 Like

I just found a place where this affects pure-Swift code:

class Attachment {}
class Photo: Attachment {}

extension Attachment {
  @objc nonisolated(nonsending)
  func doThing() async {}
}


extension Photo {
  @objc nonisolated(nonsending)
  func doThing() async {
    // This is called on the global executor,
    // not on the caller's executor (in this
    // example, the main actor)
  }
}

@MainActor
func test() {
  let photo = Photo()
  await photo.doThing()
}

The doThing() functions are declared in extensions. If I take off the @objc annotations, I get this:

Error: Non-'@objc' instance method 'doThing()' is declared in extension of 'Attachment' and cannot be overridden

The fix-it from the compiler suggests adding @objc to make it compile. But adding @objc causes nonisolated(nonsending) to have no effect. (Really, it should have told me to move the implementation into the main class, but that’s a separate issue.)

It seems to me that without the proposed change, it should be illegal to have @objc and nonisolated(nonsending) on the same function, because @objc completely nullifies nonisolated(nonsending).

3 Likes

Yeah… I would say the same, and thus initially “just did it”. Sadly I’ve discovered that in the wild people were having expectations about not being on the main actor while suddenly they more often were.

Thank you for chiming in, that’s the feedback that’s useful to hear!