Skip to content
academia.sh

Lesson 10 / 15

Concurrency Models

Comparing the thread, event loop, and message passing models on the same workload in terms of time, idle core steps, and correctness guarantee.

Contents

For four lessons, a single concurrency model was used: threads accessing shared memory, protected by locks. That model’s cost is now counted — contention producing 252 waiting steps at eight threads, a 0.4074 deadlock ratio with four locks and three threads, 18 wrong interleavings at two cores.

All of these costs have one common source: shared mutable state. Remove it, and they all disappear together. This lesson adds two models that either remove shared state or structurally serialize access to it, and counts all three on the same workload.

Three Models

The thread model. Shared address space, preemptive scheduling, lock-protected critical sections. This is the model measured up to this point.

The event loop model. A single thread, non-preemptive execution, run-to-completion. Waiting does not block work; when one task finishes, the next is taken. This model was built and measured in detail in the Asynchronous JavaScript and the Runtime course; the event loop’s queue rule, the microtask distinction, and the consequences of blocking work are not repeated here. This lesson takes it only as one of three models.

The message passing model. Isolated state, no sharing, communication only through copied messages. Because state is not shared, no lock is needed either.

The three models are three settings of the same scheduler, and this is what makes the comparison meaningful.

CC26. The thread model: preemptive, quantum 4, single core, context cost 2. CC27. The event loop model: non-preemptive, single core, context cost 0 — a switch is not an address-space change but a move to the next task in the queue. CC28. The message passing model: isolated state, four cores, context cost 2. CC29. The cost of copying a message is not modeled in this definition and counts as unmeasured. CC30. All three models run the same job list; the workload is identical.

SEED = 20260218
PROCESS_COUNT = 5
STEP_COUNT = 12
WAIT_DURATION = 30       # how many time units one wait step takes
CONTEXT_COST = 2         # cost of one context switch (time units)
VIRTUAL_PAGE = 16


def generator(seed):
    """Deterministic pseudo-random generator. The same seed gives the same sequence."""
    d = seed

    def next_val(n):
        nonlocal d
        d = (d * 1103515245 + 12345) % 2147483648
        return d % n
    return next_val


def workload(seed=SEED):
    r = generator(seed)
    jobs = []
    for i in range(PROCESS_COUNT):
        steps = []
        for _ in range(STEP_COUNT):
            if r(10) < 3:
                steps.append(("WAIT", WAIT_DURATION))
            else:
                base = (i * 3) % VIRTUAL_PAGE
                steps.append(("COMPUTE", (base + r(4)) % VIRTUAL_PAGE))
        jobs.append({"name": f"S{i+1}", "steps": steps,
                     "priority": 1 + r(3), "arrival": i * 4})
    return jobs


INFINITY = 10**9


def schedule(jobs, policy="fcfs", quantum=4, cores=1,
             context_cost=CONTEXT_COST):
    """Every core executes at most one step per time unit."""
    state = [{"name": i["name"], "steps": list(i["steps"]), "pos": 0, "arrival": i["arrival"],
              "ready_at": i["arrival"], "used": 0, "finish": None,
              "priority": i["priority"]} for i in jobs]
    share_limit = INFINITY if policy == "fcfs" else quantum
    cores_list = [{"process": None, "previous": None, "share": 0, "stall": 0}
                  for _ in range(cores)]
    t, context_switches, idle_steps = 0, 0, 0

    def not_done(d):
        return d["pos"] < len(d["steps"])

    while any(not_done(d) for d in state) or any(d["finish"] is None or d["finish"] > t
                                                   for d in state):
        for c in cores_list:                       # release
            d = c["process"]
            if d is not None and (not not_done(d) or d["ready_at"] > t or c["share"] >= share_limit):
                c["process"] = None
        for c in cores_list:                       # assign
            if c["stall"] or c["process"] is not None:
                continue
            held = [x["process"] for x in cores_list if x["process"] is not None]
            ready = [d for d in state if not_done(d) and d["ready_at"] <= t and d not in held]
            if not ready:
                continue
            if policy == "priority":
                chosen = min(ready, key=lambda d: (-d["priority"], d["ready_at"], d["name"]))
            elif policy == "fair":
                chosen = min(ready, key=lambda d: (d["used"], d["ready_at"], d["name"]))
            else:
                chosen = min(ready, key=lambda d: (d["ready_at"], d["name"]))
            if c["previous"] is not None and c["previous"] is not chosen:
                context_switches += 1
                c["stall"] = context_cost
            c["process"] = chosen
            c["previous"] = chosen
            c["share"] = 0
        for c in cores_list:                       # execute
            if c["stall"]:
                c["stall"] -= 1
                continue
            d = c["process"]
            if d is None:
                idle_steps += 1
                continue
            kind, value = d["steps"][d["pos"]]
            if kind == "WAIT":
                d["pos"] += 1
                d["ready_at"] = t + value
                c["process"] = None
                idle_steps += 1
                if not not_done(d):
                    d["finish"] = t + value
            else:
                d["pos"] += 1
                d["used"] += 1
                d["ready_at"] = t + 1
                c["share"] += 1
                if not not_done(d):
                    d["finish"] = t + 1
                    c["process"] = None
        t += 1
    for d, i in zip(state, jobs):
        wait_total = sum(v for kind, v in i["steps"] if kind == "WAIT")
        d["waiting"] = d["finish"] - d["arrival"] - d["used"] - wait_total
    return {"time": t, "context_switches": context_switches, "idle_core_steps": idle_steps,
            "total_work": sum(d["used"] for d in state),
            "avg_turnaround": round(sum(d["finish"] - d["arrival"] for d in state) / len(state), 2),
            "avg_waiting": round(sum(d["waiting"] for d in state) / len(state), 2),
            "finish": {d["name"]: d["finish"] for d in state}}


MODELS = (("thread", dict(policy="round_robin", quantum=4, cores=1, context_cost=2)),
          ("event loop", dict(policy="fcfs", cores=1, context_cost=0)),
          ("message passing", dict(policy="round_robin", quantum=4, cores=4, context_cost=2)))

for k, seed in enumerate((20260218, 20260219)):
    if k:
        print()
    JOBS = workload(seed)
    compute = sum(1 for i in JOBS for t, _ in i["steps"] if t == "COMPUTE")
    print(f"workload {seed}: {compute} compute steps , "
          f"{PROCESS_COUNT * STEP_COUNT - compute} wait steps")
    print("  model             time  context  idle core  avg.turnaround  avg.waiting")
    for name, settings in MODELS:
        s = schedule(JOBS, **settings)
        print(f"  {name:16s}  {s['time']:4d}  {s['context_switches']:6d}  {s['idle_core_steps']:9d}"
              f"  {s['avg_turnaround']:14.2f}  {s['avg_waiting']:11.2f}")
workload 20260218: 39 compute steps , 21 wait steps
  model             time  context  idle core  avg.turnaround  avg.waiting
  thread             197      25        108          153.80        20.00
  event loop         178      22        139          137.20         3.40
  message passing    179      19        639          141.40         7.60

workload 20260219: 46 compute steps , 14 wait steps
  model             time  context  idle core  avg.turnaround  avg.waiting
  thread             202      22        112          122.20        29.00
  event loop         177      14        131           98.00         4.80
  message passing    175      12        630           98.00         4.80

What the Table Shows

The clearest result is that the thread model gives the worst time on both workloads: 197 on the first, 202 on the second. This is the face of the shared definition’s first reading that falls to this lesson — preemptive time-slicing makes fairness cost time, and the cost holds on both workload compositions.

The difference between the event loop and message passing, however, cannot be measured. On the first workload, 178 against 179, that is 1 time unit; on the second, 177 against 175, that is 2 time units. On this workload, the measurement band’s lower bound is the size of the context cost, that is, 2 time units. The first difference is below the band and cannot be interpreted; the second sits exactly at the boundary and is not enough on its own to establish a ranking.

The difference between these two models sits not in the time column but in the idle core steps column. On the first workload, the event loop spends 139 idle core steps, message passing 639. To get the same time, message passing uses four cores; the event loop, one. Time is equal, but the resource is four and a half times as much.

A ranking claim cannot be established, but a cost comparison can be: on this workload, message passing’s four cores produce no gain in time.

What the Workload’s Composition Changes

The two workloads carry the same five processes in different compositions: the first has 39 compute, 21 wait; the second has 46 compute, 14 wait. The second is more compute-heavy.

As the compute share grows, message passing improves relatively: on the first workload it trails the event loop by 1 time unit, on the second it moves 2 time units ahead. The direction is the expected one — adding cores parallelizes compute, not waiting. But the magnitude sits at the boundary of the measurement band, and in this course such a difference is not converted into a claim of superiority.

The context-switch column speaks more clearly. On the second workload, the thread model produces 22 context switches, the event loop 14, message passing 12. The cost of preemption shows up directly here.

Average turnaround diverges too: on the second workload, the thread model gives 122.20, the other two 98.00. While the difference in overall time cannot be measured, individual jobs’ finish times diverge measurably — which metric is asked determines which model wins.

The Correctness Axis

Time is not the only axis. The three models give three separate guarantees in answer to the same shared-counter question.

def counter_run(pattern: list[int]) -> int:
    counter, local = 0, {0: None, 1: None}
    stage = {0: 0, 1: 0}
    for who in pattern:
        s = stage[who]
        if s == 0:
            local[who] = counter
        elif s == 1:
            local[who] = local[who] + 1
        else:
            counter = local[who]
        stage[who] = s + 1
    return counter


def interleavings(a: int, b: int) -> list[list[int]]:
    if a == 0:
        return [[1] * b]
    if b == 0:
        return [[0] * a]
    return ([[0] + s for s in interleavings(a - 1, b)]
            + [[1] + s for s in interleavings(a, b - 1)])


def quantum_interleavings(steps: int, quantum: int) -> list[list[int]]:
    result = []

    def walk(remaining_a, remaining_b, current, sequence):
        if not remaining_a and not remaining_b:
            result.append(list(sequence))
            return
        for candidate in (0, 1):
            remaining = remaining_a if candidate == 0 else remaining_b
            if not remaining:
                continue
            n = min(quantum, remaining)
            sequence.extend([candidate] * n)
            walk(remaining_a - n if candidate == 0 else remaining_a,
                 remaining_b - n if candidate == 1 else remaining_b, candidate, sequence)
            del sequence[len(sequence) - n:]
    walk(steps, steps, None, [])
    distinct = []
    for d in result:
        if d not in distinct:
            distinct.append(d)
    return distinct


print("model             shared state  critical section splits  reachable  wrong")
for name, shared, splits in (("thread", "yes", True),
                              ("event loop", "yes", False),
                              ("message passing", "no", None)):
    if shared == "no":                 # a single owner changes the counter, no interleaving occurs
        print(f"  {name:16s}  {shared:12s}  {'-':>24s}  {'-':>9s}  {'-':>5s}")
        continue
    reachable = interleavings(3, 3) if splits else quantum_interleavings(3, 3)
    wrong = sum(1 for d in reachable if counter_run(d) != 2)
    print(f"  {name:16s}  {shared:12s}  {str(splits):>24s}  {len(reachable):9d}  {wrong:5d}")
model             shared state  critical section splits  reachable  wrong
  thread            yes                               True         20     18
  event loop        yes                              False          2      0
  message passing   no                                   -          -      -

In the thread model, the critical section can be split; if unprotected, 18 of the 20 interleavings produce a wrong result. If protected, correctness follows, and its cost was already counted in the previous lessons: at four threads, time rises from 29 to 47 and waiting from 18 to 54.

In the event loop model, the critical section cannot be split: the run-to-completion rule structurally forbids another task from running in the middle of one. Reachable interleavings are 2, wrong is 0, and this is different in kind from the quantum setting in the first lesson — there, the protection was a setting; here, it is the model’s definition. In exchange, the same rule brings a limit: a long compute step holds up the entire queue, and the model cannot go beyond a single core.

In the message passing model, the question cannot be asked: a single owner changes the counter, and the other side sends a message. Because there is no shared mutable state, there is no critical section, no lock, no deadlock, no memory visibility problem. Its cost sits somewhere unmeasured — copying state on every message (CC29).

The Models Do Not Exclude One Another

That the three models are three settings of the same scheduler is not just a measurement convenience; it is a structural observation. The settings are independent of one another and can be mixed.

The most visible example of this is in the table. The event loop setting is single-core; the message passing setting is four-core and isolates state. Combining the two means running one event loop per core and sending only messages between them. This combination is not a new model; it is choosing two settings at once.

In the same way, the thread model can be combined with isolation: shared state is left only where it genuinely needs to be shared, and the rest is kept private to each thread. As the critical section shortens, contention drops, and this drop was already measured in the second lesson — at eight threads, as the critical share fell from 0.50 to 0.05, time dropped from 83 to 20.

The rule that follows is this: a model is not a flag, it is a set of settings. The question to ask is not “which model” but “which state will be shared, will there be preemption, how many cores will be used.” Each of these has had its cost counted separately in this topic.

What Choosing a Model Actually Chooses

Model Time (39/21) Time (46/14) Idle core Correctness guarantee
Thread 197 202 108 / 112 none, built with a lock
Event loop 178 177 139 / 131 run-to-completion
Message passing 179 175 639 / 630 no sharing

The one general conclusion that can be drawn from the table is that the thread model gives the worst time on both workloads. The difference between the other two models sits within or at the boundary of the measurement band on both workloads; in this course, such a difference counts as unmeasured.

The choice, then, is not made on time. What is chosen is which cost will be paid: contention and deadlock risk in the thread model, the single-core limit in the event loop, copying and four and a half times the idle core steps in message passing.

Summary

  • When the three models are measured on the same workload, the thread model gives the worst time on both workloads: 197 and 202.
  • The time difference between the event loop and message passing is 1 and 2 time units; it sits below or at the boundary of the measurement band and is not enough to establish a ranking.
  • The difference shows up in idle core steps: for the same time, the event loop spends 139 idle core steps, message passing 639.
  • The correctness guarantees differ in kind: the thread model has none and it is built with a lock; the event loop’s comes from run-to-completion; in message passing the question cannot even be asked, since there is no sharing.
  • Which metric is asked changes the winner: on the second workload, while the time difference cannot be measured, average turnaround diverges at 122.20 against 98.00.

Next Step

This topic counted concurrency’s cost on three axes: waiting steps, deadlock ratio, and the reachable interleaving set. All of them shared one assumption: that memory is unlimited and access is free — a compute step always took one time unit. The next topic removes that assumption. Memory is finite, the address space is larger than the physical one, and an access may have to wait for a page coming from disk. The first lesson will take the 39-access sequence this workload produces, sweep three page-replacement procedures across 15 distinct virtual pages, and show that a smarter procedure does not always pay off.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close