feat(jobs): real OS threads - Job.parallel_for, fn name, thread-safe Sync

- `fn name` names a top-level function as a value (E_FNREF, lowers to @fn_<name>); the worker
  entry point for Job.parallel_for, which checks it takes (int, pointer-like) and returns void.
- runtime/native/threads.ll (pthreads) and threads_win.ll (Win32 SRWLOCK/CONDITION_VARIABLE): a
  pool of one worker per core but one, parked between batches; every thread claims chunks by
  compare-and-swap. Linked only into programs that use Job/Promise/Sync, by `ludicc -o`,
  `ludic build` and the test suite's build helper.
- Sync.* is real: native mutexes, atomics as cmpxchg retry loops (neither clang takes atomicrw,
  the PC's rejects seq_consistent), mutex-guarded channels, Sync.cpu_count from the OS.
- spawn/despawn on a pool thread stop the program with a located panic.
- examples/library/threads.ludic and its test; docs for fn, Job.parallel_for, Job.is_worker.
- Reseeded (bootstrap-cfree: out.ll == seed.ll). 141/141 on macOS; jobs, threads and the guard
  pass on Windows from the reseeded Windows seed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-15 13:52:22 +03:00
parent f5d1a62ccf
commit c10abd9f9f
22 changed files with 57464 additions and 55794 deletions

View file

@ -665,6 +665,8 @@ function emit_ns_call(ns: pointer, meth: pointer, e: Node) -> Val {
if (meth == "error") { bare = "job_error"; push(labels, "handle") }
if (meth == "pending") { bare = "job_pending" }
if (meth == "free") { bare = "job_free"; push(labels, "handle") }
if (meth == "parallel_for") { bare = "job_parallel_for"; push(labels, "count"); push(labels, "work"); push(labels, "ctx") }
if (meth == "is_worker") { bare = "job_is_worker" }
}
if (ns == "Promise") {
if (meth == "all") { bare = "prom_all"; push(labels, "handles") }
@ -1363,6 +1365,16 @@ function emit_expr(e: Node) -> Val {
return emit_new_struct(e.s, e.a)
}
if e.kind == E_LIST { return emit_list(e) } # [a, b, c] -> a fresh slice
# fn name -> the function's address, for a worker entry point. The OS-thread runtime calls it
# as void(i32, ptr), so that is the only signature a reference may have.
if e.kind == E_FNREF {
let d = find_fn(e.s)
if d == null { perr(`fn {e.s}: no function called {e.s}`) }
var ok = len(d.kids) == 2 and llty(d.ty) == "void"
if ok { ok = llty(d.kids[0].ty) == "i32" and llty(d.kids[1].ty) == "ptr" }
if not ok { perr(`fn {e.s}: a worker function takes (i: int, ctx: pointer) and returns nothing`) }
return val(`@fn_{e.s}`, "pointer")
}
if e.kind == E_ID {
let li = loc_find(e.s)
if li >= 0 { return emit_load_at(loc_reg[li], loc_ty[li]) }

View file

@ -118,7 +118,27 @@ function prefab_record(name: pointer, cn: pointer) -> Node {
return merge_records(prefab_record(p.ty, cn), spawn_record(p, cn))
}
# The threads rule's debug guard: a Job.parallel_for worker computes on what it was handed and
# never changes the world, so spawn and despawn stop the program when a pool thread reaches them.
# Emitted only into programs that use Job/Promise/Sync, which are the only ones with a pool.
function emit_worker_guard(what: pointer, line: int) -> void {
if not g_uses_jobs { return }
g_uses_panic = true
let w = emit_bind("call i32 @thr_is_worker()")
let on = emit_bind(`icmp ne i32 {w}, 0`)
let lok = lbl("wgok"); let lbad = lbl("wgbad")
emit(` br i1 {on}, label %{lbad}, label %{lok}\n`)
emit(`{lbad}:\n`)
let prefix = emit_str_const(`{g_src_name}:{itoa(line)}: panic: `)
let m = emit_str_const(`{what} on a Job.parallel_for worker thread (workers must not change the world)`)
let se = emit_bind(stdstream_rhs(2))
emit(` call i32 (ptr, ptr, ...) @fprintf(ptr {se}, ptr @.fmt_panic, ptr {prefix}, ptr {m})\n`)
emit(" call void @exit(i32 1)\n unreachable\n")
emit(`{lok}:\n`)
}
function emit_spawn(st: Node) -> pointer {
emit_worker_guard("spawn", st.line)
let e = emit_bind("call i32 @L_alloc()")
let model = spawn_model(st.s)
let ak = find_arch_id(model)
@ -153,6 +173,7 @@ function emit_spawn(st: Node) -> pointer {
}
function emit_despawn(st: Node) -> void {
emit_worker_guard("despawn", st.line)
let v = emit_expr(st.a)
# @OnDespawn: dispatch on the entity's kind and run the matching model's hook
if len(g_ondespawn) > 0 {

View file

@ -37,6 +37,7 @@ const E_TRY: int = 52 # try EXPR else { ... } — recover a fallible
# a=the fallible (result-typed) expression b=else block (its
# trailing expression is the fallback) line=source line
const E_LIST: int = 54 # [a, b, c] — a slice literal; kids=the elements, all of one type
const E_FNREF: int = 55 # fn name — a top-level function as a value (s=the name); a worker entry point
# statements
const S_LET: int = 10
const S_ASSIGN: int = 11

View file

@ -192,6 +192,8 @@ function p_primary() -> Node {
if (t.text == "null") { pi += 1; return node(E_NULL) }
if (t.text == "new") { pi += 1; let n = node(E_NEW); n.s = ptype(); if is_op("{") { n.a = record() }; return n }
if (t.text == "spawn") { return parse_spawn() } # spawn as an expression: the new entity
# fn name — a top-level function as a value, for a worker entry point (Job.parallel_for)
if (t.text == "fn") and (toks[pi + 1].kind == TK_ID) { pi += 1; let n = node(E_FNREF); n.line = t.line; n.s = eat_id(); return n }
# try EXPR else { ... } — evaluate a fallible (result-typed) expression; on
# `ok` the whole expression is its payload, on `err` the else block runs (with
# the message bound to `error`) and its trailing expression is the fallback.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -267,6 +267,12 @@ entry {
# reached by name). Works headless too, so it is outside the windowed block —
# macOS-only for now, which is where the toolchain runs.
# On Windows the transport is http_win.ll, over WinHTTP.
# Job.* / Promise.* / Sync.* link the OS threads behind Job.parallel_for and Sync.*:
# threads.ll (pthreads) or threads_win.ll (Win32).
if g_uses_jobs {
if g_target_win { cmd = `{cmd} {join_path(home, "runtime/native/threads_win.ll")}` }
else { cmd = `{cmd} {join_path(home, "runtime/native/threads.ll")}` }
}
if g_uses_http {
if g_target_win {
let httpw = join_path(home, "runtime/native/http_win.ll")