std/async: OS threads with thread-safe channels, and cross-thread refcount fixes - #910
Draft
TimWhiting wants to merge 9 commits into
Draft
std/async: OS threads with thread-safe channels, and cross-thread refcount fixes#910TimWhiting wants to merge 9 commits into
TimWhiting wants to merge 9 commits into
Conversation
…buv) Increment 2 foundation for `app(threads=…)`: the two runtime primitives the plan called for but that didn't exist (only one-shot `compute`/uv_queue_work did). - xchannel: a THREAD-SAFE channel. `xemit` from ANY thread marks the value thread-shared, pushes it onto a mutex-protected FIFO queue, and wakes the owner loop via uv_async_send; the owner's async callback drains the queue and delivers each value into a loop-local Koka channel that `xreceive` awaits. `xsender` is the cross-thread half (JUST the C handle, no Koka channel) -- a worker captures `x.sender`, never the whole channel, so it never races on the owner-side Koka channel's mutable state. - spawn-thread(body): a PERSISTENT worker OS thread (uv_thread_create) that runs `body` under its OWN async loop -- it reuses `async()` (event-loop-init + async/exn handlers + event-loop-run/uv_run), so the worker is "just another async main" on its own thread and can host the delivery callbacks correctly. (Tim's key insight: a threadpool `compute` worker has no async handler, so its cross-thread wake path misbehaves; a real async-run thread fixes it.) test/async/xthread: a worker thread xemits 5 values across the boundary; the main loop receives [10,20,30,40,50] in order. Wired into util/test-wasi.sh (native host only -- real OS threads don't run under wasi). Native-only (uv backend); a portable re-export + web/coop fallback + the UI `app(threads=…)` wiring are the next steps. Follow-ups: worker-thread join/teardown + channel-close timing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ubbed) `std/async/thread` now re-exports (pub import) the host-selected backend's threading surface -- xchannel / xsender / spawn-thread / native-threads -- so a portable module can use them and gate real threading on native-threads(): - libc backend: the real thread-safe channel + persistent worker threads. - coop backend (web/wasi): xchannel = a plain single-thread channel; spawn-thread is an inert stub; native-threads() = False. Same signatures, so callers compile everywhere and simply run single-threaded on the web. `xemit` is now `ioc` (was total) to match the coop emit and keep the portable signature host-consistent. test/async/xthread imports the portable module now; verified it runs native (sum 150) and compiles for wasi-web (coop path). Groundwork for ui/app's `app(threads=Threaded)`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nt the handle) The thread-safe channel boxed its libuv handle with kk_cptr_raw_box -- a REFCOUNTED box with a destructor. But the handle is not Koka memory, and an `xsender` crosses it to worker threads, so refcounting it meant the box's last drop (and its free-fun: uv_close + dropping the owner-side deliver/Koka-channel) could run on ANY thread -> a cross-thread free of owner-thread objects -> mimalloc heap corruption (32-byte thread-free-list corruption). It only surfaced for boxed payloads / single-emit / main-outlives-worker timings; the earlier int-only test masked it (unboxed ints never actually cross-thread-refcount anything). Tim's diagnosis: don't refcount a libuv handle. Fix: box the handle as an UNREFCOUNTED raw pointer (kk_cptr_box = a value for heap addresses, no destructor). Crossing a sender now just copies a value; nothing frees the handle on drop. Lifetime is explicit: `xchannel/close` (owner thread) uv_closes the async handle so a finite program's loop drains and it exits cleanly (apps that run for their whole lifetime need not call it). test/async/xthread now closes and exits 0; verified boxed + single/multi emit deliver with no corruption; web/coop compiles; compute (Increment 1) unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ain refcounts Once a second OS thread runs Koka code concurrently, several process-global heap blocks carried PLAIN (non-atomic) reference counts and were dup/dropped from every thread. The racing read-modify-writes lose counts, freeing blocks that are still referenced (and still reachable from C statics); the next dup then writes into freed memory -- corrupting the allocator free list (reproduces identically under mimalloc and libc malloc). Four fixes: 1. kk_evv_empty_singleton (init.c): heap-allocated a plain-refcounted block that every thread's evidence vector starts from and every handler push/pop dup/drops. Use the existing static kk_evv_empty_static (stuck refcount). 2. kk_block_make_shared (refcount.c): positive rc N encodes N+1 references while thread-shared -N encodes N references (both stated in the invariant comment), so the count-preserving map is `~rc` = -(rc+1). The previous `-rc` lost one reference for every marked block with rc >= 1: any marked-shared graph with a multiply-referenced node was freed prematurely. Also removes a stray debug fprintf in kk_block_mark_shared_recx. 3. String literals (string.h/string.c): literals are lazily heap-allocated into C statics (shared by all threads) with plain refcounts, and function-local literals initialize with a non-atomic first-use check that can race between threads. New kk_string_literal_init: one-time release-CAS publication plus a STUCK refcount (new kk_block_make_stuck) so dup/drop become no-ops -- the same steady state as a compile-time KK_HEADER_STATIC literal, and cheaper than the previous non-atomic refcounting. (A fully static, born-stuck literal is possible when KK_COMPRESS==0 -- like kk_define_static_function -- and is left as a follow-up; this path works on all configurations.) 4. Computed module toplevel constants (Backend/C/FromCore.hs): non-literal toplevel values (tables, vectors, closures, bignums) also live in C statics and are dup/dropped from any thread using the module. Mark their reachable graph STATIC at module init (new kk_box_mark_static: every not-yet-shared block in the graph becomes stuck, matching compile-time statics; blocks that are already thread-shared stay atomically counted). Emitted as dup;box;mark;drop-box which covers both reference types and value structs. Repro (crashed 30-50% of runs, now clean under -DKK_DEBUG_FULL and --fstdalloc): two OS threads concurrently calling `(n.milli-seconds).show` -- no data shared between them beyond the stdlib's literals and decimal constants. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The C backend emits kk_uint8_box/kk_uint8_unbox for the uint8 fields of `bytes`, but kklib only gained the int8 variants (104fd70); any clean rebuild of std/core/bytes fails to compile. Mirrors kk_int8_box; note kk_uint8_unbox takes no borrow argument (matching the generated calls). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Once the process becomes a Cocoa GUI app (GLFW/Metal init), macOS coalesces timers on default/background-QoS threads to ~1s; a persistent worker loop barely ticks. Pin worker threads to user-interactive QoS so their libuv timers fire promptly, matching the main (UI) thread. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Part 1 churns duration.show from two OS threads concurrently (dup/drops the same shared literals and decimal constants -- the historical ~50% crasher); part 2 streams boxed structs over an xchannel while the worker keeps allocating. Guards the shared-refcount fixes (evv singleton, make_shared ~rc, stuck literals, marked toplevel constants). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
TimWhiting
force-pushed
the
pr/uv-thread-safety
branch
from
July 16, 2026 03:08
4a06777 to
9bd0078
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This adds OS-thread support to
std/asyncand fixes the runtime thread-safety issues it uncovered.Thread support
spawn-thread( body : () -> <async,ioc> () ): a persistent OS thread runningbodyunder its own async handler and libuv loop, sowait,spawn, and channels work on the worker exactly as on main.xchannel<a>/xsender<a>: a thread-safe channel. Any thread mayxemitthrough the sender; values are marked thread-shared, enqueued under a mutex, and the owner loop is woken viauv_async_send(the only thread-safe libuv call), delivering on the owner's thread. The sender half carries no Koka state, so it is the only part that crosses threads. A regularchannelcannot be shared across threads: its state transitions are not atomic, and emitting to a waiting receiver would run the receiver's continuation (and drive its uv loop) on the wrong thread.compute( work : () -> pure a ) : async a: offload pure work to the libuv threadpool.std/async/thread; on hosts without OS threads (web/wasi) the same API degrades to cooperative strands andnative-threads()reportsFalse.Thread-safety fixes
Running Koka code on two threads concurrently corrupted the heap (~30-50% of runs) because several process-global heap blocks carry plain, non-atomic reference counts:
kk_evv_empty_singletonwas a heap-allocated shared block; it now uses the existing static (stuck refcount).kk_block_make_sharedmapped rcN(= N+1 references) to-N(= N references), losing one reference for every marked block with rc >= 1; the correct map is~rc.kk_string_literal_initnow publishes with a CAS and gives literals a stuck refcount (dup/drop become no-ops -- also cheaper than before).kk_box_mark_static: every not-yet-shared block in the graph becomes stuck, matching how compile-time statics behave).test/async/xthread-stress.kkreproduces the corruption (two threads churningduration.showconcurrently, plus boxed values streaming over anxchannel); it crashed ~50% of runs before these fixes and is clean over repeated runs under mimalloc's debug checks and the system allocator.Unrelated to threading:
kk_uint8_box/unboxare added to kklib --lib/std/core/bytes.kkuses theuint8value type so the generated code references them, and a clean stdlib rebuild fails without them.🤖 Generated with Claude Code