cld-toys › Toys › stackless-vs-stackful

Commentary: stackless-vs-stackful

Two green-thread runtimes over one program. A library function gains one line and starts suspending: the stackful column silently returns 150 where 200 is the only correct answer, the stackless one refuses to run — and colouring all four frames above it to make it run again also returns 150. A study guide for runtimes.py.

stackless-vs-stackful/ on GitHub·the source with line numbers, for reading rather than downloading
How to read this This is the only documentation the toy has — read it with runtimes.py open beside you. runtimes.py is the toy itself (290 lines: two green-thread schedulers, one bank workload in four colourings, and a call-depth generator); demo.py runs the seven panels and derives the numbers; test_runtimes.py pins 150, 200, the 20/20-against-0/20 comparison and the depth table (21 tests). Every transcript below was captured from a real run on macOS 26.5.2 (macOS-26.5.2-arm64-arm-64bit-Mach-O, arm64), Python 3.15.0a8 (main, Apr 14 2026, 14:20:41) [Clang 22.1.3], GIL enabled, sys.getswitchinterval() = 5.0 ms. Stdlib only.
cd stackless-vs-stackful
python3 demo.py            # the aha (§6). Panel 7 takes ~20s; the rest is instant
python3 test_runtimes.py   # pins every number this page quotes
Contents
  1. Orientation
  2. The problem this mechanism exists to solve
  3. Background you need
  4. The mental model
  5. Reading the source
  6. The demo, and what it proves
  7. Design decisions and roads not taken
  8. What's simplified vs. the real thing
  9. Check yourself
  10. Further reading

1. Orientation

A green thread is a task the language schedules instead of the operating system. There are two ways to build one, and they differ in exactly one thing: where a task is allowed to suspend.

The received wisdom about the trade is that colouring is an ugly tax you pay for cheap tasks, and that the stackful version is the same thing without the paperwork. This toy runs one program on both and measures what the tax actually buys.

The measurement has a sting in it. A library function called io_read gains one line — it suspends between reading the row and returning it. No frame above it is edited. The stackful column silently returns 150 where 200 is the only correct answer. The stackless column refuses to compile the thought: TypeError: unsupported operand type(s) for +: 'generator' and 'int'. Then you do what the error tells you and add yield from to all four frames above it — and it runs, and returns 150.

Colouring bought visibility. It did not buy correctness.

And then the part that inverts the folklore. Against real asyncio and real OS threads, 20 trials each on the same workload: the cooperative version was wrong on 20 of 20, the preemptive one on 0 of 20. Cooperative scheduling did not make the race rarer. It made it the only behaviour the program has.

By the end you should be able to:


2. The problem this mechanism exists to solve

Ten thousand connections, one machine. A thread each spends its memory on stacks and its CPU on switching between them to discover nothing arrived, so every runtime that wants a lot of concurrency ends up multiplexing many logical tasks onto few OS threads. That much is settled, and §6.6 measures the prize: 192.5 bytes of state for a suspended green task against 38,789 bytes of resident memory for a parked OS thread, and a hard ceiling of 16,383 OS threads on the machine this ran on against 100,000 green ones that did not raise an eyebrow.

The design question is not whether to multiplex. It is what a task is allowed to do when it wants to give the CPU back, and there are two honest answers with different bills.

The competing goals that keep both designs alive:


3. Background you need

ConceptWhere it's used in the toyOne link
Generators driven by send() Green.run resumes a task with g.send(None); returning from send is suspension PEP 342
yield from delegation The colouring. g3_db_fetch/g3_repo_get/g3_deposit/g3_teller — four frames that exist only to forward a suspension PEP 380
Function colouring The thing being measured: chain() builds a call stack of depth D and counts the frames that must change What Color is Your Function?
Lost update (read-modify-write) sf_deposit's three lines: read b, add, write back. The suspension lands between the first and the third PostgreSQL 13.2
Cooperative scheduling Green.run and Stackful.run both switch only where the program says so; neither can interrupt a task PEP 492
Semaphores as a baton Stackful gives each task a Semaphore(0) and keeps one back semaphore, so exactly one thread runs at a time threading
Growable stacks Absent by necessity. Go's stackMin = 2048 is why a goroutine is not an OS thread; CPython exposes nothing equivalent go/src/runtime/stack.go

The three flagged ☋ carry the result. yield from is how colouring is spelled, function colouring is what is being counted, and the lost update is the failure that both runtimes produce identically — which is the only reason the comparison says anything.


4. The mental model

One program. The library owns only the bottom frame.

teller ──▶ deposit ──▶ repo_get ──▶ db_fetch ──▶ io_read │ │ │ b = repo_get(...) ◀── critical │ v = bank[acct] │ b = b + 1 section │ ~~~~~~~~~~~~~~ │ bank[acct] = b │ return v └──▶ ONE LINE IS ADDED HERE WHAT EACH RUNTIME NEEDS IN ORDER TO SUSPEND ON THAT LINE STACKFUL STACKLESS ┌─────────────────────────────┐ ┌─────────────────────────────┐ │ teller (untouched) │ │ teller yield from ✎ │ │ deposit (untouched) │ │ deposit yield from ✎ │ │ repo_get (untouched) │ │ repo_get yield from ✎ │ │ db_fetch (untouched) │ │ db_fetch yield from ✎ │ │ io_read suspend() │ │ io_read yield │ └─────────────────────────────┘ └─────────────────────────────┘ 0 frames edited 4 frames edited (D = 4) no caller can tell every caller can AND THEN BOTH OF THEM RUN THIS: T0 v = bank["acct"] → 100 ──── suspends here ────▶ T1 v = bank["acct"] → 100 ──── suspends here ────▶ T0 bank["acct"] = v + 1 = 101 ; v = bank["acct"] → 101 ─▶ T1 bank["acct"] = v + 1 = 101 ; v = bank["acct"] → 101 ─▶ T0 bank["acct"] = v + 1 = 102 ; ... Both tellers read the same value and both write the same value, so ONE ROUND ADVANCES THE BALANCE BY ONE, whatever the teller count. 50 rounds ⇒ 100 + 50 = 150. Of 100 increments performed, 50 landed.

The shortcut to carry away: a suspension point is a place where every invariant you are half-way through establishing becomes visible to everybody else. Stackless spelling makes that place visible in the source at every level; stackful spelling makes it invisible above the frame that does it. Neither spelling makes the place safe, and §6.3 measures the difference between the two spellings at exactly zero.


5. Reading the source

5.1 Green — the stackless runtime, in twenty lines

runtimes.py · lines 26–45
class Green:
    """STACKLESS. A task is a generator, driven with send(); a task suspends
    by returning from send() and resumes on the next one. Plain FIFO."""

    def __init__(self):
        self.q = collections.deque()
        self.switches = 0

    def spawn(self, gen):
        self.q.append(gen)

    def run(self):
        while self.q:
            g = self.q.popleft()
            try:
                g.send(None)
            except StopIteration:
                continue
            self.switches += 1
            self.q.append(g)

There is no suspension machinery here at all, and that is the point. The scheduler never causes a suspension; it calls send and the generator comes back when it feels like it. Everything this toy is about happens in the type system above this class, not in it: g must be a generator, so whatever built g must have been a generator function, so whatever suspends inside it must have had a yield reachable through an unbroken chain of yield from.

Note what switches counts — a send that returned rather than raising StopIteration. That is the honest definition of a context switch in a stackless runtime: it is a function return.

5.2 Stackful.suspend — the same idea as a call

runtimes.py · lines 78–86
    def suspend(self):
        """Hand the baton back and wait to be given it again.

        This is an ordinary method call, so it works at ANY stack depth, in
        any kind of frame. That single fact is the whole toy.
        """
        slot = self.cur.slot
        self.back.release()
        slot["sem"].acquire()

Three lines, and one of them is bookkeeping. The task's OS thread blocks on its own semaphore; the scheduler, blocked on back, wakes up and gives the baton to the next slot. The Python frames of the suspended task stay exactly where they were, on a real stack, because nobody unwound them — which is the entire difference from Green, where suspension is a return and the frames above the yield had to be generators precisely so that they could be unwound and rebuilt.

self.cur is a threading.local(), so suspend needs no argument to work out who is calling it. That is not a convenience: it is what makes the module level suspend() below callable from a frame that has never heard of the scheduler.

5.3 The suspension primitive, reachable from anywhere

runtimes.py · lines 108–113
def suspend():
    """The stackful suspension primitive, reachable from anywhere -- exactly
    as `runtime.Gosched()` is in Go and a blocking call is on a Loom virtual
    thread. Nothing in the signature of a caller records that it can happen.
    """
    SCHED.suspend()

The last sentence of that docstring is the claim the whole toy tests. sf_deposit calls sf_repo_get, whose type, signature, name and call site are identical before and after the library learned to suspend. There is no declaration anywhere in the program that a switch can occur inside it — and §6.3 shows that this missing declaration is worth exactly 50 lost increments and nothing else, because the coloured version, which does declare it, loses the same 50.

5.4 The application, stackful: five frames, one marker

runtimes.py · lines 119–140
def sf_io_read(bank, acct):
    if WHERE == "before_read":
        suspend()
    v = bank[acct]                  # the row is read here...
    if WHERE == "after_read":
        suspend()                   # <-- THE ONE LINE THE LIBRARY ADDS
    return v                        # ...and the reply reaches us here

def sf_db_fetch(bank, acct):
    return sf_io_read(bank, acct)

def sf_repo_get(bank, acct):
    return sf_db_fetch(bank, acct)

def sf_deposit(bank, acct, amt):
    b = sf_repo_get(bank, acct)     # critical section starts
    b = b + amt
    bank[acct] = b                  # critical section ends

def sf_teller(bank, acct, n):
    for _ in range(n):
        sf_deposit(bank, acct, 1)

WHERE is a module global set by run_stackful, and it selects between three one-line editions of the library's io_read: no suspension, suspension before the read, suspension after it. Reading a configuration flag inside io_read is not how a real library works — a real one just has the line or does not — but it lets the counterfactual be a parameter instead of a second copy of the file, and §6.3 is the reason it is worth doing.

The four frames underneath are the whole exhibit. sf_db_fetch, sf_repo_get, sf_deposit and sf_teller are byte-identical across all three editions of the library, and test_the_bank_workload_is_depth_four asserts that none of them contains the string yield or suspend. A reviewer looking at a diff of this program when the library changed would see nothing, because there is nothing to see.

sf_deposit is the failure, and it is worth reading as three separate instructions rather than one idea. b = sf_repo_get(...) is a read of shared state. bank[acct] = b is a write derived from it. The invariant "nobody else touched bank[acct] in between" is asserted by nothing at all — it is merely true, as long as no suspension lands on the line between them.

5.5 The application, stackless, after the library changed

runtimes.py · lines 169–188
def g2_io_read(bank, acct):
    v = bank[acct]
    yield                           # <-- THE ONE LINE THE LIBRARY ADDS
    return v

def g2_db_fetch(bank, acct):
    return g2_io_read(bank, acct)

def g2_repo_get(bank, acct):
    return g2_db_fetch(bank, acct)

def g2_deposit(bank, acct, amt):
    b = g2_repo_get(bank, acct)
    b = b + amt
    bank[acct] = b

def g2_teller(bank, acct, n):
    for _ in range(n):
        g2_deposit(bank, acct, 1)
    yield

This is the same edit — one line, in the library, nothing above it touched — and it does not run. g2_io_read is now a generator function, so calling it returns a generator object without executing a single line of its body. That object travels up through g2_db_fetch and g2_repo_get, both of which are perfectly happy to return something they never look at, and dies in g2_deposit at b = b + amt.

The line that raises is two frames above the line that changed, and it mentions neither generators nor suspension. That distance is why §6.5 matters: the error is loud here only because somebody did arithmetic on the value. §6.5 runs the two callers that don't.

g2_teller ends with a bare yield for a mundane reason: Green.spawn takes a generator, so a task has to be a generator function whether or not it ever suspends. That is the floor of colouring — even the version of this program with no suspension anywhere (g1_teller, lines 160–163) pays it.

5.6 The same program with all four frames coloured

runtimes.py · lines 192–210
def g3_io_read(bank, acct):
    v = bank[acct]
    yield
    return v

def g3_db_fetch(bank, acct):
    return (yield from g3_io_read(bank, acct))

def g3_repo_get(bank, acct):
    return (yield from g3_db_fetch(bank, acct))

def g3_deposit(bank, acct, amt):
    b = yield from g3_repo_get(bank, acct)
    b = b + amt
    bank[acct] = b

def g3_teller(bank, acct, n):
    for _ in range(n):
        yield from g3_deposit(bank, acct, 1)

Four edits, and every one of them is mechanical: put yield from in front of the call and, where the value is used, unwrap the result. Nothing about the program's logic changed. A code reviewer would approve this diff in seconds, and would be approving a diff that does not fix the bug.

Look at g3_deposit beside sf_deposit. It is now completely explicit: b = yield from g3_repo_get(...) says, in the source, on the line, that this task may lose the CPU here — and then two lines later it writes b back anyway. The information that the stackful column was missing is present, visible, and syntactically unavoidable, and the program produces the same 150. That is the strongest single sentence this toy can offer about colouring: it is a notification mechanism, and the notification is delivered to a reader who has to know what to do with it.

5.7 chain — the depth experiment, generated rather than asserted

runtimes.py · lines 242–270
def chain(depth, style):
    """Source for a task with `depth` frames above its suspending call.

    style: 'stackful' | 'coloured' | 'uncoloured' (the last two stackless).
    Returns (source, edits), `edits` counting the frames that had to gain a
    suspension marker they did not previously carry.
    """
    mark = "    suspend()" if style == "stackful" else "    yield"
    if depth == 0:                          # the task body suspends directly
        return "\n".join(["def task():", "    v = 7", mark,
                          "    out.append(v + 1)"]), 0
    src = ["def leaf():", "    v = 7", mark, "    return v"]
    call, edits = "leaf()", 0
    for i in range(depth - 1):
        if style == "coloured":
            src += [f"def f{i}():", f"    return (yield from {call})"]
            edits += 1
        else:
            src += [f"def f{i}():", f"    return {call}"]
        call = f"f{i}()"
    if style == "coloured":
        src += ["def task():", f"    v = yield from {call}",
                "    out.append(v + 1)"]
        edits += 1
    else:
        src += ["def task():", f"    out.append({call} + 1)"]
        if style == "uncoloured":
            src += ["    yield"]           # a Green task must be a generator
    return "\n".join(src), edits

"Colouring costs D edits at depth D" is the sort of statement that is obviously true and worth not believing. So the toy writes the program at each depth, in all three styles, execs it and runs it. The edits counter is incremented at the exact places a yield from is emitted, so it counts something that happened rather than something asserted.

The depth == 0 branch is the boundary, and it is deliberately a separate case rather than a loop that happens to run zero times. At D = 0 the suspension is in the task's own body: there is no caller to inform, so the uncoloured and coloured styles produce identical source, and so does the stackful one modulo the spelling of the primitive. §6.7 runs that row, and it is the one place on this page where the two runtimes are the same tool.

out.append({call} + 1) rather than out.append({call}) is load-bearing in a small way. The + 1 is what forces the caller to consume the value, which is what makes an un-coloured caller fail loudly. Drop it and the uncoloured column passes silently at every depth — which is not a bug in the experiment, it is §6.5.


6. The demo, and what it proves

python3 demo.py

Seven panels; panel 7 spawns OS threads until macOS refuses, which takes about twenty seconds. Everything else is instant.

6.1 The headline

stackless-vs-stackful: the suspension point, and what it costs ====================================================================== macOS-26.5.2-arm64-arm-64bit-Mach-O arm64 Python 3.15.0a8 (main, Apr 14 2026, 14:20:41) [Clang 22.1.3 ] GIL: enabled switchinterval=5.0 ms workload: 2 tellers x 50 deposits of 1 into one account starting at 100. The only correct answer is 200. The critical section is five frames deep. 1. THE HEADLINE -- one line is added to a library function ====================================================================== ACT 1 -- library v1: io_read does not suspend stackful (OS threads) final=200 switches=2 OK stackless (generators) final=200 switches=2 OK ACT 2 -- library v2: io_read gains one line and starts suspending between reading the row and returning it. No frame above it is edited, in either column. stackful (OS threads) final=150 switches=102 WRONG stackless (generators) REFUSES TO RUN runtimes.py, line 182, in g2_deposit b = b + amt TypeError: unsupported operand type(s) for +: 'generator' and 'int' ACT 3 -- stackless, after colouring every frame above io_read stackless (generators, fixed) final=150 switches=100 WRONG frames that had to be edited to make the program run again: stackful: 0 stackless: 4 (db_fetch, repo_get, deposit, teller) Colouring bought visibility. It did not buy correctness.

The switches column is worth deriving, because it shows the two runtimes doing the same amount of scheduling.

Same interleaving, same number of switches, same answer. The only thing that differs between ACT 2 and ACT 3 is how many source files had to change.

6.2 Where 150 comes from

2. WHERE 150 COMES FROM ====================================================================== Round-robin lockstep: in each round every teller reads the same balance v and writes v+1, so a ROUND advances the balance by 1 no matter how many tellers are in it. 50 rounds => 100 + 50 = 150 100 increments were performed, 50 of them were lost. predicted final = START + deposits, independent of teller count: tellers deposits increments predicted observed 2 10 20 110 110 2 50 100 150 150 3 10 30 110 110 3 50 150 150 150 4 10 40 110 110 4 50 200 150 150 Adding tellers adds zero correct increments.

Both schedulers are round-robin, and the suspension sits at a fixed point in io_read, so the tasks march in lockstep. Every teller reads bank["acct"], every teller suspends, every teller then writes v + 1 — the same v, and therefore the same v + 1. The last writer wins and the balance has advanced by exactly one, whether two tellers or eight were in the round.

So final = START + deposits. With START = 100 and deposits = 50: 150, and 2 × 50 − 50 = 50 increments lost — half of them.

The tellers column is the part worth staring at. At 4 tellers the program performs 200 increments and the balance still moves by 50. Adding concurrency added exactly zero correct work. This is not "throughput scaled sublinearly"; it is a program whose useful output is independent of how much of it you run.

6.3 The load-bearing line, and the inert one

3. THE LOAD-BEARING LINE, AND THE INERT ONE ====================================================================== The suspension moved by one position inside the same function. It is io_read either way, and it suspends either way: no suspension at all final=200 lost=0 OK suspend(); v = bank[acct] final=200 lost=0 OK v = bank[acct]; suspend() final=150 lost=50 WRONG The inert line: colouring itself. stackful, nothing coloured final=150 stackless, all 4 frames coloured final=150 Identical. Four edits, no change in behaviour.

The load-bearing line: suspend() moved by one position inside sf_io_read. 200 → 150. It is the same function either way, it suspends the same number of times either way, and the number of tasks, deposits and context switches are unchanged. The only thing that moved is whether the suspension falls between the read and the write.

This is the best line in the toy, because the two variants are indistinguishable to every caller and to every reviewer who is not reading the library's body. io_read suspends: that fact, which is the only fact an await would have told you, is true in both. What matters is a detail one level below the interface.

The inert line: the colouring. Four frames edited, 150 before and 150 after. It is worth being blunt about what this rules out. Colouring is not a correctness mechanism, does not serialise anything, does not shorten the critical section, and does not prevent the interleaving. It writes the fact of a possible switch into the source at every level, and stops there.

6.4 Certain against rare

This is a claim about real runtimes, so it is measured against real ones — real asyncio, real threading, not the toy.

5. CERTAIN vs RARE -- real asyncio against real threads ====================================================================== A. the identical workload, 20 trials each, expected 200 cooperative (asyncio) values=[150] wrong on 20/20 trials preemptive (threads) values=[200] wrong on 0/20 trials B. how hard preemption has to be pushed to lose even one update (5 trials per row; `pad` widens the window with dead loops) deposits pad trials wrong worst loss 50 0 5 0 0 (0.0000% of increments) 50,000 0 5 0 0 (0.0000% of increments) 50,000 20 5 2 5,059 (5.0590% of increments) 200,000 0 5 5 200,000 (50.0000% of increments) 200,000 20 5 5 200,000 (50.0000% of increments) Cooperative scheduling did not make the race rarer. It made it the only behaviour the program has.

Real asyncio reproduces 150 exactly, on every trial. The toy is not inventing its model: await asyncio.sleep(0) between the read and the write of a two-task gather is the same program, and it produces the same number.

Panel B is deliberately noisy, and its noise is the finding. Preemption survived 50,000 deposits — a thousand times the workload panel A used — without losing a single update. It took a padded window to make it fail at all, and even then on 2 of 5 trials rather than 5. Only at 200,000 deposits did it fail every time. Row by row this table is different on every run of the demo; the row that has not moved across any run behind this page is the first one, 50 deposits, pad 0, 0 wrong, which is the row that matches panel A.

One machine, one run Panel B is a distribution, not a constant. The trial counts are printed (20 and 5). Re-run it and the wrong and worst loss columns will move. The cooperative row will not.

That asymmetry is the whole point. The cooperative failure is total (50% of increments), reproducible (20/20), and derivable in advance from the source (§6.2). The preemptive failure needs the OS to interrupt a thread inside a window of a few bytecodes — CPython checks for a thread switch every 5 ms by default, and 100 iterations of a tight loop finish long before that — so it missed 20 times out of 20 at the workload where the cooperative version was certain.

The received wisdom is "cooperative concurrency is safer, because you know where you yield". Both halves are true and the conclusion is backwards. You do know where you yield. That is exactly why the failure is total and reproducible rather than a one-in-a-million heisenbug. It is not a race you will catch in staging, because it is not a race: it is the only behaviour the program has.

The narrower, still-true version of the folklore: cooperative scheduling eliminates data races — no torn reads, no half-written objects, no need for a mutex around a dict. It does nothing whatever about invariant races, which is what a lost update is.

6.5 The limit of the safety net

§6.1 said the stackless runtime "refuses to run". That claim is false as stated, and here is the counterexample.

6. THE LIMIT OF THE SAFETY NET ====================================================================== 'stackless refuses to run' holds only where the caller CONSUMES the value. Same un-coloured generator, three callers: uses it (b['bal'] + 1) -> TypeError: 'generator' object is not subscriptable forwards it (log.append(b)) -> 'row logged' NO ERROR truth-tests it (if b: ...) -> 'found' NO ERROR what got logged in the forwarding case: [generator], and it was never run Same three, against a real `async def` coroutine: uses it -> TypeError: 'coroutine' object is not subscriptable truth-tests it (if user_exists) -> 'found' NO ERROR forwards it -> logged coroutine, NO ERROR the only thing Python said, and it said it at GC time: RuntimeWarning: coroutine 'panel_silence.<locals>.real_io_read' was never awaited bool() of an unstarted generator or an un-awaited coroutine is unconditionally True, so `if user_exists():` is always taken.

The type error only fires because g2_deposit did arithmetic. Two extremely ordinary things a caller might do instead:

Python's only defence is a RuntimeWarningcoroutine '...' was never awaited — emitted by the garbage collector, at an unpredictable time, often long after the request that caused it has been answered, and only for async def, never for a plain generator. It is a real safety net and it has large holes in it.

So the honest form of the headline is: the stackless runtime refuses to run where the caller consumes the value, and fails silently where it does not. That is still better than the stackful column, which has no signal at any call site. It is not the categorical guarantee "colouring makes it a compile error" suggests.

6.6 What a task costs

This is the reason anybody tolerates any of the above.

7. WHY ANYONE ACCEPTS COLOURING AT ALL ====================================================================== ONE MACHINE, ONE RUN. These are the only wall-clock and memory numbers on the page; the banner above is their whole scope. state of suspended tasks green, 10,000 one-frame tasks (tracemalloc) 1,925,392 B 192.5 B/task green, 10,000 suspended g3_teller chains -- 5 generator frames each, because that is where the toy's tasks suspend 11,765,352 B 1176.5 B/task = 235.3 B/frame stackful, 2,000 parked OS threads (RSS delta) 77,578,240 B 38789.1 B/task ratio against the one-frame task: 201.5x against the 5-frame chain: 33.0x spawning the 2,000 threads took 92.2 ms (46.1 us each) ceiling green threads alive at once: 100,000 (no error) OS threads alive at once: 16,383 -> RuntimeError: can't start new thread

Three numbers and one derivation.

192.5 B/task is a suspended one-frame generator, measured with tracemalloc, which sees Python heap allocations exactly. 1176.5 B/task is one of this toy's own tasks — a g3_teller suspended five yield from levels down, so five live generator objects. 1176.5 / 5 = 235.3 bytes per frame, and the two measurements agree to within the size of a frame's locals. That is the arithmetic behind the number, and it is also a fact about the stackless design worth keeping: a stackless task's memory is proportional to how deep it was when it suspended, because the suspended chain is the stack. Colouring is not only a source-code tax.

38,789 B/task is a parked OS thread, measured by RSS delta because a thread's stack is not a Python allocation and tracemalloc cannot see it. It is resident memory, not reserved: RLIMIT_STACK on this machine is 8,372,224 bytes, so each thread reserves 8 MB of address space and touches about 38 KB of it.

16,383 is where threading stopped, with RuntimeError: can't start new thread. It was 16,383 on every run — 2^14 − 1, a hard macOS limit rather than a memory exhaustion, since 16,383 × 38 KB is well under this machine's RAM. Green tasks hit no limit at 100,000, which is where the demo stops asking.

These are the only timing- and memory-derived numbers on this page. They are one machine's, on one run. Across four runs of the panel the tracemalloc figures were byte-identical every time, the OS-thread ceiling was exactly 16,383 every time, and the RSS figure moved between 38,789 and 39,002 B — a spread of 0.5%.

6.7 The boundaries — where the effect vanishes

Three of them, all measured.

4. BOUNDARIES -- where the effect vanishes ====================================================================== (a) how many tellers it takes tellers=1 final=150 expected=150 applied=50 lost=0 tellers=2 final=150 expected=200 applied=50 lost=50 tellers=3 final=150 expected=250 applied=50 lost=100 tellers=4 final=150 expected=300 applied=50 lost=150 tellers=8 final=150 expected=500 applied=50 lost=350 One teller has nothing to interleave with. The effect needs 2. (b) call depth D = frames above the suspending call, task body included. The bank workload is D=4. stackful stackless, uncoloured coloured D=0 edits=0 ok runs, edits=0 edits=0 D=1 edits=0 ok TypeError edits=1 D=2 edits=0 ok TypeError edits=2 D=3 edits=0 ok TypeError edits=3 D=4 edits=0 ok TypeError edits=4 At D=0 the suspension is in the task's own body: zero frames are forced to change in either runtime, and the uncoloured stackless column runs too. The two runtimes are indistinguishable. Every tutorial example is written at D=0.

Boundary 1 — one teller. tellers=1 gives final=150 and lost=0: with one task the "correct" answer is 150, because 50 deposits were performed. Nothing interleaves, so the suspension inside the critical section is harmless. The effect needs at least two tasks sharing the state, which is the same condition as any lost update.

Boundary 2 — the suspension outside the critical section. From §6.3: suspend(); v = bank[acct] gives 200. The suspension may stay; only its position matters. A library that suspends before touching shared state, or after it has finished with it, is free.

Boundary 3 — call depth 0, and this is the important one. At D = 0 the suspending code is the task's own body. Nothing is forced to change in either runtime; the uncoloured stackless column runs perfectly happily, because there is no un-coloured caller to be silent about anything. The two runtimes are, at that depth, the same tool with different spelling.

Every toy example of async code is written at D = 0. async def main(): await fetch(url) has no intermediate frames. That is precisely why the trade looks free in tutorials, and why the argument between goroutines and async/await only starts to exist at D ≥ 1 — inside somebody's ORM, HTTP client, retry decorator or template renderer, where the call stack between your task and the socket is fifteen frames deep and none of them are yours.

The depth table is generated and executed, not asserted: chain() in §5.7 writes each program, and test_colouring_costs_exactly_D_edits_at_depth_D runs all five depths in all three styles.

6.8 Pinned by tests

python3 test_runtimes.py
ok test_library_v1_is_correct_in_both_runtimes ok test_one_added_line_makes_the_stackful_run_return_150 ok test_the_same_line_makes_the_stackless_run_refuse ok test_colouring_all_four_frames_makes_it_run_and_it_returns_150 ok test_colouring_is_inert ok test_a_round_advances_the_balance_by_one_whatever_the_teller_count ok test_exactly_half_the_increments_are_lost_at_two_tellers ok test_moving_the_suspension_one_line_up_restores_200 ok test_no_suspension_inside_the_critical_section_is_correct ok test_one_teller_loses_nothing ok test_colouring_costs_exactly_D_edits_at_depth_D ok test_a_stackful_runtime_costs_zero_edits_at_every_depth ok test_an_uncoloured_caller_breaks_at_every_depth_above_zero ok test_depth_zero_makes_the_two_runtimes_indistinguishable ok test_the_bank_workload_is_depth_four ok test_the_stackful_race_is_the_same_value_on_every_run ok test_the_stackless_race_is_the_same_value_on_every_run ok test_real_asyncio_is_wrong_on_20_of_20_trials ok test_real_threads_are_wrong_on_0_of_20_trials ok test_an_uncoloured_generator_only_blows_up_when_it_is_consumed ok test_bool_of_an_unawaited_coroutine_is_unconditionally_true 21 tests passed

test_real_threads_are_wrong_on_0_of_20_trials is the only assertion in this repo that is a probability rather than a certainty, which is itself §6.4's lesson. It passed on eight consecutive runs of the suite while writing this page.


7. Design decisions and roads not taken

7.1 What this toy owns, and what mini-asyncio owns

These two toys share a scheduler shape and must not be read as the same lesson.

mini-asyncio is about when a task resumes. It takes stacklessness as given and never mentions it. Its subject is the ready queue and the timer heap: a task that asked for a 1000 ms tick gets one every 1400 ms, and the loop's own instrumentation reports zero lateness. Its result is measured in milliseconds and its closed form is (⌈P/R⌉ + hops)·R.

This toy is about where a task may suspend. It has no timers, no clock, no lateness and no queue arithmetic — deliberately, because that boundary has to stay clean. Its result is measured in lost increments and edited frames, and its closed form is final = START + deposits. Nothing here depends on how long anything took.

The two questions are independent, and each has a runtime that answers it and ignores the other. A goroutine is stackful and preemptive since Go 1.14; a Java virtual thread is stackful and cooperative. This toy's Stackful is stackful and cooperative, which is the combination that isolates the variable it cares about.

work-stealing-queue is a third question again — which worker runs a task — and is untouched by both.

7.2 A baton over OS threads, not real preemption

Stackful could have used bare threads and let the OS interleave them. It hands a baton instead, so exactly one thread runs at a time and switches only at suspend().

That is not a simplification for its own sake; it is what makes the comparison mean anything. With real preemption the stackful column produces a different number nearly every run. Five trials of demo.preempt_trial at 2 × 200,000 deposits, expecting 400,100, gave [284384, 329477, 315982, 370107, 356466] — five distinct wrong answers. A page built on that would be comparing "cooperative, deterministic" against "preemptive, random", which is two variables at once. With the baton, both columns run the same interleaving, produce the same 150, and the only difference left is the suspension point.

The cost of the choice is that Stackful is not what a production stackful runtime looks like. §6.4 pays that back by running the real preemptive version separately, where its randomness is the finding rather than the noise.

7.3 Generators, not greenlet or async def

Three candidates for the stackless column, and two lost.

greenlet is the obvious tool — it gives CPython genuine stackful coroutines by switching C stacks, and gevent is built on it. It is out because it is a third-party C extension (the repo's toys are stdlib-only), and more importantly because using it would delete the toy: with greenlet both columns are stackful and there is nothing left to compare.

async def would look more modern, and §6.4 uses real coroutines to check the claim. It is not the toy's stackless runtime because a coroutine cannot yield a value to its driver without an awaitable protocol, so the toy would need an __await__ shim purely for appearances — and because yield from makes the colouring visible as a chain, which is the thing being counted. Every conclusion transfers: await is yield from with a keyword.

sys.setrecursionlimit games / trampolines. A trampoline (return a thunk, let the scheduler call it) is a third way to get suspension at depth without a stack. It is absent because it colours harder than yield from — every frame must return a thunk rather than a value — and would make the toy about CPS, not about the choice it is examining.

7.4 A bank, not a socket

The workload is a lost update on an integer, which is a database example, not a concurrency-runtime example. That is on purpose. A socket workload would need I/O, a selector and a reason for the reader to trust that the toy's io_read resembles a real one; the bank needs none of that, and the correct answer is a number a reader can verify in their head.

The cost is that the failure looks like a database problem, and readers may file it under "use a transaction". §9's last question is aimed squarely at that reflex.

7.5 The context-switch ratio, measured and dropped

The obvious fifth number for §6.6 is what a switch costs each way. It is not on this page, and the omission is deliberate.

Measured six times on this machine (200,000 generator resumptions, 20,000 semaphore baton handoffs between two threads, per run): the generator send was stable at 29.9–38.8 ns; the OS-thread handoff came out at 6,913, 6,989, 7,072, 7,075, 8,936 and 9,359 ns. That is a quotable ratio anywhere between 222× and 312× depending on which run you print.

A number whose second significant figure depends on what else the laptop was doing is not a result, it is a mood. The repo's rule is that a reader must be able to re-derive every number on the page; a 40% spread makes that impossible, so the number does not ship and there is no switch-cost code in demo.py. What can be said and reproduced is the memory ratio and the ceiling in §6.6, both of which were stable to under 1%.

7.6 Roads not taken


8. What's simplified vs. the real thing

8.1 The memory ratio is the price of OS threads, not of stackfulness

This is the disclosure that matters most, because it is the one place the toy could mislead you into a wrong conclusion about real systems.

§6.6 measures 38,789 bytes per stackful task against 192.5 per green one, and it would be easy to read that as "stackful tasks are 200× more expensive". They are not. That is the price of an OS thread, which is the only stackful task CPython can build.

Go starts a goroutine on a 2 KB stack — stackMin = 2048 in src/runtime/stack.go — and grows it by allocating a bigger one and copying the old one into it, adjusting every pointer that referred to the old stack as it goes. Java's virtual threads do something comparable, unmounting a continuation's stack chunks onto the heap while it is parked. Both are stackful, both suspend from arbitrary depth, and both cost a few hundred bytes for a task that has not gone deep.

What that requires is a runtime that knows where every pointer into a stack lives — precise stack maps, its own calling convention, and a garbage collector that cooperates. CPython has none of those; its frames are entangled with C stack frames from every extension module on the stack. There is no supported way to relocate one. That is why Project Loom took a decade, and why greenlet has to do it by copying raw C stack segments and hoping.

So the honest reading of §6.6 is narrower than it looks: in Python, choosing stackful means choosing OS threads, and that is what costs 38 KB and caps you at 16,383. In Go or on the JVM, the memory column of this comparison largely disappears and only the source-code column — §6.7's D edits — remains.

8.2 The other simplifications


9. Check yourself

Answer before expanding. Each answer is derivable from the source.

Question 1

ACT 2 in §6.1 shows the stackful column returning 150 and the stackless one raising. Which of the two runtimes has the bug?

Answer

Neither, and both. The bug is in sf_deposit/g2_deposit — a read-modify-write with no protection — and it was already there in ACT 1, where both columns returned 200. What the library's one added line changed is not the correctness of the program but whether the latent bug is reachable.

That is the useful framing: a suspension point does not create a race, it activates one. Every read-modify-write in your codebase over shared state is a lost update waiting for a suspension point to be introduced between its two halves, and in the stackful column that introduction is invisible at every call site.

The stackless runtime does not find the bug either. It finds a type error — that somebody added an int to a generator — and the fact that this error happens to be raised in the same function as the bug is a coincidence of this program's shape. §6.5's forwarding case is the same bug with no error at all.

Question 2

§6.3 shows that colouring all four frames changed nothing (150 → 150). So does colouring have any value? Answer using the transcript.

Answer

Yes, and the transcript shows exactly what it is and is not worth.

What it is worth: in ACT 2, the stackless program would not run. A developer who made this library change and ran the test suite once would find out immediately, at b = b + amt. The stackful program ran, produced a number, and produced a plausible one — 150 is not obviously wrong unless you know the answer should be 200.

What it is not worth: 150 in ACT 3. The four edits are mechanical, a reviewer approves them in seconds, and the resulting g3_deposit — which now says b = yield from g3_repo_get(...) in plain sight on the line above b = b + amt — still loses half the increments.

So colouring converts a silent behaviour change into a compile-time-ish failure, and then hands the actual decision back to a human. Its value is entirely in when you are told, not in what is done about it. And §6.5 bounds even that: forward the value or truth-test it and you are told nothing.

Question 3

§6.7 says the two runtimes are indistinguishable at call depth 0. If that is true, why does anybody argue about this?

Answer

Because D = 0 is where the examples are and D ≥ 1 is where the code is.

The depth table shows the cost is exactly D frame edits for stackless and 0 for stackful, at every depth. At D = 0 both are 0 — the suspension is in the task's own body, the person writing await is the person who called the suspending function, and nothing is hidden from anybody. That is async def main(): await fetch(url), which is every tutorial ever written.

Real code lives at the other end. The stack between your task and the socket runs through an ORM, a connection pool, a retry decorator, a tracing middleware and a serialiser, and most of those frames belong to somebody else. Making a library function suspend at D = 15 means fifteen frames must change, across four packages you do not own — which is why ecosystems end up with two parallel universes of libraries (requests and httpx, psycopg2 and asyncpg) rather than one.

The corresponding stackful claim is the honest one to compare against: 0 edits at every depth, and therefore no signal at any depth. You get to add suspension to a library without a coordinated ecosystem migration, and you get §6.1's silent 150.

Question 4

§6.4 measures real threads wrong on 0/20 trials and real asyncio wrong on 20/20. Does that mean the threaded version of this program is correct?

Answer

No, and this is the trap the panel is built to spring.

The threaded version has exactly the same bug. Panel B pushes it until it shows: at 200,000 deposits it lost 200,000 increments — 50.0% of them, the same fraction the cooperative version loses at 50 — on 5 of 5 trials. Nothing was fixed between panel A and panel B except how many chances the OS had to interrupt at the wrong moment.

What 0/20 means is that at this workload the window is a few bytecodes wide and CPython's switch interval is 5 ms, so the interpreter simply never looked at the thread boundary in the middle of the critical section. That is a property of the timing, not of the code, and it changes with load, with CPU count, with a GC pause landing in the window, and with the free-threaded build where there is no GIL at all.

The correct summary is: cooperative turned a rare bug into a certain one, and preemptive left it rare. Rare is worse to find and better to survive. Neither is correct. The only thing that makes this program correct is not suspending between the read and the write — §6.3's suspend(); v = bank[acct] row — or holding a lock across both.

Question 5

Reaching past the toy: you maintain a library with a synchronous get_user(id) used by hundreds of callers. You want it to do a network fetch and suspend. What are your options, and what does each cost, in this page's terms?

Answer

Four, and the page prices three of them.

(a) Colour it. Make it async def and let the error propagate up your callers' stacks. Cost: D edits per call path, in code you do not own, and a release that is breaking for everybody. Benefit: §6.2's g3_deposit — every affected call site now says it can suspend, so a caller holding an invariant across it has a chance of noticing. This is what the Python ecosystem did, and the reason httpx and asyncpg exist as separate packages.

(b) Ship both. get_user and aget_user, the same logic twice. Everybody's least favourite answer and by far the most common. Cost: the implementation is duplicated or wrapped, forever. Benefit: no caller is broken today, and §6.7's boundary is respected — callers who stay at D = 0 never pay.

(c) Run it on a stackful runtime — a goroutine, a Java virtual thread, or greenlet/gevent in Python. Cost: 0 edits, per §6.7's stackful column, which is exactly why gevent's monkey-patching is so seductive. And the bill is §6.1's ACT 2: every caller that was holding an invariant across your function now has a suspension point inside it and no notification, so the first symptom is a wrong number rather than a stack trace.

(d) Keep it synchronous and let callers move it to a thread pool. run_in_executor, and the honest answer for a library that cannot commit. Cost: a thread per concurrent call, at §6.6's 38 KB and a ceiling of 16,383 — which is precisely the cost that made everyone want green threads in the first place, and where this page started.

The thing to carry: (a) and (c) are the same change to your library's behaviour. They differ only in whether your callers are told. Deciding between them is deciding who you trust to notice.


10. Further reading