Skip to content

Follow-up to #17: demos, an explicit API boundary, C++ support, and documentation - #19

Draft
LarryRuane wants to merge 50 commits into
masterfrom
v2-followup
Draft

LarryRuane wants to merge 50 commits into
masterfrom
v2-followup

Conversation

@LarryRuane

@LarryRuane LarryRuane commented Sep 1, 2026

Copy link
Copy Markdown
Owner

If you're smoke-testing this, start here. It's still one header you copy in, so the fastest check is to drop the new protothread.h into something you already have and see whether it builds and behaves. Only three things can affect code you've already written:

  • A protothread may no longer free the storage holding its own pt_thread_t. The scheduler marks a thread exited after its function returns, so freeing that storage from inside the thread is now a use-after-free, and sanitizers won't reliably report it. Free it from whoever owns it, after pt_join() or once pt_is_alive() is false.
  • pt_set_atexit() is gone. if (pt_kill(&c->pt_thread)) { cleanup(c) ; } does the same thing, and it was only ever called in that one case anyway.
  • Internal names picked up a pt_i_ prefix. Anything you were calling that didn't have one is unchanged and still public.

Everything else is additions (pt_join(), pt_is_alive(), timers, interrupt safety, PT_NO_MALLOC), documentation and CI. The commit count is high because nothing has been squashed, but there's no need to read through it.


Follow-up to #17, in nine groups of commits, each independent and reviewable on its own.

1. Check protothread_run() results, and document the context-struct idiom

The suite called protothread_run() 15 times and discarded the answer, including twice on a scheduler with nothing in it. Each call site now checks the result against the contract: false when nothing was ready, otherwise whether the ready list is still non-empty.

Verified they bite, by injecting bugs into protothread_run() and running both the old and new suites:

injected bug old suite new suite
always returns true aborts aborts
"nothing ready" path returns true hangs (20s timeout) caught at :34, names the condition
reports readiness before running caught at :364, 1000-thread test caught at :113, 2-thread test

The middle row is the point: that bug makes while (protothread_run(pt)) ; spin forever, so CI would burn its timeout rather than report a failure.

Also in this commit: the README's "you cannot use the function return value for your own purposes" — which reads as an apology — is replaced with a worked in/out parameter example, since the caller owns the callee's context and it survives blocking. A TODO entry records that *s = (struct protothread_s){0} must not replace protothread_init()'s explicit loop: measured freestanding at PT_NWAIT=1024, the loop needs no libc symbols at any optimization level, while the struct assignment makes gcc emit memset and clang memcpy + memset at -O0 — breaking the zero-libc guarantee in exactly the build used to debug on bare metal. And .gitignore picks up build*/ and .vscode/.

2. Collect the demos into demo/, and add two more

The thread-pool example was the only worked example, and it covered one of the three ways a protothread meets the outside world. protothread_pool_example.c becomes demo/pool.c, joined by:

  • demo/async_io.c — no POSIX threads at all. Concurrent asynchronous I/O with one poll() loop turning completions into signals. ptaio -v traces every submission and completion.
  • demo/helper_thread.c — one throwaway thread per blocking call, created and joined by the protothread that needs it.

The -v trace is why this commit is bigger than it looks. The straightforward version printed 64 submissions in index order followed by 64 completions in index order — a lock-step batch that teaches the reader the opposite of the truth about asynchronous I/O. Two changes fixed it: each protothread now runs a short sequence of I/Os, and the simulated device finishes after a pseudo-random delay. The pipes, the poll(), the blocking and the completion handler are all real; only the latency is simulated, and the comment says so. The 1 ms sleep in helper_thread.c makes the interleaving measurable — 64 helpers finish in 4 ms, not 64 ms, so the concurrency is demonstrated rather than asserted.

The demos also document offloading as the three arrangements it actually is — a thread per call, a standing pool, or a dedicated helper per protothread parked between calls (about 8 kB resident each against 64 bytes for the protothread) — rather than the earlier claim that a thread per protothread is always POSIX threads with extra steps. That is only true of giving each protothread a thread to run on.

3. Mark internal names with a pt_i_ prefix, and list the API

Header-only removed the boundary the linker used to draw: when there was a library, the non-static functions were the API. Now everything is static inline in a header and nothing distinguishes the two. 32 internal functions and macros now carry pt_i_ / PT_I_, protothread.h opens with the complete public API one line each, and the README carries the same list near the top.

A leading underscore — the obvious choice, and the one most header-only C libraries make — is not available. C reserves identifiers beginning with an underscore at file scope, and C++ additionally reserves any identifier containing a double underscore, so stb's stbi__ and sokol's _sg_ are both formally nonconforming. Six struct tags in this repo already had the reserved form (_pt_lock_t and friends) and now use the house _s suffix.

Three list primitives took a critical section that every caller already held (pt_i_link, pt_i_unlink, pt_i_find_and_unlink). PT_CRITICAL_* nests, so these were harmless — but on a target where entering one means touching PRIMASK they were not free. They now document that the caller must already be in a critical section.

That removal was verified rather than reasoned about: a harness defines PT_CRITICAL_ENTER/EXIT as a depth counter and asserts non-zero depth on entry to each function, and the suite runs clean under it across PT_DEBUG, PT_NWAIT and optimization settings, with assertions forced live under PT_DEBUG=0. The first version of that harness was vacuous — the quoted #include resolved to the real header rather than the instrumented copy — which a negative control caught by passing when it was required to fail.

4. Compile as C++, and keep it that way with CI

One line stood in the way: protothread_create() assigns malloc()'s void * to a typed pointer, which C++ will not do implicitly. A C-style cast is valid in both languages and costs C nothing.

CI now builds and runs a real protothread as C++ — yields, nesting through the semaphore, lock and timer helpers, and the computed goto — under g++ and clang++ at -std=c++11, c++17 and c++20. Compiling only the headers would have been cheaper and would have missed whether the machinery works at all.

The README claim is deliberately narrow: C code does not port unchanged, because every protothread function opens by recovering its context from env_t and each one needs a cast. Nor is any of this idiomatic C++ — C++20 coroutines are the native answer for new C++ code. What it offers is one scheduler shared between C and C++ translation units.

One finding worth flagging: clang++ rejects an initialized local declared after pt_resume() outright, because C++ forbids jumping into the scope of a variable with an initializer. That promotes the bug described under "Local variables" from a warning to a hard error — but only that one. The silent case, a variable initialized before pt_resume() and re-initialized on every resume, still compiles, so const remains the only thing that catches it.

5. README structure and wording (three small commits)

Drop the pointer to the multicore fork. The second line of the README sent readers to protothread-multicore, a fork of a much older version of this one that trades away determinism and the freedom from locking discipline — two of the three reasons to use protothreads. That repository is now archived with a note explaining what it was and why it stopped.

Move bare-metal use and interrupt safety near the top. "By default this library is not interrupt-safe" sat at line 494, after the example, the implementation walkthrough, the benchmarks and the versioning policy. Someone writing an ISR that calls pt_signal() reached it, if at all, after writing the bug — and the failure is silent, with no assertion to catch it. It now follows the API listing. The CMSIS PT_CRITICAL_* definitions and the interrupt-latency note were too detailed to lead with, so they became a subsection of Configuration next to where those macros are documented. No prose was rewritten and all internal links still resolve.

Say microcontroller. The README said "MCU" once, in an aside about code size, and otherwise relied on "bare metal" and "embedded" — accurate, but not the word someone deciding whether this runs on their STM32 searches for. It now appears in the two headline bullets, the opening line of the bare-metal section, and beside the toolchain list, which already named four microcontroller compilers without ever using the term.

6. Check the critical-section discipline in CI

Commit 3 removed three redundant critical sections on the grounds that every caller already held one. That property was then documented in comments and nothing more — and PT_CRITICAL_* are no-ops unless the application defines them, so a future change that started touching a list from thread context would break interrupt safety on a real target while passing every test in this repo.

PT_CRITICAL_ASSERT() makes the requirement checkable. It compiles to nothing by default (the freestanding build still needs zero libc symbols at every optimization level), and an application on a target where entering a critical section is observable can define it to audit its own discipline. CI defines it against a depth counter and runs the suite across PT_DEBUG, NDEBUG, PT_NWAIT and four optimization levels on both compilers.

Two things about the job itself:

  • It runs its negative control first and fails the build if the control does not trap. The interesting failure mode here is not a library bug but a harness that quietly tests nothing — which is not hypothetical: the first version of this check, run locally, passed against an uninstrumented header because a quoted #include resolved next to the test file rather than to the instrumented copy. Defining a macro instead of rewriting the header removes that class of mistake entirely, so the job copies and edits no source at all.
  • It uses __builtin_trap() rather than assert(), since NDEBUG and PT_DEBUG=0 are two of the configurations being checked and assert() would vanish in exactly those.

Verified end to end rather than by the control alone: with pt_i_add_ready()'s critical section removed, all 24 configurations fail; with it restored, all 24 pass.

7. Documentation: accuracy, reading order, and the case for the design (20 commits)

All README and TODO; no code changes.

Corrections — the docs were saying things that aren't true.

  • The API reference grouped pt_signal(), pt_broadcast(), pt_kill() and pt_create() under "Either thread or non-thread execution context", which reads naturally as "from any OS thread or interrupt handler" — exactly where pt_signal() loses wakeups. Now split into Creating, waking and killing protothreads (call from the scheduler's OS thread; the rule is overlap, not identity) and Protothread system setup and teardown.
  • The README taught nested protothread functions to take env_t and cast it. pt_call() is a direct call, so a nested function can take its real context type and be fully type-checked — the library's own semaphore, lock and timer code already did. Only top-level functions need env_t. The example was extracted from the README as written and compiled under gcc, clang, g++ and clang++; passing the wrong context to pt_call() is a hard error on both.
  • Two C++ claims narrowed accordingly: only top-level functions need a cast.

Reading order. Bare-metal use and interrupt safety now follow the producer/consumer example, so a newcomer sees what a protothread is before meeting a lost-wakeup race diagram. Verified as a pure move: identical word count, every internal link resolves.

New arguments for the design.

  • Wait channels: a condition variable without the pthread_cond_t; universal, since every blocking primitive here is ordinary protothread code over it (and Linux's futex is the same shape); and reducible to a busy-wait on a predicate with a delay inserted — with the one place that intuition misleads, which is lost wakeups.
  • Stack sizing: protothreads don't abolish it, but reduce it from one worst-case guess per thread to one overall.
  • Determinism made concrete: SAN/iQ's dbd_test, now called deterministic simulation testing, with FoundationDB's Flow as the independent industrial example. It says plainly that most users won't need this, and that making every external interface mockable is most of the work.

Presentation. API reference and Configuration entries now put each prototype inside its own description block. README code blocks use the conventional unspaced semicolon. C++20/C++11 escaped so markdown-it's ++ins++ extension no longer underlines half a paragraph. Removed a duplicate idle loop and a duplicated explanation of channels.

TODO. Three recorded design notes: a lock-free completion queue (and why it replaces only half of what a self-pipe does), a bounded-delay sweep with a lost-wakeup detector, and type-safe top-level protothreads with its measured cost — gcc refuses to inline any function containing a computed goto.

8. Remove pt_set_atexit(), finish the pt_i_ prefix, and add a changelog (6 commits)

pt_set_atexit() is removed. Whoever calls pt_kill() can do the cleanup, exactly equivalently, since the callback ran only when the kill found the thread, after the unlink, in the caller's context:

if (pt_kill(&c->pt_thread)) {
    cleanup(c) ;
}

It cost a pointer in every protothread, including in programs that never kill anything; was named after atexit() but never ran on normal exit; and could run a free() in interrupt context. Measured, not computed: the minimum RAM per protothread drops from 32 to 28 bytes on Cortex-M and from 64 to 56 on x86-64, taking the benchmark's memory ratio against pthreads from 256x to 293x.

The pt_i_ prefix now covers types and enum constants too, so "any name without the prefix is public API" is literally true: state_t, pt_critical_t, PT_RETURN_*, pt_return_e/_s, pt_lock_state_t, the PT_LOCK_* request states and pt_time_diff_t. state_t is deleted rather than renamed, since it was a second typedef for the type protothread_t already names, and repeating that typedef is an error under -std=c99.

CHANGELOG.md, in Keep a Changelog format. Building it from the history exposed that the README's "Upgrading from 1.x" notes omitted the renamed internals and struct tags; they are listed now.

README:

  • The API section shows only the core — 18 entries in protothread.h, of which the producer/consumer example uses 9. Semaphores, reader-writer locks and timers are presented as optional layers built on it, with their reference under a new "Built on top" heading. The headers, tests and CI coverage are unchanged.
  • A paragraph on why the blocking macros are lowercase: readability, with precedent in the C standard's own setjmp, assert and va_arg and the kernel's wait_event().

Verified before pushing: the 70-configuration matrix, all twelve language standards including C99, ASan/UBSan, zero libc symbols freestanding, C++11 and C++20, the demos, and the critical-section and C++ CI jobs run verbatim from the workflow.

9. Nothing may depend on pt_signal() waking only one waiter (4 commits)

A wakeup is a hint that a condition may have changed, never a promise, so waking one waiter rather than all of them should be an optimization that correct code never relies on. PT_SIGNAL_WAKES_ALL=1 makes pt_signal() behave as pt_broadcast(); it defaults to 0 and compiles to nothing. A new CI job, signal wakes all, builds the test suite and the three demos that way on gcc and clang.

The job checks its own switch first. With two consumers and a producer on one channel, the program stalls when pt_signal() wakes one waiter — a consumer absorbs the wakeup the producer needed — and completes when it wakes everyone. A macro that silently stopped working would otherwise leave the job green while testing nothing.

That same program is why the README's new text is careful about the converse. Waking everyone is always safe, but pt_signal() is a safe substitute for pt_broadcast() only when every waiter on the channel could use the event. The Wait channels section now says so, and explains where the producer/consumer example uses while rather than if. The library's own semaphores, locks and timers never call pt_signal() at all.

Also: README "What you get" tightened, the CMake find module described in plain terms, and a typo.

pt_broadcast() is now the README's stated default, as the Unix kernel's wakeup() always was. pt_signal() is safe only when every waiter on the channel waits for the same condition, the woken waiter is certain to run, and it leaves the condition false or passes the wakeup on — properties of the whole program, not of the call. What pt_signal() saves is a thundering herd, which needs many waiters on one channel, and on a microcontroller that rarely exists; where it does, splitting the channel is free. The producer/consumer example and its expansion now use pt_broadcast() to match; with one producer and one consumer the behavior is identical.


Verified locally, beyond what CI runs: 70-configuration C matrix on gcc and clang (PT_DEBUG, NDEBUG, PT_NWAIT of 1/4/1024, -O0 through -Os), twelve language standards, six C++ configurations, zero libc symbols under -ffreestanding at every optimization level, ASan, UBSan, TSan, the three demos, and the cmake build and install — all under -Wall -Wextra -Werror.

🤖 Generated with Claude Code

The test suite called protothread_run() 15 times and threw the answer away,
including twice on a scheduler that had nothing in it.  Every call site now
checks the result against what the contract requires: false when nothing was
ready, otherwise whether the ready list is still non-empty.

These are not decorative.  Injecting a bug that makes the "nothing is ready"
path return true sends the old suite into an infinite "while (protothread_run
(pt)) ;" -- CI would burn its timeout and report nothing.  The new check
catches it on the second line of the first test and names the condition.  A
subtler injection, reporting readiness before running the thread rather than
after, was already caught by the old suite, but 250 lines deep in a
thousand-thread producer/consumer test; it now fails in a two-thread test that
points at the return value itself.

README: the claim that "you cannot use the function return value for your own
purposes" read as an apology for a limitation.  It is not one.  The caller owns
the callee's context structure, so that structure is a better return channel
than a return value: any number of values of any types, no copying, no extra
storage, and unlike a return value it survives blocking.  Replaced the sentence
with a worked in/out example, compiled and run for both the success and the
short-read paths.

TODO: record that protothread_init()'s explicit loop must not be "simplified"
to *s = (struct protothread_s){0}.  Measured at PT_NWAIT=1024, freestanding:
the loop needs no libc symbols at any optimization level, while the struct
assignment makes gcc emit memset and clang emit memcpy and memset at -O0.  That
breaks the zero-libc guarantee in exactly the build someone debugging bare metal
would use.  -ffreestanding does not prevent it; the standard permits emitting
those calls even in freestanding mode, which is why CI's -O0 case matters.

Also ignore build*/ and .vscode/, so an out-of-tree debug build configured with
-DCMAKE_BUILD_TYPE=Debug does not show up as untracked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SR7v9BVsFrHiEhQBSqZmAs
LarryRuane and others added 3 commits September 11, 2026 13:40
The thread-pool example was the only worked example, and it covers only
one of the three ways a protothread meets the outside world.  Add the
other two, and move all three into a directory of their own, since one
example did not need one and three do.

async_io.c is the case protothreads are actually best at: asynchronous
I/O with no POSIX threads anywhere, many operations in flight, and a
poll() loop turning completions into signals.  Run it with -v and it
traces every submission and completion.

Making that trace worth looking at took two changes.  Each protothread
now runs a short sequence of I/Os rather than one, so submissions and
completions have something to interleave with, and the simulated device
finishes requests after a pseudo-random delay rather than instantly, so
they come back out of order.  Without both, the trace is 64 submissions
in index order followed by 64 completions in index order -- a lock-step
batch that would teach the reader the opposite of the truth about
asynchronous I/O.  The pipes, the poll(), the blocking and the
completion handler are all real; only the latency is simulated, and the
comment says so.

That leaves the demo's completion order varying from run to run, which
the header comment addresses head-on rather than hiding: the protothread
scheduling is still entirely determined by the completion events, and
what varies is the wall-clock-anchored device.  A test would simulate
time too, as the README's determinism section already advises.

helper_thread.c is the cheap middle ground: a call that blocks, has no
asynchronous form, and happens rarely enough that creating a thread for
it and joining it beats owning a pool.  The helper is a temporary
extension of the protothread that started it.

Offloading a blocking call is really three arrangements, and both the
demo comment and the README now say so.  A thread per call is cheapest
when the call is rare; a pool is right when the work is constant and hot
or the protothreads far outnumber the threads you want; and between them
sits a dedicated helper per protothread, parked between calls, which
costs about 8 kB resident each against 64 bytes for the protothread, and
in exchange never makes one protothread queue behind another for a
worker and lets a helper hold state a stateless pool worker cannot --
a connection, a thread-bound library handle.  The claim these notes
previously made, that a thread per protothread is always pthreads with
extra steps, was too broad: it is only true of giving each protothread a
thread to *run on*, which none of the three does.

All three obey the rule pool.c already explained: the completion side
never calls pt_signal() itself, only records what happened and lets the
thread that owns protothread_run() signal between runs.

Verified: gcc and clang, -O0 through -Os, clean under ASan, UBSan and
TSan.  The 1 ms sleep in helper_thread.c makes the interleaving
measurable -- 64 helpers finish in 4 ms, not 64 ms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SR7v9BVsFrHiEhQBSqZmAs
Header-only removed the boundary that used to be drawn by the linker:
when there was a library to link against, the non-static functions were
the API and everything else obviously was not.  Now every function is
static inline in a header and nothing distinguishes the two.

Internal functions and macros now carry pt_i_ or PT_I_, so the prefix
alone answers the question at the call site, and protothread.h opens
with the whole public API, one line each.  README gets the same list
near the top.

A leading underscore, the obvious choice and the one most header-only C
libraries make, is not available: C reserves identifiers beginning with
an underscore at file scope, and C++ additionally reserves any
identifier containing a double underscore.  stb's stbi__ and sokol's
_sg_ are both formally nonconforming.  A pt_i_ prefix is conforming in
both languages and greps as a unit.  Six struct tags already had the
reserved form (_pt_lock_t and friends) and now use the house _s suffix.

Three list primitives took a critical section that every caller already
held: pt_i_link, pt_i_unlink and pt_i_find_and_unlink.  PT_CRITICAL_*
nests, so these were harmless, but on a target where entering one means
touching PRIMASK they were not free.  They now document that the caller
must already be in a critical section, and pt_i_unlink_oldest says the
same although it never took one.  pt_i_add_ready keeps its own, because
it is reached both from thread context and from inside a critical
section, and it says so.

The removals were verified rather than reasoned about: a harness defines
PT_CRITICAL_ENTER/EXIT as a depth counter and asserts the depth is
non-zero on entry to each of the three.  The full suite runs clean under
it across PT_DEBUG, PT_NWAIT and optimization settings, with assertions
forced live under PT_DEBUG=0.  The first version of that harness was
vacuous -- the quoted #include resolved to the real header rather than
the instrumented copy -- which a negative control caught by passing when
it had to fail.

Verified: 70-configuration test matrix on gcc and clang, twelve language
standards, zero libc symbols under -ffreestanding at every optimization
level, ASan, UBSan, TSan, the three demos and the cmake build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One line stood between the headers and C++: protothread_create() assigns
malloc()'s void* to a typed pointer, which C++ will not do implicitly.
A C-style cast is valid in both languages and costs C nothing -- no new
warning at any optimization level on either compiler.

That is the whole library-side change.  The earlier pt_i_ work removed
the other obstacle without meaning to: six struct tags had the reserved
_pt_lock_t form, and a leading underscore is reserved at global scope in
C++ as well as at file scope in C.

A claim nothing tests stops being true, so CI now builds and runs a real
protothread as C++ -- yields, nesting through the semaphore, lock and
timer helpers, and the computed goto -- under g++ and clang++ at
-std=c++11, c++17 and c++20.  Compiling only the headers would have been
cheaper and would have missed whether the machinery works at all.

The README section is deliberately narrow.  C code does not port
unchanged: every protothread function opens by recovering its context
from env_t, and each one needs a cast, so this is not a drop-in.  Nor is
any of it idiomatic C++; C++20 coroutines are the native answer for new
C++ code, and what this offers is one scheduler shared between C and C++
translation units.

Worth knowing, and documented: clang++ rejects an initialized local
declared after pt_resume() outright, because C++ forbids jumping into
the scope of a variable with an initializer.  That promotes the bug in
the "Local variables" section from a warning to a hard error -- but only
that one.  The silent case, a variable initialized before pt_resume()
and re-initialized on every resume, still compiles, so const remains the
only thing that catches it.  g++ warns where clang++ errors, so the same
source can build under one and fail under the other.

Verified: 70-configuration C matrix on gcc and clang, six C++
configurations, zero libc symbols under -ffreestanding, the three demos,
and the new CI step extracted from the workflow and run verbatim.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@LarryRuane LarryRuane changed the title Check protothread_run() results, and document the context-struct idiom Follow-up to #17: demos, an explicit API boundary, and C++ support Sep 14, 2026
LarryRuane and others added 3 commits September 14, 2026 15:32
The protothread-multicore repository is a fork of a much older version of
this one, is not maintained, and is not being brought forward: it trades
away determinism and the freedom from locking discipline that are two of
the three reasons to use protothreads at all.  Pointing new readers at it
from the second line of the README oversells it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"By default this library is not interrupt-safe" sat at line 494, after
the example, the implementation walkthrough, the benchmarks and the
versioning policy.  A reader writing an interrupt handler that calls
pt_signal() reaches it, if at all, well after writing the bug -- and the
failure it warns about is silent, with no assertion to catch it.

The section now follows the API listing, which is as early as it can go
while still coming after the names it uses.  The "What you get" bullet
that advertises the freestanding build carries the caveat too, since
that bullet is where an embedded reader's eye lands first and it was
previously selling the capability without mentioning the hazard.

Two pieces stayed behind as too detailed to lead with: the CMSIS
PT_CRITICAL_* definitions and the note on interrupt latency and PT_NWAIT.
They are now a subsection of Configuration, next to where those macros
are already documented, and the moved section links forward to them.
The idle-loop skeleton went up with the rest -- five lines, and it is the
shape of the program being described.

No prose was rewritten; the word count difference is the added caveat.
All 14 internal links still resolve.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Someone deciding whether this will run on their STM32 or AVR searches
for "microcontroller".  The README said MCU once, in an aside about code
size, and otherwise relied on "bare metal" and "embedded" -- accurate,
but not the word people reach for.  It now appears in the two headline
bullets, in the opening line of the bare-metal section, and beside the
toolchain list, which already named four microcontroller compilers
without ever using the term.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@LarryRuane

LarryRuane commented Sep 15, 2026

Copy link
Copy Markdown
Owner Author

Hi @TragicWarrior, just wondering if you might want to review (and merge) this, no pressure if you're too busy, hope it's not too much. It's turned into a general improvement/cleanup PR. (Claude code has been amazingly helpful.) If you're not available, I can just self-merge but wanted to give you a chance first. Thanks!

There's really only two changes that are non-fluff (could definitely benefit from review):

  • protothread.h, commit 3 — the three removed critical sections are the only change in the PR that could cause a real bug on an interrupt-driven target. I verified it with a depth-counter harness (see commit comment). Actually, just added an 8th commit to test this in CI.
  • The API list, same commit — whether the public/internal seems right to you.

Three internal functions require the caller to already be in a critical
section.  Until now that was a comment and nothing more: PT_CRITICAL_*
are no-ops unless the application defines them, so a change that started
touching a list from thread context would break interrupt safety on a
real target and pass every existing test in this repo.

PT_CRITICAL_ASSERT() makes the requirement checkable.  It compiles to
nothing by default -- the freestanding build still needs zero libc
symbols at every optimization level -- and an application on a target
where entering a critical section is observable can define it to audit
its own discipline.  CI defines it against a depth counter and runs the
full suite across PT_DEBUG, NDEBUG, PT_NWAIT and four optimization
levels on both compilers.

The job runs its negative control first and fails the build if the
control does not trap, because the interesting failure mode here is not
a bug in the library but a harness that quietly tests nothing.  That is
not hypothetical: the first version of this check, run locally, passed
against an uninstrumented header because a quoted #include resolved next
to the test file rather than to the instrumented copy.  Defining the
macro instead of rewriting the header removes that whole class of
mistake, so the job no longer copies or edits any source.

It uses __builtin_trap() rather than assert(), since NDEBUG and
PT_DEBUG=0 are two of the configurations being checked and assert()
would vanish in exactly those.

Verified end to end: with pt_i_add_ready()'s critical section removed,
all 24 configurations fail; with it restored, all 24 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@LarryRuane

Copy link
Copy Markdown
Owner Author

Hi @TragicWarrior, I'm about to make more changes to this PR, so please don't merge. Actually, I'll set it to DRAFT for now.

I have a question about pt_set_atexit(), which you added back in 2019 (43224c6).

While reorganizing the API reference I've started wondering whether we still need it, and since v2.0.0 isn't tagged yet, now is a good time to decide. The case against:

  • Whoever calls pt_kill() can usually do the cleanup itself, right after the call.
  • It costs a pointer in every pt_thread_t, even in programs that never kill anything. Removing it would drop the minimum RAM per protothread from 32 to 28 bytes on a 32-bit target.
  • Despite the name, it runs only when a protothread is killed, never when it returns normally, unlike C's atexit().
  • If pt_kill() is called from an interrupt handler (allowed once PT_CRITICAL_* are defined), the callback runs in interrupt context, where typical cleanup like free() isn't safe.

The strongest case for keeping it is that generic clean-up code — a supervisor, say — that holds only a pt_thread_t * and doesn't know the context's type, so it can't do type-specific cleanup itself. That's legitimate, though application code could also handle it with its own registry of protothreads and cleanup functions.

Do you still use it, or know of code that does? Nothing in this repo uses it apart from its own test. If you don't have a strong opinion (or don't mind changing your existing code that uses it), I'd prefer to nuke it for version 2.0.0. Thanks!

@LarryRuane
LarryRuane marked this pull request as draft September 16, 2026 20:09
LarryRuane and others added 15 commits September 16, 2026 14:10
The memory section compared sizes -- 32 bytes against an RTOS task's
control block and stack -- but never made the argument that the number
is *knowable*.  That is a different advantage from being small, and for
anyone who has had to pick a stack size for a task it is the more
persuasive one.

Stated carefully, because the strong form is not true: protothreads do
not remove stack sizing, they reduce it from one decision per thread to
one decision overall, with no safety margin multiplied by the thread
count.  The debug-build detail is worth having too -- an -O0 binary
generally wants more stack than the -O2 build a size was measured
against, which is a nasty way to meet the problem.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Records a question raised about the demos: whether the mutex-protected
array in pool.c and the self-pipe in helper_thread.c could be a
lock-free FIFO instead.

The useful part of the answer is the distinction rather than the
conclusion.  A lock-free queue handles the data path, but nothing in
memory can wake a thread asleep in poll(), so the wakeup still needs an
OS primitive.  Once those are separated it is clear why the two demos
already choose differently, and why the answer on bare metal -- where
there are no pipes -- is the flag-and-drain loop the README prescribes.

Also notes the constraint that would bite if this moved into the
library: stdatomic.h is C11 and not freestanding, so it would cost both
-std=c99 and the zero-libc property, while the __atomic_* builtins cost
neither.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The README described the interface and its pedigree but never argued for
it.  Two arguments are worth having, and one closes a TODO item.

It is universal: every blocking synchronization primitive in common use
can be built on wait-and-wake-on-an-address, and this repository
demonstrates rather than asserts that -- the semaphore, the
reader-writer lock and the timer sleeps are ordinary protothread code
over pt_wait() and pt_signal(), with no privileged access to the
scheduler.  Linux is independent evidence for the same claim: futex is
wait-and-wake on an address, and every pthreads primitive sits on one.

And it degenerates to something obvious.  A protothread waits for a
condition to be true, not for an event to happen -- a condition can be
re-tested, an event can be missed -- so the simplest correct wait is a
spin on the predicate.  pt_wait() changes none of that logic; it only
inserts a delay so the spinning is cheap.  That framing explains why the
predicate is re-tested in a while and not an if, and why a premature
wakeup is harmless.

It also has a limit, which the section names rather than glosses: a real
spin loop cannot miss anything, while pt_wait()'s delay ends only when
somebody signals, so a lost signal is a permanent wait rather than a
slow one.  That is the one place the intuition misleads, and it is where
the Lost wakeups section takes over.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An idea from Bell Labs days: if a signal can be lost through a bug,
waking anyway after a timeout keeps the system running instead of
hanging, and logs so the defect can be found.

Recorded with the justification rather than just the mechanism, because
the justification is what makes it principled rather than a band-aid.
The README now argues that pt_wait() is a busy-wait with a delay
inserted; a real spin loop cannot miss anything because it re-tests
continuously, so bounding the delay buys back precisely the property
that was traded away.

Two things worth having in writing.  A global sweep does not need the
wait-with-timeout machinery listed above it -- it sidesteps the
single-channel, single-next constraint that blocks that item, which
makes it the easier design as well as the more useful one.  And the
detector is separable from, and more valuable than, the recovery: a
protothread that makes progress after a sweep wake had a true predicate
that nobody signalled, and PT_DEBUG already records the file and line to
name the site.

Also records the objection, since it is the strongest one: self-healing
hides bugs, and a hang is easy to diagnose with the gdb macros this
repository ships.  That argues for building the detector before the
recovery.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The determinism section argued its case for four paragraphs without ever
showing the shape of a system where it pays off, so a reader could not
tell whether it was an engineering advantage or a theoretical nicety.
SAN/iQ settles that: dbd_test ran many simulated nodes in one address
space over a simulated network on simulated time, dropped and reordered
messages, crashed and recovered nodes, all from one seeded generator --
and a failing seed reproduced the run exactly.

The section also oversold itself by omission.  It is now explicit that
most users will not need this, and that sequencing a few activities on a
microcontroller is not the case it is arguing for.  Saying who something
is for costs a sentence and makes the rest more credible.

Two things kept because they are true rather than flattering:
protothreads came too late for SAN/iQ, whose event loop was already
written and optimized by hand -- which is precisely the origin story,
and explains why this implementation has nesting, wait channels and a
real scheduler.  And the twenty-minute time box, because a bug that
takes two days to appear takes two days to reproduce.

Links the "Designing Testable Software" presentation, originally by
SAN/iQ's architect Mark Hayden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every headline bullet in "What you get" was an efficiency or packaging
claim -- header-only, C++, no libc, tiny, fast -- while the strongest
argument for choosing this over POSIX threads in a serious concurrent
system sat four hundred lines further down, and the word deterministic
appeared at the top only as a scheduling property with no payoff
attached.

Efficiency arguments are contingent on scale: a 720x context switch is
irrelevant at a hundred switches a second, and 32 bytes against 16 KB is
irrelevant with 64 MB to spare.  Reproducibility is not contingent, and
gets more valuable as a system gets more concurrent -- which is the
direction that makes the pthreads-or-protothreads decision hard in the
first place.

The bullet says in the same breath that it is not free, because it
mostly is not: swapping out the network and time itself is the bulk of
the work, and an architect will think of exactly that within seconds of
reading the claim.  Better to concede it first.  The section now states
the boundary plainly -- protothreads supplies the one source of
nondeterminism that is not yours to control, and the rest is work you
are at least permitted to do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"up the the user", and "Example 2" numbering left over from a document
that once had an Example 1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The determinism section rested on one storage product most readers will
not have heard of.  Naming the modern term gives them something to
search for, and FoundationDB is a far heavier citation: they built Flow,
an extension giving C++ single-threaded actor concurrency, specifically
so an entire cluster could run deterministically in one process with the
network, disk, clock and RNG replaced by shims.

That is the same architecture SAN/iQ used, arrived at independently by
people who wanted the same testing property -- which is the argument the
section is making, now with evidence from outside this repository.

Also distinguishes it from fuzzing, since that is the reasonable first
guess: the randomness here is in the environment and the schedule rather
than in the inputs to one component, and the scope is a whole system
rather than a single function.

Both links verified live.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
markdown-it's ins plugin, enabled by some previewers though not by
GitHub, reads ++text++ as inserted text.  "C++20" can open such a span,
because its ++ is followed by a word character, and the next "C++ " in
the same paragraph closes it: "for new C++ code C++20 coroutines are the
native answer. What the headers offer a C++ project" rendered as
underlined text reading "C20 ... a C project".

Backslash-escaping ASCII punctuation is core CommonMark, so C\+\+20
renders as C++20 on GitHub, while an escaped + cannot take part in a
delimiter run.  Only the openers are escaped -- a C++ followed by a
space, comma or period cannot open a span, so nothing can pair once the
openers are gone -- and only in prose, since a backslash inside a code
span would render literally.

Found one in the README; a scan of every Markdown file turned up the
same pairing in TODO.md, and a third opener there with nothing yet to
close it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
README code blocks were 86 lines unspaced against 3 spaced, two of the
three added today.  The library's own sources keep the space before a
semicolon; the README deliberately does not, because readers arriving
from other C code expect the conventional form, and a code block is the
first thing many of them read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The lost-wakeups section showed the drain-then-run idle loop, and twelve
lines later a bare version of the same loop without the drain.  A reader
could not tell which was canonical.  The drain version is the general
one, and the Timers reference has the complete form with pt_timer_next().

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The producer/consumer example explained that the channel idea came from
the UNIX kernel, resembles a condition variable, and avoids allocating
one -- all now said more fully in the Wait channels section.  Keep the
two mechanical facts that section does not state, that a channel has no
memory and how pt_broadcast() differs from pt_signal(), and link to the
rest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Bare-metal use, interrupt safety and the lost-wakeup race came straight
after the API table, so a newcomer met a race diagram and a drain loop
before being told what a protothread is.  The section now follows the
producer/consumer example: still a quarter of the way into the document
rather than more than halfway, as it was originally, but after the
concept and a worked example.  The "What you get" bullet still warns the
skimmer who never scrolls that the library is not interrupt-safe by
default.

A pure move -- the word count is unchanged and every internal link still
resolves -- apart from one reference it would otherwise have weakened:
"How does it work?" said "this example", which now points back across
the interrupt section, so it names the producer/consumer example.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each API reference entry was a prototype on its own line followed by a
blockquote.  Read from the top that is unambiguous, but dropped into the
middle of the section a reader cannot tell whether a description belongs
to the prototype above it or the one below -- and C habit, where the
comment precedes the function, suggests the wrong answer.

The prototype now opens the blockquote it describes, so each entry is
one visually separate block.  Entries whose description ran to several
paragraphs were already several adjacent blockquotes, which the change
merges into one so the extra paragraphs stay attached: two for
protothread_run(), four for protothread_set_ready_function().  The two
lock-release prototypes share one block, as they share one description.
Section-level prose between entries stays outside.

Verified mechanically: with quote markers and blank lines ignored, the
section's content is identical before and after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Same change as the API reference, for the same reason: each setting's
name sat on its own line above a blockquote, so from the middle of the
section it was unclear which description went with which name.  The two
sections also looked different from each other after the API reference
changed.

The headers here are not bare prototypes -- "PT_DEBUG (default 1)", and
a comma-separated list for the critical-section macros -- so they are
detected as a backtick-led line immediately followed by its quote.
Content verified identical with quote markers and blank lines ignored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
LarryRuane and others added 5 commits September 16, 2026 14:10
The API reference grouped pt_create(), pt_signal(), pt_broadcast() and
pt_kill() with the lifecycle calls under "Either thread or non-thread
execution context".  The intended meaning was "inside or outside a
protothread", but it reads just as naturally as "from any OS thread or
interrupt handler" -- which is exactly where pt_signal() loses wakeups,
and has read that way for years.

The section is now two.  "Creating, waking and killing" says to call
them from the OS thread that runs protothread_run(), names what goes
wrong elsewhere -- corrupted lists without PT_CRITICAL_*, lost wakeups
even with it, and a pt_kill() that cannot know whether its target is
running -- and notes the rule is about overlap rather than thread
identity, since a setup thread may create protothreads before the
scheduler first runs.

"Lifecycle" holds pt_set_atexit() and the create/init/free/deinit
calls.  What they share is that none schedules or wakes a protothread,
not that they are thread-safe: protothread_init() writes every list, and
freeing a system another thread is running is a use-after-free.  They
are safe from a different OS thread because of when they are called,
and the section says so.

Also renames "Thread execution context" to "Inside a protothread
function", matching the table at the top, since "thread" would otherwise
mean a protothread in one heading and an OS thread in the next.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Lifecycle" did not say whose.  "Protothread system setup and teardown"
does, and distinguishes it from the per-protothread calls around it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pairs with "Protothread system setup and teardown": these calls act on
protothreads, not on the system.  Plural rather than "an individual
protothread", since pt_broadcast() wakes every waiter on a channel and
pt_signal() wakes whichever is oldest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pt_call() expands to a direct call, so a nested protothread function can
declare its context parameter with its real type and have the compiler
check it like any other argument.  The library's own semaphore, lock
and timer functions already do.  The README taught the opposite: its
read_thr example, reachable only through pt_call(), took env_t and cast
it -- so most of the unchecked void* in a program written from these
docs came from the docs rather than from any necessity.

The rule is now stated where pt_call() is introduced: only a top-level
function, the one passed to pt_create(), needs env_t, because the
scheduler keeps every protothread's function in one list and cannot know
their types; likewise a function called through a pt_f_t pointer.

That also corrects two over-broad C++ claims.  Not every protothread
function needs a cast under C++, only top-level ones, and the cure for a
nested function in existing code is to type its parameter.

The example was verified by extracting it from the README as written
and compiling it under gcc, clang, g++ and clang++ with -Wall -Wextra
-Werror; passing the wrong context to pt_call() is a hard error in both
compilers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Records the idea of stamping out a typed trampoline and creator per
context type, after the generic-header technique Mark Hayden used for
hash tables at LeftHand, so that user code never casts env_t.

Recorded with its cost, which is measured rather than guessed: gcc
refuses to inline a function containing a computed goto, so the
trampoline adds a real call per resume on a library whose headline
number is a 4.6 ns context switch.  Also notes that nested functions
need none of this, that channels should stay untyped, and how a typed
kill could absorb much of pt_set_atexit()'s use case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@LarryRuane LarryRuane changed the title Follow-up to #17: demos, an explicit API boundary, and C++ support Follow-up to #17: demos, an explicit API boundary, C++ support, and documentation Sep 16, 2026
@LarryRuane

Copy link
Copy Markdown
Owner Author

One more thing, so I'm not contradicting myself: when I closed #15 I wrote that pt_set_atexit() "turned out to be the natural hook for running a C++ destructor when a protothread is killed", and TODO.md says the same.

Thinking about it again, that's weaker than it sounded. A C++ wrapper would be templated on the context type, so the code that kills a protothread already knows the type and can run the destructor itself — call pt_kill(), then destroy the context — without a function pointer stored in every protothread. The same trick works in plain C: a macro can generate a typed pt_kill_foo(foo_ctx_t *) that kills and then runs the cleanup, fully type-checked.

If you are using it, the change on your side should be small. The callback only ran when the kill actually found the thread, so at each call site this does exactly what it did:

if (pt_kill(&c->pt_thread)) {
    cleanup(c) ;
}

LarryRuane and others added 7 commits September 16, 2026 14:50
It registered a callback that pt_kill() ran after unlinking a thread.
Whoever calls pt_kill() can do that cleanup itself, and exactly
equivalently -- the callback ran only when the kill found the thread,
after the unlink, in the caller's context:

    if (pt_kill(&c->pt_thread)) {
        cleanup(c) ;
    }

Against that, it cost a function pointer in every pt_thread_t, including
in the large majority of programs that never kill anything; it was named
after atexit() but ran only on kill, never on normal exit; and since
pt_kill() is documented as callable from an interrupt handler once
PT_CRITICAL_* are defined, its callback -- typically a free() -- could
run in interrupt context.

The one case it served that the caller cannot is a generic killer
holding only a pt_thread_t * and not knowing the context's type.  That
case can keep its own registry, and TODO.md records a generated, typed
pt_kill_<name>() as the better answer, which would also cover running a
C++ destructor for a killed protothread -- the use earlier notes had
reserved this hook for.

Removal fails loudly: an implicit function declaration is a hard error
by default in current gcc and clang, so nothing changes behaviour
silently.

Sizes, measured rather than computed:
  Cortex-M (clang --target=arm-none-eabi):  pt_thread_t 24 -> 20,
                                            minimum per protothread 32 -> 28
  x86-64:                                   pt_thread_t 48 -> 40,
                                            minimum per protothread 64 -> 56
  x86-64 with PT_DEBUG=1:                   minimum 104 -> 96
  ptbench memory against PTHREAD_STACK_MIN: 256x -> 293x
The "before" figures reproduce the ones the README already quoted.

Also corrects a claim those numbers exposed: the README said PT_DEBUG
roughly triples the per-thread cost.  It triples pt_func_t, but the
per-protothread minimum rose from 64 to 104 bytes, not threefold; the
README now gives the measured figures.

Verified: 70-configuration test matrix on gcc and clang, ASan and UBSan,
zero libc symbols under -ffreestanding, the headers as C++, and the three
demos.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The API section listed 31 entries, and a reader deciding whether this is
worth learning counts them.  Thirteen belonged to semaphores, reader-
writer locks and timers, which are not part of the core at all: each is
ordinary protothread code over pt_wait() and pt_signal() in its own
optional header, with no privileged access to the scheduler.  They were
written as much to show what the core can express as to be used, and
nothing in them is needed to start.

The API section now shows only the core -- eighteen entries, all in
protothread.h, of which the producer/consumer example uses nine -- and
lists the three optional headers in one line each.  Their reference
material moves under its own top-level heading, "Built on top", which
says plainly that none of it is needed and compares them to the demo
programs rather than to the API.

Elsewhere, the "Why another implementation" list no longer gives them a
headline bullet, "What you get" calls them optional and adds timers,
which it had omitted, and "Using it" says one header is the whole core.
Incidental mentions -- the freestanding claim, the interrupt-safety
table, the upgrade notes -- are facts rather than presentation and are
unchanged.  The headers, their tests and their CI coverage are untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Keep a Changelog format, since that pairs with the semantic versioning
the project already follows, and a file in the repository travels with a
vendored copy of the headers where a GitHub Release page does not.

The 2.0.0 entry was built from the commit history and then checked
against the 1.x headers.  That check found two upgrade-affecting changes
the README's "Upgrading from 1.x" notes do not mention: names visible in
the 1.x header that are now pt_i_-prefixed internals, and the reserved
_pt_*_t struct tags that were renamed.  Neither affects code using only
the documented API.

The heading reads "Unreleased" until the v2.0.0 tag is pushed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pt_wait(c, x) draws an objection from any experienced C programmer on
first sight, since macros are conventionally capitalized.  Answer it
where the README first says the return and goto are hidden in macros.

The case is readability -- these appear constantly, and in capitals the
code reads as shouting -- and it has standard precedent: setjmp, assert
and va_arg are lowercase macros, setjmp for the same kind of control-flow
reason, as is the Linux kernel's wait_event().  PT_DONE stays capitalized
because it is a value, which is what shows the choice is principled.

It also says what capitals would normally warn about and why that is
acceptable here: a blocking macro returning from your function is the
model itself, and the context argument being evaluated more than once is
harmless for the plain pointer every example passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"A little-known gcc feature" predates the clang fix; the compiler
requirements section already says gcc or clang.

The event-driven comparison now says why a chain of resume points
encodes so much state: each function has several places it can block, so
each pointer in the chain can take several values.

Also fixes "event-drive" and "fine-grain".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pt_i_ convention promised that any name without the prefix is public
API.  The first pass renamed internal functions and macros but missed
types and enum constants, so the promise was not true.  These remained,
none of them documented and none meant for users:

  protothread.h        state_t, pt_critical_t, PT_RETURN_WAIT,
                       PT_RETURN_DONE, enum pt_return_e, struct pt_return_s
  protothread_lock.h   pt_lock_state_t, PT_LOCK_READ, PT_LOCK_WRITE,
                       PT_LOCK_READING, PT_LOCK_WRITING
  protothread_timer.h  pt_time_diff_t

All are now pt_i_ or PT_I_, except state_t, which was a second typedef
for exactly the type protothread_t already names.  It is deleted rather
than renamed: renaming it would repeat the protothread_t typedef, which
C11 permits and C99 does not, and CI builds with -std=c99.  demo/pool.c
was its only user outside the headers.

Struct tags behind public typedefs keep their names -- users declare
struct protothread_s, and gdbinit walks pt_thread_s -- as do the
PT_VERSION_ macros, which are documented.

Nine of these names were visible in 1.x, so the README's "Upgrading from
1.x" notes now list the renamed internals and the struct tag renames,
which they had omitted while saying everything else was unchanged; the
changelog gains the same names.  TODO.md's C++ section still listed the
missing malloc() cast as an open blocker, though the C++ commit had
fixed it; it is marked done.

Verified: 70-configuration matrix, all twelve language standards
including c99, ASan and UBSan, zero libc symbols freestanding, C++11 and
C++20, the three demos, and the critical-section and C++ CI jobs run
verbatim from the workflow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The upgrade notes and changelog said the headers no longer include
<stdlib.h>, <string.h> or <assert.h>.  Only <string.h> is gone
outright.  <assert.h> is still included while PT_DEBUG is on, which is
the default, and <stdlib.h> unless PT_NO_MALLOC is defined.  Measured by
compiling a file that uses malloc, memset and assert without including
any of them:

  defaults                     memset missing
  PT_DEBUG=0                   memset, assert missing
  PT_NO_MALLOC                 memset, malloc, free missing

The flattened version understated the trap that matters: code relying
on assert builds in a default debug configuration and fails only in a
production build with PT_DEBUG=0, so a smoke test of the default
configuration alone would not find it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@LarryRuane

Copy link
Copy Markdown
Owner Author

Pushed a few more updates...

It's ready for your smoke test, we can un-draft this once you've had a chance to rebuild.

2.0.0 has some deliberate breaking changes. Here's what I'd expect you to hit, most likely first:

  1. No library to link. It's header-only now, so the protothread-static and protothread-shared CMake targets are gone, and pkg-config no longer emits -lprotothread. If you use add_subdirectory and link one of those targets, link the protothread target instead, or nothing at all. find_package(PROTOTHREAD) still works; ${PROTOTHREAD_LIBRARIES} is just empty.
  2. pt_set_atexit() is removed. Where you registered a callback, do the cleanup at the call site instead. This runs in exactly the circumstances the callback did:
if (pt_kill(&c->pt_thread)) {
    cleanup(c) ;
}
  1. Standard headers no longer come through protothread.h: <string.h> never, <assert.h> only while PT_DEBUG is on, and <stdlib.h> only without PT_NO_MALLOC. Code using memset, assert or malloc without its own #include will break — and code relying on assert can build fine in debug and fail only with PT_DEBUG=0. If you can, please rebuild with -DPT_DEBUG=0 as well as your usual configuration.
  2. Internal names now have a pt_i_ prefix, so anything that reached into internals won't compile: for example pt_get_protothread() (use pt_get_pt(c)), state_t (use protothread_t, the same type), or struct _pt_lock_t (use the pt_lock_t typedef).

One behavioral change rather than a build error: reader-writer locks now grant requests in arrival order.

The full list is in CHANGELOG.md and the README's "Upgrading from 1.x" section. Anything that breaks and isn't on this list is exactly what I'm hoping the smoke test turns up, so please let me know either way.

LarryRuane and others added 15 commits September 16, 2026 23:57
Defer the C++ cast detail to its own section, recast the benchmark
caveat as plain text and add that the pthread side pays for mutex
locking, and drop pt_lock_t from the memory table now that locks are an
optional layer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"A cmake find script" was jargon with no instructions, and the file is
not installed, so a reader could not tell what it did or how to use it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A wakeup is a hint that a condition may have changed, never a promise
that it has, so waking one waiter instead of all of them should be an
optimization that correct code does not rely on.  PT_SIGNAL_WAKES_ALL=1
makes pt_signal() behave as pt_broadcast(), and a new CI job builds the
test suite and the three demos that way on gcc and clang.  It defaults
to 0 and compiles to nothing, and is documented because the README now
suggests the same check for users' own designs.

The job proves its own switch works first: two consumers and a producer
on one channel stall when pt_signal() wakes one waiter -- a consumer
absorbs the wakeup the producer needed -- and complete when it wakes
all of them.  Without that control, a macro that silently stopped
working would leave the job green while testing nothing.

The README explains, where the producer/consumer example uses while
rather than if, that a wakeup is only a hint.  Wait channels says why
stray wakeups must be expected -- an if happens to be enough with one
consumer and breaks with two -- and records the asymmetry the control
program demonstrates: waking everyone is always safe, but pt_signal() is
a safe substitute for pt_broadcast() only when every waiter on the
channel could use the event.

The library's own semaphores, locks and timers never call pt_signal(),
so for them the check is vacuous; the README says that rather than
claiming they pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Unix kernel's wakeup() always woke every sleeper, and with a while
loop re-testing the condition that is always correct.  pt_signal() is
an optimization whose safety depends on conditions that are properties
of the whole program rather than of the call: every waiter on the
channel waiting for the same condition, the woken waiter certain to run
(a pt_kill() between wakeup and run takes the wakeup with it), and that
waiter leaving the condition false or passing the wakeup on.  A correct
pt_signal() becomes a hang when someone later adds a different kind of
waiter to the same channel, without the call site changing.

What it buys is avoiding a thundering herd, and a herd needs many
waiters on one channel.  On the microcontrollers this library mostly
targets, protothreads number in the tens and seldom more than a few
share a channel, so broadcast costs close to nothing.  Where it does
cost, the README now recommends splitting channels -- free here, since a
channel is only an address -- before reaching for pt_signal().

Wait channels states the default, the three conditions and the cost
argument.  The API table lists pt_broadcast() first and says it is the
default, the pt_signal() reference entry points back to the reasoning,
and the producer/consumer example and its expansion use pt_broadcast(),
since a flagship example that contradicted the stated default would win.
With one producer and one consumer the two behave identically.

Also corrects three places that described the semaphore, lock and timer
headers as built on pt_signal(); they use pt_broadcast() exclusively.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PT_NWAIT=0 compiled and then crashed.  The wait table became a zero-size
array, which -Wall -Wextra do not object to -- only -Wpedantic says
anything, "ISO C forbids zero-size array" -- and the bucket index is
computed as (address >> 4) & (PT_NWAIT - 1), so the mask became -1 and
every wait and signal indexed the table by most of an address.

Other non-powers of two did not crash but quietly wasted the table: at
PT_NWAIT=3 the mask is 2, so only buckets 0 and 2 are ever used.  The
README has always specified a power of two; now the header enforces it.

The check is #if plus #error rather than _Static_assert, since CI builds
with -std=c99.  It rejects 0, negatives and non-powers of two on both
gcc and clang, and 1, 2, 4, 16, 1024 and the default still build and
pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
They are POSIX's, taken deliberately so they read as they do elsewhere,
even though a channel replaces the condition variable rather than
accompanying it.  Without saying so, a reader who knows pthreads wonders
whether the resemblance is meant, and one who does not wonders why
"signal" means "wake".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Not for merging as is; see the measurements below.

The scheduler already learns when a protothread exits, since a top-level
function returns PT_DONE only when the whole protothread is finished and
protothread_run() was discarding that value.  On exit it now wakes anyone
waiting on the pt_thread_t address, and pt_join() is a macro that waits
unless the thread has already exited.  A NULL func marks an exited
thread, so no extra field is needed for that part.

A protothread may free its own context as its last act, which the
recursive test does, so nothing may be read from the thread object after
its function returns.  That is why joinability is a per-thread flag read
before the call rather than after: threads not created with
pt_create_joinable() are never touched once they return.  An earlier
version wrote the exited mark unconditionally and ASan caught the
use-after-free immediately.

pt_kill() wakes joiners too, otherwise joining a killed thread hangs.

Measured against the unmodified header, same machine, back to back:

  minimum RAM per protothread   28 -> 32 bytes (Cortex-M), 56 -> 64 (x86-64)
  create + destroy              1.88 -> 2.20 ns
  context switch                2.95 -> 3.20 ns

The context-switch cost is the joinable flag being loaded on every
protothread_run(), not just on exits: a probe that reads it only after a
PT_DONE return measures at baseline.  That probe is unsafe for the
self-freeing case, so it is not an option, but it locates the cost.

Gating the whole feature behind a compile-time PT_JOIN would return all
three numbers to their current values for anyone not using it.

join_test.c covers joining a running thread, joining one that already
exited, two joiners on one target, and joining a killed thread, with a
bounded run loop so a hang fails rather than hangs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Drops the per-thread flag from the prototype, so pt_thread_t stays at
five pointers and the minimum per protothread is 28 bytes on Cortex-M
and 56 on x86-64, unchanged.

The price is a rule: a protothread may no longer free the context
holding its own pt_thread_t, because the scheduler marks the thread
exited after its function returns.  Freeing a nested pt_call() context
is still fine, since that context holds no running thread.  Two tests
did this and are changed: the recursive test now threads every context
onto a list in its global context and frees them at the end, and
test_sem keeps its hundred contexts in an array.

Sanitizers will not reliably catch a violation.  ASan elides the shadow
check for the exit-mark store when the same address was checked just
before the call, which is what happens when s->running is cached across
it, so a self-freeing protothread runs clean.  Re-reading s->running
after the call keeps the check, and ASan then reports it, so the
scheduler does that deliberately.  Tested both ways.

Measured against the unmodified header, five runs each, same machine:

  minimum per protothread   28 / 56 bytes, unchanged
  create + destroy          1.88 -> 2.13 ns
  context switch            2.87 -> 3.06 ns

Waking joiners on every exit cost far more than that, 2.62 ns for create
and destroy, so the wake is skipped when the thread's wait bucket is
empty.  Reading the bucket head outside the critical section is safe
here: only a protothread can add a waiter, and the scheduler is one, so
an empty bucket cannot fill concurrently.  What remains is the PT_DONE
test on every resume, which is the context-switch difference.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The non-blocking half of pt_join(): true until the top-level function
has returned or the thread has been killed.  A static inline rather than
a macro, so the argument is type-checked, and pt_join() now uses it
rather than knowing that a NULL func means exited.

Named for Java's Thread.isAlive() and Python's is_alive() rather than
pt_is_running(), because "running" already means the one protothread
currently executing -- s->running, the one protothread_run() asserts
about -- and a thread blocked in pt_wait() would answer true.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Folds the prototype in: the join tests become test_join() in the suite,
so CI covers them, and join_test.c is gone.  The suite exercises joining
a running protothread, joining one that exited before the joiner
existed, two joiners on one target, and a target released by pt_kill(),
with a bounded run loop so a join that never returns fails rather than
hangs.

Documented as twenty core entries rather than eighteen, with reference
entries for both, and the pt_create() notes no longer claim the system
cannot tell you when a thread exits, which was its own argument for
adding this.

The breaking rule is stated in three places, since a violation is
silent: the pt_create() notes, the upgrade notes and the changelog.  A
protothread may not free the storage holding its own pt_thread_t; free
it from whoever owns it, after pt_join() or once pt_is_alive() is false.
Freeing a nested pt_call() context is unaffected.  All three say that
sanitizers will not reliably catch a violation.

Measured cost, minimum of seven runs of the repository's own benchmark:
create and destroy 2.4 -> 2.8 ns, context switch 5.7 -> 5.6 ns, which is
to say unchanged within noise.  The published table is left alone: this
machine measures the unmodified code at 5.7 and 2.4 where the table says
4.6 and 2.7, so those figures are historical and re-baselining half of
them would be worse than leaving them.  The changelog carries the ~15%
create and destroy cost instead.

Also fixes "prothread_" in the API section.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The earlier attempt was taken while a browser and several editors were
running, and was too noisy to use: it put the unmodified code at 5.7 ns
per context switch against the table's 4.6, and once measured the
modified code as faster.  With those shut down the unmodified code
measures 4.7 ns, which matches the published figure, so the table was
right all along and only one row actually moved.

Minimum of seven runs per column, protothread and pthread sides from the
same set:

  context switch      4.7 ns vs 3,241 ns
  create + destroy    2.8 ns vs 28,852 ns
  memory per thread   56 bytes vs 16,384 bytes

Context switch is unchanged by pt_join(); create and destroy went from
2.4 to 2.8 ns, which is the scheduler noticing exits.  The changelog now
gives those measured figures instead of an estimate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Producer / consumer was the first thing a reader saw, and it asks them to
hold several unfamiliar things at once: what's being produced, the mailbox
convention, the wait channel, why the tests are while loops. There are also
many variants of the algorithm, so the simplest one isn't obvious as such.

The new example is the protothreads equivalent of the pthreads hello world,
so a reader who knows pthreads can see what maps to what. It also gets the
context structure and pt_resume() out of the way before anything blocks.
The producer / consumer intro now says what's produced and consumed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SR7v9BVsFrHiEhQBSqZmAs
Shifting the channel address made the bucket depend on how far apart the
channels happened to be, which is not something the caller chooses on purpose.
Waiting on each element of an array of 1000 128-byte contexts reached 128 of
the 1024 buckets with the old shift of 4, and 64 with a shift by the pointer
size; over strides from 4 to 4096 bytes the worst chain was 250 and 500
respectively. Multiplying by the golden ratio and taking the top half of the
product reaches 916 buckets on that array and holds the worst chain to 4.

The TODO entry for this assumed a 64-bit multiply, and so assumed it had to be
conditional for small targets. Sizing the multiplier to uintptr_t removes that:
a 16-bit target does a 16-bit multiply. At PT_NWAIT=1 the mask is zero and both
gcc and clang delete the multiply as dead code, so the configuration recommended
for memory-constrained systems pays nothing.

Cost on x86-64 is below what this machine can resolve. Pinned to a core with
code alignment normalized, the new code measures faster than the old (context
switch 5.17 vs 5.68 ns median); the full benchmark unpinned measures it slower
(5.5 vs 5.2). Create plus destroy is flat in both. The README table is left
alone, since the machine currently reads 5.0 ns where that table, measured
idle, says 4.7.

The first version of the PT_NWAIT bound check, comparing (PT_NWAIT-1) squared
against UINTPTR_MAX, overflowed in the preprocessor and never fired; a negative
control caught it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SR7v9BVsFrHiEhQBSqZmAs
The README makes a point of how few entries the API has without giving the
reader anything to measure it against. POSIX pthread.h declares 101 functions,
counted from the Open Group page rather than estimated.

The comparison is unfair on its face, since pthreads offers preemption, real
parallelism, priorities and process-shared synchronization, so the paragraph
says so first. The interesting part is what the count is made of: 45 of the
101 only manage attribute objects and another 10 are init and destroy for the
synchronization objects, so more than half of the interface is setup for the
other half. Most of the remaining vocabulary, cancellation state, priority
protocols, cleanup stacks, exists to cope with preemption, which is exactly
what a protothread doesn't have.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SR7v9BVsFrHiEhQBSqZmAs
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants