Zephyr Cooperative Threads: Choose Yield or Sleep from the Threads That Must Progress
Repeated k_yield() calls do not guarantee progress for lower-priority threads. Yield keeps the current thread ready; the scheduler still chooses according to priority. If a cooperative worker must allow lower-priority work to run, qualify an operation that actually makes it unready, or redesign the scheduling and workload contract.
This guide uses Zephyr v3.7.0 on one CPU, ordinary cooperative and preemptive threads, no meta-IRQ threads and no deadline scheduling. SMP and special scheduling classes require a separate analysis. It focuses on voluntary yielding versus becoming unready, not mutex priority inversion or periodic-task release accuracy.
List who needs CPU time while the worker is active
Start with a runnable-thread matrix rather than adding another yield to the loop. Name the cooperative worker C and each dependent thread. Record relative priority, ready or blocked state, maximum useful processing chunk and the event that can make C unready. Include storage, transport and cleanup threads that the worker depends on indirectly.
The Zephyr 3.7.0 scheduling documentation says yield places the caller at the back of its prioritized ready list. Ready threads of higher or equal priority can run before it. If none exist, it can be immediately selected again. This means a lower-priority thread can remain ready through many successful yield calls without ever executing.
| Other ready work | C calls yield | C genuinely blocks or sleeps |
|---|---|---|
| Higher-priority H | H may run at the yield point | H is eligible for selection |
| Same-priority E | E gets an opportunity before C is selected again | E is eligible for selection |
| Only lower-priority L | C can be selected again immediately | L can be selected while C is unready |
| Several lower-priority threads | Yield does not donate a turn to all of them | The highest-priority ready one still wins |
The final column is opportunity, not a deadline guarantee. A different ready thread or an interrupt can consume the available interval. A sleep long enough to observe one lucky run is not proof that every lower-priority deadline is protected.

Verify that the chosen operation really blocks
The v3.7.0 scheduler source routes zero-tick sleep through yield. Replacing yield with a zero-duration sleep therefore does not establish a lower-priority execution interval. Check the actual timeout value after configuration and conversion.
A nonzero sleep makes the thread unready for its wait interval, subject to supported wake behavior. A busy wait is different: the scheduling documentation explicitly describes it as delaying without relinquishing the CPU to another ready thread. It is not a substitute for a sleep when the goal is thread progress.
An event wait can be preferable when the worker has no useful work until data arrives. However, an operation that finds its condition already satisfied need not block. A loop consuming an always-nonempty work source can remain continuously ready even though every iteration calls an API with a blocking timeout argument.
Measure a progress boundary rather than a yield count
- Use an isolated test build with C and one lower-priority observer L. Remove unrelated same/higher-priority threads from the test where practical.
- Give C a bounded computation chunk followed by yield. Record C’s iteration markers and L’s completed work, not merely how often yield was called.
- Add a same-priority peer E and repeat. This checks that the instrumentation distinguishes equal-priority opportunity from lower-priority starvation.
- Replace the yield boundary with a genuine nonzero sleep, then with an event wait that actually blocks. Record C’s unready interval and L’s execution.
- Restore realistic competing work and interrupts. Check the service deadlines that motivated the change.
These are proposed Zephyr tests and have not been executed here. Use a low-overhead trace or preallocated counters with a defined sampling protocol. Printing inside the loop can block, accidentally creating the very scheduling opportunity that the test is supposed to evaluate.
Include a test with continuous input, not just occasional messages. If the repair relies on the work source becoming empty, state that workload assumption explicitly. A burst that keeps it permanently ready can invalidate an otherwise convincing demonstration.
Select the smallest design that meets the requirement
Use yield when the intended contract is to offer execution to ready peers or higher-priority ordinary threads. Keep the processing chunk before yield bounded because a cooperative thread can otherwise delay them until it reaches that scheduling point.
Use a genuine event wait when no work exists, and a deliberate pacing or workload policy when input can remain continuously available. A nonzero sleep can provide an unready window, but its duration must follow the service requirement and acceptable producer latency. Do not choose a magic delay simply because the watchdog stopped resetting once.
Changing the worker to a preemptive priority is another design choice, not an automatic fix. Reassess shared-state assumptions and response times if doing so. Neither preemption nor yielding repairs a dependency where the worker holds a resource that the supposedly enabled thread needs.
Hypothetical example: telemetry parsing starves storage
Imagine a hypothetical cooperative parser that drains an input stream and yields after every record. Its lower-priority storage worker remains ready, but the input never empties. The parser’s yield counter grows while queued storage work ages.
The investigation first proves that C stays ready and that no equal/higher-priority peer consumes the yield opportunity. A revised design may bound each admitted batch, allow the parser to wait under a supported backpressure policy, or select a preemptive scheduling arrangement after reviewing shared state. The correct choice depends on both parser latency and storage progress requirements.
A diagnostic sleep demonstrates the eligibility mechanism, but it is not the final acceptance criterion. The product must still handle maximum supported arrival load, recovery bursts and periods when storage is slow.
Record a scheduling contract that survives maintenance
Deliver the thread-priority matrix, actual blocking conditions, maximum cooperative chunk and required progress evidence for dependent threads. Include no-input, continuous-input and high-interference cases. Stop qualification when a claimed blocking boundary returns immediately or a required thread remains ready without service beyond its allowed budget.
Recheck after changing priorities, timeout conversion, logging or producer behavior. The useful result is a trace explaining why each dependent thread can run under the supported workload. Counting yield calls or observing a quiet idle demo cannot establish that result.