SystemC Scheduler Internals — How the Simulation Engine Runs Your Processes
You have three SC_THREAD processes all sensitive to the same clock edge. The simulation runs — but which one goes first? And if one of them writes to a signal, will the others see the new value during this same evaluate phase?
These are not edge cases. They come up every time you build a multi-process SystemC design. The answers live inside the scheduler — the part of the SystemC kernel that owns the simulation loop, manages the runnable queue, and decides when each process executes.
This article is a ground-up look at how the scheduler works. We covered the evaluate–update loop and delta cycles in Delta Cycles in SystemC. Here we go one level deeper: process states, the runnable queue, cooperative multitasking, and the rules — and non-rules — around execution order.
1. What the Scheduler Actually Is
The SystemC scheduler is not an operating system scheduler. It does not preempt processes, does not assign time slices, and does not run on multiple threads (single-threaded by default). It is a coroutine-based cooperative scheduler built entirely inside a single OS thread.
Its job is simple: maintain a set of processes that are ready to run, pick one, run it until it yields, then pick the next one. Repeat until no processes are ready, then advance simulated time to the next scheduled wakeup.
2. Process States — The Four-State Machine
Every process registered with the kernel (via SC_THREAD or SC_METHOD) exists in one of four states:
| State | Meaning | How to enter |
|---|---|---|
| Initialized | Created during elaboration, not yet run | Module constructor completes |
| Runnable | Ready to execute — in the runnable queue | Initialization phase; event notification; time wakeup |
| Waiting | Suspended, waiting for an event or time | Process calls wait() or SC_METHOD returns |
| Terminated | Process has finished permanently | SC_THREAD body function returns; sc_stop() |
The typical lifecycle of a long-running SC_THREAD:
A process cycles between Runnable and Waiting for the entire simulation. It only becomes Terminated if its function body returns (unusual for SC_THREAD) or if sc_stop() is called.
3. The Runnable Queue — Who Gets to Run
The scheduler maintains a runnable queue (or runnable set — the standard doesn't mandate a specific data structure). A process enters the queue when:
- Initialization — all processes are added to the runnable queue once at the start of simulation (the initialization phase).
- Event notification — an event fires (
notify()ornotify(SC_ZERO_TIME)), and one or more processes are sensitive to that event — they are moved from Waiting to Runnable. - Time wakeup — a process called
wait(10, SC_NS), and that time has now been reached — the scheduler moves it back to Runnable. - Signal value change — an
sc_signalchanges value in the update phase — processes in its static sensitivity list become Runnable for the next evaluate.
The scheduler picks from this queue one process at a time, runs it to completion (until wait() or return), then picks the next. When the queue is empty, the evaluate phase ends and the update phase begins.
4. Elaboration — What Happens Before sc_start()
Simulation has two distinct phases separated by the call to sc_start(). Everything before it is elaboration.
During elaboration, C++ constructors run in declaration order. Each module constructor:
- Declares processes (
SC_THREAD,SC_METHOD) and registers them with the kernel - Declares ports and signals
- Instantiates sub-modules (their constructors run recursively)
After all constructors finish, sc_main() performs port binding — connecting ports to signals via port(signal). At this point the kernel validates the hierarchy: every port must be bound, every channel must be connected, before simulation can start.
int sc_main(int argc, char* argv[]) { // ── Elaboration ────────────────────────────────── sc_signal<bool> clk_sig; MyModule dut("dut"); // constructor runs, processes registered dut.clk(clk_sig); // port binding // ── Simulation ─────────────────────────────────── sc_start(100, SC_NS); // scheduler takes over from here return 0; }
Nothing in the scheduler runs during elaboration. No processes execute. No time passes. The kernel is only building its internal data structures.
5. The Initialization Phase — Every Process Runs Once
The first thing sc_start() does is run the initialization phase — before simulation time advances to even 1 fs. All processes are put into the runnable queue and executed once.
| Process type | Initialization behaviour |
|---|---|
SC_METHOD |
Body executes once, then suspends. Resumes when its sensitivity list triggers. |
SC_THREAD |
Body runs until the first wait() call. Everything before the first wait() is initialization logic. |
This is why SC_THREAD designs typically have an initial setup block before the main loop:
SC_HAS_PROCESS(Producer); Producer(sc_module_name n) : sc_module(n) { SC_THREAD(run); } void run() { // ── Runs during initialization phase ────────────── out.write(false); // set initial output state count = 0; // initialize counter wait(5, SC_NS); // ← first wait — initialization ends here // ── Main simulation loop ─────────────────────────── while (true) { wait(clk.posedge_event()); out.write(!out.read()); count++; } }
6. Cooperative Multitasking — No Preemption
This is the most important architectural property of the SystemC scheduler: it is strictly cooperative. Once a process is selected from the runnable queue and starts executing, it runs without interruption until it voluntarily yields. There is no preemption.
A process yields in one of three ways:
wait()— suspends the process and returns control to the scheduler- Function return (
SC_METHODonly) — the method body exits, process goes back to Waiting sc_stop()— terminates all simulation
Everything a process does between two wait() calls is atomic from the scheduler's perspective. No other process can run between two statements inside one process's execution segment.
void monitor() { while (true) { wait(clk.posedge_event()); // ── Atomic block — no other process runs here ───── int a = bus_a.read(); // reads current values int b = bus_b.read(); // guaranteed: no other process changed them result.write(a + b); // result goes to new_value buffer // ── Atomic block ends at next wait() ────────────── } }
This cooperative model is what makes SystemC simulation deterministic within a single run. There are no data races between processes on plain variables — only the ordering uncertainty of which process in the runnable set runs first.
7. Execution Order — The Scheduler Doesn't Guarantee It
This is the most common source of non-portable SystemC code. Two processes become runnable at the same delta — both are in the runnable queue simultaneously. The standard says: either may run first.
sc_signal<int> shared; // Process A — sensitive to clk posedge void proc_A() { shared.write(42); } // Process B — also sensitive to clk posedge void proc_B() { int v = shared.read(); // sees old value — always // Even if proc_A ran first, shared.write(42) went to new_value. // read() returns current_value, which is still the old value. // The write only becomes visible after the update phase. }
In this case the ordering uncertainty doesn't matter — sc_signal's evaluate-update separation means proc_B always reads the old value regardless of order. This is by design.
The ordering does matter for shared C++ variables:
int shared_var = 0; // plain variable, not sc_signal void proc_A() { shared_var = 42; } // writes immediately void proc_B() { int v = shared_var; // ← sees 42 if A ran first, 0 if A hasn't run yet // ORDER-DEPENDENT — portability bug }
sc_signal for inter-process communication — its evaluate-update semantics eliminate the ordering dependency.
8. How SC_THREAD and SC_METHOD Differ in the Scheduler
Both process types participate in the same scheduler loop. The difference is in how they yield and how the scheduler resumes them.
| SC_THREAD | SC_METHOD | |
|---|---|---|
| Yield mechanism | wait() — suspends mid-function |
Function return — body must exit completely |
| Coroutine stack | Yes — full stack saved on wait() |
No — no persistent execution context |
| Local variables | Persist across wait() calls (on the stack) |
Lost on every return — use member variables |
| Sensitivity | Dynamic — changes per wait(event) call |
Static — fixed at sensitive << declaration |
| Overhead | Higher — coroutine context switch per wait() |
Lower — plain function call and return |
SC_THREAD is implemented using coroutines (sometimes called fibers or green threads). When a thread calls wait(), the kernel saves its entire call stack and register state, returns control to the scheduler, and later restores that state to resume exactly where it left off. This is more expensive than a plain function call but allows natural sequential code with loop constructs and local state.
SC_METHOD is a plain callback. The kernel calls it like a function. When the body returns, the method's execution context is gone. It is more efficient but forces a different coding style — state must be stored in member variables, and each invocation is logically independent.
// SC_THREAD — natural sequential style, state lives on stack void thread_body() { int count = 0; // stack variable — persists across waits while (true) { wait(clk.posedge_event()); count++; // count still here after wait returned } } // SC_METHOD — must use member variables for state int m_count = 0; // member variable — outlives the call void method_body() { // called on every clk posedge m_count++; // updates member — visible on next call } // returns → context gone until next trigger
9. Delta-Triggered vs Time-Triggered Wakeups
When a process suspends with wait(), it specifies what it is waiting for. The scheduler handles two fundamentally different wakeup mechanisms:
| Wait form | Type | When process becomes Runnable |
|---|---|---|
wait(event) |
Delta-triggered | When event.notify() or notify(SC_ZERO_TIME) fires — same or next delta |
sensitive << sig (SC_METHOD) |
Delta-triggered | When sig changes value in the update phase |
wait(10, SC_NS) |
Time-triggered | When simulation time reaches current + 10 ns |
wait(10, SC_NS, event) |
Both | Whichever comes first: the event or the timeout |
Time-triggered processes are stored in a time-ordered queue (a priority queue keyed by wakeup time). The scheduler peeks at the front of this queue to determine the next simulation timestamp to advance to. Delta-triggered processes are in the immediate runnable set — they are resolved before time ever advances.
// Demonstrates both wakeup types in the same process void watchdog() { while (true) { wait(100, SC_NS, done_event); // timeout OR event — whichever first if (done_event.triggered()) { cout << "Done at " << sc_time_stamp() << endl; } else { cout << "Timeout — 100 ns elapsed" << endl; } } }
10. Starvation — What Happens When a Process Never Yields
Because the scheduler is cooperative, a process that never calls wait() will run forever — blocking every other process from executing. Simulated time will never advance. This is process starvation.
// ⚠ Starvation — this process never yields void bad_thread() { while (true) { counter++; // increments forever — wait() never called // simulation hangs at time 0 } }
The simulation doesn't crash — it just spins inside bad_thread() forever. The OS process will peg a CPU core at 100% and never return from sc_start(). In practice this is usually caused by a missing wait() inside a loop, or a conditional branch that prevents reaching the wait() under certain inputs.
- Infinite loop with no
wait()— loop condition never breaks wait()inside anifthat is never entered- Recursive function that never reaches
wait() - A deadlock between two processes waiting for each other's signal (simulated time still advances, but the processes never progress)
11. The Complete Scheduler Loop
Putting it all together — here is the scheduler's simulation loop in pseudocode:
// sc_start() pseudocode — simplified but accurate initialize(): for each process p: runnable_queue.add(p) // all processes run once evaluate_update_loop() // settle initial state simulation_loop(): while (not done): evaluate_update_loop() // run all deltas at current time next_time = time_queue.next_wakeup() if (next_time == INFINITY): break // nothing scheduled — done current_time = next_time runnable_queue.add(time_queue.pop_due(current_time)) evaluate_update_loop(): while (runnable_queue not empty): // ── Evaluate phase ────────────────────────────── while (runnable_queue not empty): p = runnable_queue.pick_one() // order unspecified p.resume() // runs until wait() or return // ── Update phase ──────────────────────────────── for each channel c in update_queue: c.update() // flush new_value → current_value if value_changed: runnable_queue.add(c.sensitive_processes) update_queue.clear()
Every iteration of the outer while loop in evaluate_update_loop() is one delta cycle. The loop exits when evaluate produces no changes — no pending updates, no new runnable processes. Only then does simulation time advance.
12. Practical Rules
| Situation | Rule |
|---|---|
| Two processes share data at the same delta | Use sc_signal, not plain variables — evaluate-update gives you order-independence |
| Process needs to run first in a delta | Don't rely on it — execution order is unspecified. Redesign to remove the dependency |
| Initialization logic in SC_THREAD | Put it before the first wait() — it runs during the initialization phase |
| State that must persist across triggers | For SC_METHOD: use member variables. For SC_THREAD: local variables on the stack work |
| Simulation seems stuck (never advances) | Check for a missing wait() — a process is probably starving the scheduler |
| Read a signal written in the same delta | You can't — use wait(SC_ZERO_TIME) to step through the update phase first |
| Need to know what time it is | sc_time_stamp() returns current simulated time; sc_delta_count() returns delta count |
Summary
- The SystemC scheduler is a cooperative, single-threaded coroutine scheduler. No preemption, no OS threads.
- Every process exists in one of four states: Initialized → Runnable → Waiting → (Terminated).
- The runnable queue is populated by initialization, event notifications, signal value changes (after update), and time wakeups.
- Elaboration (before
sc_start()) builds the module hierarchy and binds ports. No processes execute during elaboration. - The initialization phase runs all processes once before simulated time moves.
SC_THREADruns until its firstwait(). - Processes are atomic between waits — no other process can interleave within one execution segment.
- Execution order within a single evaluate phase is not guaranteed by the standard. Never design code that depends on it.
SC_THREADuses coroutines (stack saved onwait());SC_METHODis a plain function call with no persistent stack.- A process that never calls
wait()will starve all other processes — simulation hangs. - Each evaluate + update pass is one delta cycle. The scheduler loops until the runnable queue is empty, then advances time.
Part of the SystemC Foundations guide Browse all SystemC guides →