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.

Key distinction The SystemC scheduler manages simulated concurrency — many hardware processes executing "simultaneously" in the model. It achieves this with sequential execution on one CPU thread. No real parallelism, no OS threads, no locks needed between processes.

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:

Initialized
Runnable
Waiting
Runnable
Waiting
→ ···

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:

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.

The queue is not a FIFO The SystemC standard does not specify the order in which processes are selected from the runnable set during a single evaluate phase. Reference simulators (Accellera, Cadence, Synopsys) typically implement FIFO or insertion-order semantics within one delta, but this is not guaranteed. Do not write designs that depend on it.

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:

  1. Declares processes (SC_THREAD, SC_METHOD) and registers them with the kernel
  2. Declares ports and signals
  3. 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++;
  }
}
Initialization order is also unspecified Just like evaluation order, the order in which processes run during initialization is not mandated by the standard. If your initialization logic depends on another process having already run its initialization, you have a potential ordering bug.

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:

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
}
Rule Never share plain C++ variables between concurrent processes that run in the same delta. Use 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.

Common starvation patterns
  • Infinite loop with no wait() — loop condition never breaks
  • wait() inside an if that 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


📬 Get new articles in your inbox

Deep dives on SystemC, C++, and embedded systems — no spam, unsubscribe any time.

No spam, unsubscribe any time. Privacy Policy

Aditya Gaurav

Aditya Gaurav

Embedded systems engineer specializing in SystemC, ARM architecture, and C/C++ internals. Writing deep technical dives for VLSI and embedded engineers.

Part of the SystemC Foundations guide Browse all SystemC guides →