When to use `Task.yield()` instead of `Task.sleep(...)`

I've been experimenting with using NIO's NIOSingletons.unsafeTryInstallSingletonPosixEventLoopGroupAsConcurrencyGlobalExecutor() in my Vapor app.

I have a (detached) top-level Task that I need to run in the background (a mDNS service broadcast) side by side with the Vapor app. This mDNS code is looping and uses synchronous file descriptor functions but I call await Task.yield() every 0.1 seconds or so. I thought this would work, but after installing the NIO custom executor, this Task seems to prevent my Vapor app from handling any requests at all. I switch out await Task.yield() with try await Task.sleep(...), and the Vapor app starts responding again.

So my question is, when should I be using Task.yield() if ever? Should I always use Task.sleep(...) since it seems to be the only way to actually force the Task to yield?

Per the docs:

If this task is the highest-priority task in the system, the executor immediately resumes execution of the same task. As such, this method isn’t necessarily a way to avoid resource starvation.

It's not really clear what "the system" is here, but it could conceivably be the current isolation domain (e.g. a specific actor), or the current process.

sleep, in contrast, simply guarantees to sleep for the given duration. While asleep, it will of course permit lower-priority tasks to run. That's the key difference.

It's not really clear to me, either, what the purpose of yield is. Swift Concurrency tries to avoid priority inversions to begin with, so in principle there should be no need to explicitly yield in order to allow a higher-priority task to progress.

It could be used to cycle between equal-priority tasks, I suppose. Perhaps that's useful if you a mix of I/O-centric and compute-centric tasks, where it's beneficial to overall throughput or latency if the compute-centric tasks yield frequently to the I/O tasks (in order to keep I/O moving along, at little expense in terms of compute time).

So the NIOSingletons.unsafeTryInstallSingletonPosixEventLoopGroupAsConcurrencyGlobalExecutor is very crude and unsafe for general use, as the name implies. It takes over ALL task enqueues of all of swift concurrency.

That means that your "background" things are not really background, everything is on the ELG.

As for yield... a tight loop with yield is still a tight loop -- you'll likely resume immediately. Yield can make sense when there's e.g. some "long running synchronous code" and let's say there's N of those, and you have N threads in the global concurrency pool -- if none of those ever yielded, nothing else in all of swift concurrency's global pool would ever get to run.

Yield is a crude tool to "at least try to give someone a chance to run by suspending myself and enqueueing myself right away". It doesn't really guarantee anything about what else will get to run.

The sleep at least creates an artificial delay, so yeah it's more likely to "help" in the situation you described.

4 Likes

To further elaborate on Konrad's great explanation, NIO's POSIX executor (the one you're using) does not have a notion of task priority. We don't attempt to reorder tasks based on priority in any way: if you're runnable, we'll run you.

A more useful goal might be to provide a NIO-aware integration of mDNS. What library are you using for the goal?

2 Likes

It's a very WIP and thus private implementation I made that converts a lot of GitHub - mjansson/mdns: Public domain mDNS/DNS-SD library in C into Swift. Needed an implementation without dependencies so I could make it work the same way on both Linux and macOS...

1 Like

So the easiest way to get this to work would be to hide the code in question in a dispatch queue or actor with custom executor.