Swift Concurrency and Objective-C interop when using perform selector or RunLoop

I was working on some Swift code and converting a function with a completion handler to use Swift Concurrency. This function is called from Objective-C. The new function imports into Objective-C with a completion handler as expected. But the old code is doing some performSelector stuff.

class Worker: NSObject {
    @objc static func work() async {
        try? await Task.sleep(nanoseconds: NSEC_PER_SEC)
    }

    @objc static func oldWork(completion: @Sendable @escaping () -> Void) {
        DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) {
            completion()
        }
    }
}

- (void)viewDidLoad {
    [super viewDidLoad];
    [self startWorkAfterDeadline];
    [self startOldWorkAfterDeadline];
}

- (void)startWorkAfterDeadline
{
    NSLog(@"startWorkAfterDeadline");
    [self stopPlannedWork];
    [self performSelector:@selector(doWork) withObject:nil afterDelay:0.5];
}

- (void)doWork
{
    NSLog(@"doWork outside");
    [Worker workWithCompletionHandler:^{
        NSLog(@"doWork inside completion");
        [self startWorkAfterDeadline];
    }];
}

- (void)stopPlannedWork
{
    NSLog(@"stopPlannedWork");
    [[self class] cancelPreviousPerformRequestsWithTarget:self selector:@selector(doWork) object:nil];
}

- (void)startOldWorkAfterDeadline
{
    NSLog(@"startOldWorkAfterDeadline");
    [self stopPlannedOldWork];
    [self performSelector:@selector(doOldWork) withObject:nil afterDelay:0.5];
}

- (void)doOldWork
{
    NSLog(@"doOldWork outside");
    [Worker oldWorkWithCompletion:^{
        NSLog(@"doOldWork inside completion");
        [self startOldWorkAfterDeadline];
    }];
}

- (void)stopPlannedOldWork
{
    NSLog(@"stopPlannedOldWork");
    [[self class] cancelPreviousPerformRequestsWithTarget:self selector:@selector(doOldWork) object:nil];
}

Output:

startWorkAfterDeadline
stopPlannedWork
startOldWorkAfterDeadline
stopPlannedOldWork
doWork outside
doOldWork outside
doOldWork inside completion
startOldWorkAfterDeadline
doWork inside completion
stopPlannedOldWork
startWorkAfterDeadline
stopPlannedWork
doOldWork outside
doOldWork inside completion
startOldWorkAfterDeadline
stopPlannedOldWork
doOldWork outside
doOldWork inside completion
…

As the async code is running on the cooperative thread pool this doesn't work with the perform selector and the RunLoop.

I didn’t see anything about this problem before and only found one comment in the Apple Developer forums:

I think you’ve misunderstood what the warning is telling you. You can’t mix run loop based code and Swift concurrency code willy-nilly. A run loop is an event source dispatching mechanism. When you run it, it expects to block the calling thread for arbitrary amounts of time. If you call it from an async function, the calling thread is one of the threads from Swift concurrency’s cooperative thread pool. Blocking such a thread for an arbitrary amount of time is a very bad idea.
How to adapt RunLoop to swift conc… | Apple Developer Forums

If I don’t have access to the source code (e.g a third party library) and only see the generated completion handler of an async function exposed to Objective-C how do I differentiate that to an old Swift function with a normal completion handler? Or is there another way to know, if the code may run on the cooperative thread pool? Or is using perform selector in Objective-C considered generally unsafe when interoperating with Swift?

  1. Are or will there be any warnings for mixing Swift Concurrency with perform selector?
  2. Is this documented anywhere? From previous WWDC sessions I know that certain primitives are unsafe, but I wasn’t aware about this before

There is no RunLoop for Swift Concurrency stuff. So the time is not accumulating and won’t ever fire. You would need to add a RunLoop yourself and make sure it runs.

I’m not it’s worth the frustration to attempt to shoe horn Swift Concurrency into that setup. Maybe wait until you’re ready to build a completely nee worker in Swift.

Yes, I already removed Swift Concurrency as it's the easiest way to fix this as long as we have this legacy Objective-C code there. But I'm just wondering how to handle it from the Objective-C side in case I have some completion handler based code from a 3rd party framework, where I don't know if the implementation may use Swift Concurrency under the hood or not.

Unless documented by the API, you cannot make any assumption about the context you're being called back on. Dispatch back to the main queue (or to the queue/thread that runs the run loop you need).

1 Like

Within a Swift Concurrency context, you can call a legacy threading api with a completion handler using the Swift CheckedContinuation functions.

There are several other variations including one that can throw errors.

This Swift API essentially exists specifically for the classic Obj-C completion handler APIs.

If your legacy completion handler API kicks off a GCD block it may also solve the RunLoop issue, as all GCD queues have a RunLoop.