Follow-up to #17: demos, an explicit API boundary, C++ support, and documentation - #19
LarryRuane wants to merge 50 commits into
Conversation
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
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>
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>
|
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):
|
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>
|
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 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:
The strongest case for keeping it is that generic clean-up code — a supervisor, say — that holds only a 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! |
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>
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>
|
One more thing, so I'm not contradicting myself: when I closed #15 I wrote that 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 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) ;
} |
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>
|
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:
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. |
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
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.hinto something you already have and see whether it builds and behaves. Only three things can affect code you've already written: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, afterpt_join()or oncept_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.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 idiomThe 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:falsewhen 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:truetrue:34, names the condition:364, 1000-thread test:113, 2-thread testThe 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 replaceprotothread_init()'s explicit loop: measured freestanding atPT_NWAIT=1024, the loop needs no libc symbols at any optimization level, while the struct assignment makes gcc emitmemsetand clangmemcpy+memsetat-O0— breaking the zero-libc guarantee in exactly the build used to debug on bare metal. And.gitignorepicks upbuild*/and.vscode/.2. Collect the demos into
demo/, and add two moreThe 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.cbecomesdemo/pool.c, joined by:demo/async_io.c— no POSIX threads at all. Concurrent asynchronous I/O with onepoll()loop turning completions into signals.ptaio -vtraces every submission and completion.demo/helper_thread.c— one throwaway thread per blocking call, created and joined by the protothread that needs it.The
-vtrace 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, thepoll(), the blocking and the completion handler are all real; only the latency is simulated, and the comment says so. The 1 ms sleep inhelper_thread.cmakes 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 APIHeader-only removed the boundary the linker used to draw: when there was a library, the non-
staticfunctions were the API. Now everything isstatic inlinein a header and nothing distinguishes the two. 32 internal functions and macros now carrypt_i_/PT_I_,protothread.hopens 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_tand friends) and now use the house_ssuffix.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 touchingPRIMASKthey 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/EXITas a depth counter and asserts non-zero depth on entry to each function, and the suite runs clean under it acrossPT_DEBUG,PT_NWAITand optimization settings, with assertions forced live underPT_DEBUG=0. The first version of that harness was vacuous — the quoted#includeresolved 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()assignsmalloc()'svoid *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++andclang++at-std=c++11,c++17andc++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_tand 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 afterpt_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 beforept_resume()and re-initialized on every resume, still compiles, soconstremains 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 CMSISPT_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 acrossPT_DEBUG,NDEBUG,PT_NWAITand four optimization levels on both compilers.Two things about the job itself:
#includeresolved 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.__builtin_trap()rather thanassert(), sinceNDEBUGandPT_DEBUG=0are two of the configurations being checked andassert()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.
pt_signal(),pt_broadcast(),pt_kill()andpt_create()under "Either thread or non-thread execution context", which reads naturally as "from any OS thread or interrupt handler" — exactly wherept_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.env_tand 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 needenv_t. The example was extracted from the README as written and compiled under gcc, clang, g++ and clang++; passing the wrong context topt_call()is a hard error on both.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.
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.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++11escaped 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 thept_i_prefix, and add a changelog (6 commits)pt_set_atexit()is removed. Whoever callspt_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: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 afree()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, thePT_LOCK_*request states andpt_time_diff_t.state_tis deleted rather than renamed, since it was a second typedef for the typeprotothread_talready 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:
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.setjmp,assertandva_argand the kernel'swait_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=1makespt_signal()behave aspt_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 forpt_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 useswhilerather thanif. The library's own semaphores, locks and timers never callpt_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'swakeup()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. Whatpt_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 usept_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_NWAITof 1/4/1024,-O0through-Os), twelve language standards, six C++ configurations, zero libc symbols under-ffreestandingat every optimization level, ASan, UBSan, TSan, the three demos, and the cmake build and install — all under-Wall -Wextra -Werror.🤖 Generated with Claude Code