Is Task {…} in a @MainActor method guaranteed to run in the next run loop cycle?

When setting breakpoints, it’s pretty clear that running this code on Apple platforms causes the code in the Task to run in the next run loop cycle:

@MainActor func test() {
    print("1")
    Task {
        print("2")
    }
}

My question is: Is this guaranteed or is there a possibility that the Task might start running in the same run loop cycle? I’m pretty sure that’s just the way it works due to how it’s implemented and we can rely on this behavior, but I haven’t seen it written down anywhere so far.

In other words: Is this code above a usable replacement for DispatchQueue.main.async {…}, which is often used to defer something to the next run loop cycle.

I’ve seen discussions that are kind of related to this question (see links below), but they don’t address this question directly.

I reckon it is, but because async never guaranteed which cycle it'd run in either (to my knowledge).

Most uses of these two patterns probably don't care, anyway - they just mean to say "run this once I'm done, without too much delay" (when the caller is already on the main thread) or "run this soon on the main thread" (when the caller is not).

Officially the only way to do precisely what you're asking is to use -[NSRunLoop performSelector:target:argument:order:modes:] - it's the only method documented to do what you want. Even -[NSRunLoop performBlock:] isn't (although I'd be very surprised if it weren't implemented the same way as its older sibling).

Keep in mind runloop modes - i.e. what "the next runloop" means isn't well-defined. I'm guessing none of these methods, that don't explicitly take the mode(s) as a parameter, even try to run in event-tracking modes.

1 Like

From an implementation perspective I suspect runloops do essentially:

while true {
    let tasks: [Task]

    withSomeLock {
        tasks = self.readyToRunTasks
        self.readyToRunTasks = []
    }

    for task in tasks {
        task.run()
    }
}

Otherwise, a single run of a runloop might never finish if some task always schedules another task (e.g. endless recursive loops of DispatchQueue.main.async), and the program would essentially hang (at least in part).

I was doubting that I remember it correct, but in my memory relying on DispatchQueue.main.async has never had any guarantees on execution. It just happened that in most cases it worked out OK. Yet relying on that behaviour almost always has been a sign for me that probably this better to be implemented in a different way at all.

With new concurrency and Tasks I would prefer to avoid it even more, since there are much less guarantees that it would execute on a correct thread and easier to introduce a bug if eventually it will be created not on main actor. I mean, with DispatchQueue you at least has clear notion that it executed on main by just looking at the code, while with tasks it might be implicit.

The specific caveat with DispatchQueue.main.async that trips people up particularly on macOS is that it only works[1] in the default runloop modes, and it only works in the outermost runloop. iOS doesn't typically use nested runloops so this is less of an issue there.


  1. processes that use dispatch_main() are a different story since they don't have a main thread or a main runloop ↩︎

2 Likes
  • -[NSRunLoop performSelector:target:argument:order:modes:] is not (as of macOS 14.3.1) implemented as the documentation describes (with a timer). In my testing, it uses a .beforeTimers CFRunLoopObserver.

  • -[NSRunLoop performBlock:] calls -[NSRunLoop performInModes:block:], which calls CFRunLoopPerformBlock. Blocks passed to CFRunLoopPerformBlock are executed by a dedicated mechanism (__CFRunLoopDoBlocks) that is invoked up to three separate times at different points in a single pass through the run loop.

  • See objective c - How do you schedule a block to run on the next run loop iteration? - Stack Overflow for my analysis of what the run loop does and how to schedule something to run “on the next run loop iteration”. Although I wrote it 11 years ago, I believe it is still accurate.

3 Likes

I would like to know how nested Runloops work. I seem to know pretty well what a RunLoop is, but how many times do I hear about nested Runloops, but I don't understand how it works, and most importantly, why is it necessary? Please explain, if it doesn't bother you.

If I recall correctly (it's been a long while since I really fiddled with runloops directly) there's two aspects potentially at play:

  1. You can create your own runloops and execute them however you wish. Typically this is done when you create separate threads explicitly (e.g. with NSThread) where they don't implicitly have & run a runloop for you. However, you can do it (technically) pretty much anywhere, including while executing out of an existing runloop.

  2. You can execute a runloop multiple times in a semi-recursive fashion. Typically this is done during certain user interactions - e.g. drag & drop - where you want to track a certain subset of events at the exclusion of [most] others. So you might take the existing runloop and manually run it in a specific, non-default mode, such as eventTracking, with run(mode:before:) or friends.

The reasons for doing this vary, as alluded to, but generally are about control - by creating your own runloop and/or controlling which modes it executes in, you can reduce interference from other tasks. Although in practice it's hard to actually control what runs where, because it's easy for arbitrary code deep in some library to grab the current runloop and muck with it.

Pertaining to the original question in this thread, the main take-away from this little side foray is that you should never assume the current runloop is the main runloop, even if you believe you're on the main thread.

2 Likes

You might want to worry about the current run loop mode, but you don't generally have to worry about which run loop you're in, if you know what thread you're on. If you know you're on the main thread, the current run loop is always the one returned by CFRunLoopGetMain. If you know you're not on the main thread, the current run loop is never the one returned by CFRunLoopGetMain.

From the CFRunLoopGetCurrent documentation:

Each thread has exactly one run loop associated with it.

In general, a thread's run loop is created on demand the first time that thread calls CFRunLoopGetCurrent, with the exception of the main run loop, which will be created the first time any thread calls either CFRunLoopGetCurrent or CFRunLoopGetMain. Each thread stores its run loop in thread-local storage. Here's the code. ‘TSD’ is Thread-Specific Data.

The only run loops that are easily accessible are the current thread's (via CFRunLoopGetCurrent) and the main thread's (via CFRunLoopGetMain), but the only public functions for running a run loop (CFRunLoopRun and CFRunLoopRunInMode) don't take a run loop argument; internally they both call CFRunLoopGetCurrent and run what it returns. So there is no (public) way to run the ‘wrong’ run loop on a thread.

In my experience, it's very rare to see a background thread running a run loop. Core Foundation will run a run loop on a background thread if you use the CFReadStreamSetDispatchQueue or CFWriteStreamSetDispatchQueue functions. Here's the code.

4 Likes

Thank you all so much for these thoughtful answers – there’s a lot of good information to be found here!

Apologies for not being clearer in my original question and being a bit hand-wavey in what it means to “defer something to the next run loop cycle”. I did not mean to go into the ins and outs of run loops and their modes and whether DispatchQueue.main.async {…} is the right way to do that.

What I really want to know probably ties into run loops because of the underlying implementation (at least on Apple platforms?), but it feels to me like it should be answerable in a platform-agnostic way without mentioning run loops. I should not have mentioned them at all.

So I’ll try again, building on the code in my original question a bit more:

@MainActor func test() {
    print("before task")
    Task { // inherits @MainActor
        print("in task")
    }
    print("after task")
}

My question is: Does Swift guarantee that this code will:

  • always produce this output:
    before task
    after task
    in task
    
  • and never this:
    before task
    in task
    after task
    

Hmmm, so in your specific example yes, but only via a rather brittle specific way this function is written.

Because there is no suspension point here this function test() will run to completion until anything else can run on it. If you had ANY await in this function, the order would not be guaranteed anymore.

I was answering effectively the same question over here a day ago or so: Task: Is order of task execution deterministic? - #60 by jmjauer which you might find useful

edited: misread your example

2 Likes

Thanks for the confirmation! test() not being async really is part of my question (also that it’s @MainActor and kicks off a @MainActor-isolated Task). So I think I have my answer :+1:

Also, nice to see that there is progress in Swift to getting more control over actor isolation.


All that being said, I do understand that deferring work to “later” like in my sample code is not a pattern that should be used without good reason. In my experience, it’s usually workarounds for things we can’t change and that nobody likes to do, but that are sometimes necessary nonetheless.

But that thread-local variable can be changed. There's nothing in CoreFoundation that seems to, but there is _CFRunLoopSetCurrent which does it, and I suspect that's called by Foundation.

In any case, perhaps I'm remembering SPI. What I said earlier was based on a vague memory from years ago while at Apple, where we were creating a custom runloop (for reasons I forget) and we had some issues with other random code ending up scheduling events on it, because it happened to be the current runloop when that code got invoked.

Maybe I'm misremembering - I'm fairly sure it wasn't merely a runloop mode issue - but in any case as you point out there's clearly no public API to actually [directly] create a new runloop, nor set the 'current' one for any thread.

It’s (exclusively, as far as I can tell), for Carbon in non-preemptive-threading environments. An interesting historical relic.

2 Likes