Skip to content

std/async: OS threads with thread-safe channels, and cross-thread refcount fixes - #910

Draft
TimWhiting wants to merge 9 commits into
dev-uvfrom
pr/uv-thread-safety
Draft

std/async: OS threads with thread-safe channels, and cross-thread refcount fixes#910
TimWhiting wants to merge 9 commits into
dev-uvfrom
pr/uv-thread-safety

Conversation

@TimWhiting

Copy link
Copy Markdown
Collaborator

This adds OS-thread support to std/async and fixes the runtime thread-safety issues it uncovered.

Thread support

  • spawn-thread( body : () -> <async,ioc> () ): a persistent OS thread running body under its own async handler and libuv loop, so wait, spawn, and channels work on the worker exactly as on main.
  • xchannel<a> / xsender<a>: a thread-safe channel. Any thread may xemit through the sender; values are marked thread-shared, enqueued under a mutex, and the owner loop is woken via uv_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 regular channel cannot 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.
  • Exposed portably via std/async/thread; on hosts without OS threads (web/wasi) the same API degrades to cooperative strands and native-threads() reports False.

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:

  1. kk_evv_empty_singleton was a heap-allocated shared block; it now uses the existing static (stuck refcount).
  2. kk_block_make_shared mapped rc N (= N+1 references) to -N (= N references), losing one reference for every marked block with rc >= 1; the correct map is ~rc.
  3. String literals are lazily allocated into C statics shared by all threads, with a first-use initialization that can race. kk_string_literal_init now publishes with a CAS and gives literals a stuck refcount (dup/drop become no-ops -- also cheaper than before).
  4. Computed module toplevel constants also live in C statics; the C backend now marks their reachable graph static at module init (new kk_box_mark_static: every not-yet-shared block in the graph becomes stuck, matching how compile-time statics behave).

test/async/xthread-stress.kk reproduces the corruption (two threads churning duration.show concurrently, plus boxed values streaming over an xchannel); 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/unbox are added to kklib -- lib/std/core/bytes.kk uses the uint8 value type so the generated code references them, and a clean stdlib rebuild fails without them.

🤖 Generated with Claude Code

TimWhiting and others added 8 commits July 15, 2026 19:26
…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
TimWhiting force-pushed the pr/uv-thread-safety branch from 4a06777 to 9bd0078 Compare July 16, 2026 03:08
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.

1 participant