Skip to content

Commit f6dfd35

Browse files
adamwclaude
andauthored
Add cede() for yielding the current virtual thread (#481)
Adds `cede()` to `ox.control`, next to `checkInterrupt()`: ```scala inline def cede(): Unit = checkInterrupt() Thread.`yield`() ``` Virtual threads are not preempted, so a CPU-bound loop can starve other virtual threads (in an experiment with 2x-cores busy-looping VTs, half of them received *zero* CPU time). `cede()` gives compute-intensive code a way to cooperate with the scheduler — and, since it also checks the interrupt flag, with the cancellation protocol: a busy loop calling `cede()` becomes both fair and cancellable. ## Why `checkInterrupt + Thread.yield` Based on experiments comparing yielding techniques on virtual threads (JDK 21 & 26, Linux, 8 cores), following up on [this gist](https://gist.github.com/lbialy/9f3732ce9901ba76ee2da9dd86f7df33): * `Thread.yield` genuinely unmounts and resubmits the continuation to the back of the scheduler's queue (external submit when the carrier's local queue is empty) — it reliably prevents starvation (89–93% liveness-probe capture vs 2% baseline), at ~1µs per call. * `LockSupport.parkNanos(1)` gives near-perfect round-robin fairness, but costs ~40–46µs per call (timer round-trip), stalls the caller for that time, and silently degrades to a no-op when the interrupt flag is set — i.e. exactly when the enclosing scope is being cancelled. Since `Thread.yield` is ~50x cheaper, calling it more frequently (every ~1ms of computation) matches `parkNanos(1)`'s liveness at a quarter of the overhead. The trade-off is mentioned in the scaladoc. * `LockSupport.parkNanos(0)` is a true no-op. * `Thread.sleep(0)` behaves identically to `checkInterrupt + Thread.yield` (that's literally its JDK implementation for virtual threads), but only as an `@implNote` — the explicit version doesn't rely on unspecified behaviour. The interrupt check makes `cede()` a cancellation boundary, consistent with e.g. cats-effect's `IO.cede`. Includes tests and a docs entry in `utils/control-flow.md`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent fd71b52 commit f6dfd35

3 files changed

Lines changed: 43 additions & 0 deletions

File tree

core/src/main/scala/ox/control.scala

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,21 @@ inline def never: Nothing = forever {
3131
*/
3232
inline def checkInterrupt(): Unit =
3333
if Thread.interrupted() then throw new InterruptedException()
34+
35+
/** Yields the current (virtual) thread back to the scheduler, allowing other threads to run. Useful in compute-intensive code, which
36+
* doesn't otherwise call any blocking operations: virtual threads are not preempted, so without yielding, a CPU-bound loop can starve
37+
* other virtual threads. As a rule of thumb, call `cede()` about once every millisecond of computation (a single call costs about 1µs).
38+
*
39+
* Additionally, checks if the current thread is interrupted (as [[checkInterrupt]] does), making the surrounding computation cooperate in
40+
* the cancellation protocol, e.g. when run in a [[supervised]] scope.
41+
*
42+
* Yielding is implemented using `Thread.yield`, which prevents complete starvation of other threads, but doesn't guarantee a fair
43+
* distribution of CPU time. If strict fairness between CPU-bound threads is required, `LockSupport.parkNanos(1)` yields near-round-robin
44+
* scheduling, at a much higher cost (about 40µs per call, as it involves a timer round-trip).
45+
*
46+
* @throws InterruptedException
47+
* if the current thread is interrupted.
48+
*/
49+
inline def cede(): Unit =
50+
checkInterrupt()
51+
Thread.`yield`()

core/src/test/scala/ox/ControlTest.scala

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,27 @@ class ControlTest extends AnyFlatSpec with Matchers:
6868
trail.get shouldBe Vector("done")
6969
}
7070

71+
"cede" should "throw InterruptedException and clear the interrupt flag when the thread is interrupted" in {
72+
Thread.currentThread().interrupt()
73+
intercept[InterruptedException](cede())
74+
Thread.currentThread().isInterrupted() shouldBe false
75+
}
76+
77+
it should "return normally when the thread is not interrupted" in {
78+
noException should be thrownBy cede()
79+
}
80+
81+
it should "make a compute-intensive fork responsive to cancellation" in {
82+
val r = supervised {
83+
val f = forkCancellable {
84+
forever(cede())
85+
}
86+
sleep(100.millis) // making sure the fork starts
87+
f.cancel()
88+
}
89+
r should matchPattern { case Left(_: InterruptedException) => }
90+
}
91+
7192
"timeoutOption" should "pass through the exception of failed computation" in {
7293
val myException = new Throwable("failed computation")
7394

doc/utils/control-flow.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,9 @@ There are some helper methods which might be useful when writing code using ox's
88
* `never` blocks the current thread indefinitely, until it is interrupted
99
* `checkInterrupt()` checks if the current thread is interrupted, and if so, throws an `InterruptedException`. Useful in
1010
compute-intensive code, which wants to cooperate in the cancellation protocol
11+
* `cede()` yields the current (virtual) thread back to the scheduler, allowing other threads to run, and checks for
12+
interruption (as `checkInterrupt()` does). Useful in compute-intensive code, which doesn't otherwise call any blocking
13+
operations: virtual threads are not preempted, so without yielding, a CPU-bound loop can starve other virtual threads.
14+
As a rule of thumb, call `cede()` about once every millisecond of computation (a single call costs about 1µs)
1115

1216
All of these are `inline` methods, imposing no runtime overhead.

0 commit comments

Comments
 (0)