Skip to main content

catallaxy_stdlib/
fiber.rs

1//! Fibers: lightweight cooperative threads for I/O-bound work (M:N scheduling).
2//!
3//! A fiber runs on the OS thread that spawned it, for its whole life. Pinning keeps every rule of
4//! the object model intact: a fiber's objects are owned by its thread, so ownership checks,
5//! thread-local young collection and `with lock` work unchanged, and a fiber block may capture
6//! owned values. Many fibers share one thread and interleave only where one of them waits.
7//!
8//! Each fiber has its own native stack (a stackful coroutine) and its own [`Vm`] context
9//! (registers, frames, handles), registered with the thread's main VM so the collector scans
10//! parked fibers. The thread's main context runs the scheduler whenever it waits: in `join`, a
11//! channel operation, `sleep`, file or console I/O, or at the end of the thread.
12//!
13//! Waiting in a fiber parks it instead of blocking the thread:
14//! - channel send/receive and fiber `join` register a waker and park;
15//! - `sleep` sets a timer and parks;
16//! - blocking system calls (files, stdin) run on a small shared pool of I/O threads while the
17//!   fiber is parked.
18//!
19//! A fiber never parks while it holds a lock keyed by its OS thread (`with lock`, `Mutex`
20//! closures): a sibling fiber could otherwise enter the same lock. It blocks the whole thread
21//! instead.
22
23use std::cell::{Cell, RefCell};
24use std::collections::{BinaryHeap, HashMap, VecDeque};
25use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
26use std::sync::{Arc, LazyLock};
27use std::time::{Duration, Instant};
28
29use catallaxy_runtime::object::Wake;
30use catallaxy_runtime::{RtResult, Trap, Value};
31use catallaxy_vm::Vm;
32use corosensei::stack::DefaultStack;
33use corosensei::{Coroutine, CoroutineResult, Yielder};
34use parking_lot::{Condvar, Mutex};
35
36/// Native stack per fiber. Interpreted calls do not use it; only nested native calls do.
37const FIBER_NATIVE_STACK: usize = 256 * 1024;
38/// Register stack per fiber, in values (lazily committed).
39const FIBER_REGISTERS: usize = 64 * 1024;
40
41static NEXT_ID: AtomicU64 = AtomicU64::new(1);
42
43/// The main context of a thread, in waker ids.
44const ROOT: u64 = 0;
45
46/// Cross-thread side of a scheduler: wakeups arrive here.
47struct Shared {
48    woken: Mutex<Vec<u64>>,
49    cv: Condvar,
50}
51
52impl Shared {
53    fn wake(&self, id: u64) {
54        self.woken.lock().push(id);
55        self.cv.notify_one();
56    }
57}
58
59#[derive(Clone)]
60struct Waker {
61    shared: Arc<Shared>,
62    id: u64,
63    /// Set by `wake`; a park that finds it set returns at once (no lost wakeups).
64    flag: Arc<AtomicBool>,
65}
66
67impl Waker {
68    fn wake(&self) {
69        self.flag.store(true, Ordering::Release);
70        self.shared.wake(self.id);
71    }
72    fn into_wake(self) -> Wake {
73        Arc::new(move || self.wake())
74    }
75}
76
77enum Outcome {
78    Running,
79    /// The result is pinned in the thread's main VM under the fiber id.
80    Done,
81    Panicked(String),
82    Joined,
83}
84
85/// What other fibers (and the scheduler) know about a fiber.
86struct Ctl {
87    outcome: Mutex<Outcome>,
88    joiners: Mutex<Vec<Wake>>,
89}
90
91struct Fiber {
92    coro: Coroutine<(), (), (), DefaultStack>,
93    vm: *mut Vm,
94    flag: Arc<AtomicBool>,
95    yielder: Cell<*const Yielder<(), ()>>,
96}
97
98struct Scheduler {
99    shared: Arc<Shared>,
100    root_flag: Arc<AtomicBool>,
101    /// Parked or ready fibers (a running fiber is taken out while it runs).
102    fibers: HashMap<u64, Fiber>,
103    ready: VecDeque<u64>,
104    /// (deadline, id) min-heap via `Reverse`.
105    timers: BinaryHeap<std::cmp::Reverse<(Instant, u64)>>,
106    ctl: HashMap<u64, Arc<Ctl>>,
107}
108
109impl Scheduler {
110    fn new() -> Self {
111        Scheduler {
112            shared: Arc::new(Shared { woken: Mutex::new(Vec::new()), cv: Condvar::new() }),
113            root_flag: Arc::new(AtomicBool::new(false)),
114            fibers: HashMap::new(),
115            ready: VecDeque::new(),
116            timers: BinaryHeap::new(),
117            ctl: HashMap::new(),
118        }
119    }
120}
121
122thread_local! {
123    static SCHED: RefCell<Option<Scheduler>> = const { RefCell::new(None) };
124    /// The running fiber (0 in the main context) and its yielder.
125    static CURRENT: Cell<u64> = const { Cell::new(ROOT) };
126    static YIELDER: Cell<*const Yielder<(), ()>> = const { Cell::new(std::ptr::null()) };
127}
128
129fn with_sched<R>(f: impl FnOnce(&mut Scheduler) -> R) -> R {
130    SCHED.with(|s| f(s.borrow_mut().get_or_insert_with(Scheduler::new)))
131}
132
133/// True while a fiber (not the thread's main context) is running.
134pub fn in_fiber() -> bool {
135    CURRENT.with(|c| c.get()) != ROOT
136}
137
138/// True if this thread has fibers that have not finished.
139pub fn has_fibers() -> bool {
140    SCHED.with(|s| s.borrow().as_ref().is_some_and(|s| !s.fibers.is_empty()))
141}
142
143/// A waker for the running fiber, or for the main context.
144fn current_waker() -> Waker {
145    let id = CURRENT.with(|c| c.get());
146    let flag = if id == ROOT {
147        with_sched(|s| s.root_flag.clone())
148    } else {
149        RUNNING_FLAG.with(|f| f.borrow().clone()).expect("running fiber flag")
150    };
151    Waker { shared: with_sched(|s| s.shared.clone()), id, flag }
152}
153
154thread_local! {
155    /// The running fiber's wake flag (it is out of the fiber table while it runs).
156    static RUNNING_FLAG: RefCell<Option<Arc<AtomicBool>>> = const { RefCell::new(None) };
157}
158
159// ---- spawning and running ----------------------------------------------------------------------
160
161/// Starts a fiber running `closure` on this thread. It first runs when this thread's main
162/// context waits (or when another fiber parks). Returns the fiber id.
163pub fn spawn(vm: &mut Vm, closure: Value) -> u64 {
164    let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
165    let fvm = Box::into_raw(vm.new_fiber(FIBER_REGISTERS));
166    unsafe { (*fvm).extra_roots().push(closure) };
167    let ctl = Arc::new(Ctl { outcome: Mutex::new(Outcome::Running), joiners: Mutex::new(Vec::new()) });
168    let ctl2 = ctl.clone();
169    let vm_ptr = fvm as usize;
170    let stack = DefaultStack::new(FIBER_NATIVE_STACK).expect("fiber stack");
171    let coro = Coroutine::with_stack(stack, move |y: &Yielder<(), ()>, ()| {
172        YIELDER.with(|c| c.set(y as *const _));
173        // SAFETY: the VM lives until the scheduler releases it after this body returns.
174        let vm = unsafe { &mut *(vm_ptr as *mut Vm) };
175        let closure = vm.extra_roots()[0];
176        let r = vm.call_value(closure, &[]);
177        let outcome = match r {
178            Ok(v) => {
179                vm.pin(id, v);
180                Outcome::Done
181            }
182            Err(Trap::Panic(msg)) => {
183                let mut full = msg;
184                for line in vm.panic_trace.take().unwrap_or_default().iter().take(8) {
185                    full.push_str(&format!("\n    at {line}"));
186                }
187                Outcome::Panicked(full)
188            }
189            Err(Trap::Exit(code)) => {
190                crate::flush();
191                std::process::exit(code);
192            }
193            Err(Trap::Unwind { .. }) => Outcome::Panicked("break or return out of a fiber block".into()),
194        };
195        *ctl2.outcome.lock() = outcome;
196        for w in std::mem::take(&mut *ctl2.joiners.lock()) {
197            w();
198        }
199    });
200    with_sched(|s| {
201        s.fibers.insert(id, Fiber { coro, vm: fvm, flag: Arc::new(AtomicBool::new(false)), yielder: Cell::new(std::ptr::null()) });
202        s.ctl.insert(id, ctl);
203        s.ready.push_back(id);
204    });
205    id
206}
207
208/// Resumes fiber `id` until it parks or finishes. Main context only.
209fn resume(id: u64) {
210    let Some(fiber) = with_sched(|s| s.fibers.remove(&id)) else { return };
211    let prev_y = YIELDER.with(|c| c.replace(fiber.yielder.get()));
212    CURRENT.with(|c| c.set(id));
213    RUNNING_FLAG.with(|f| *f.borrow_mut() = Some(fiber.flag.clone()));
214    let mut fiber = fiber;
215    let r = fiber.coro.resume(());
216    fiber.yielder.set(YIELDER.with(|c| c.get()));
217    RUNNING_FLAG.with(|f| *f.borrow_mut() = None);
218    CURRENT.with(|c| c.set(ROOT));
219    YIELDER.with(|c| c.set(prev_y));
220    match r {
221        CoroutineResult::Yield(()) => {
222            let woken = fiber.flag.load(Ordering::Acquire);
223            with_sched(|s| {
224                s.fibers.insert(id, fiber);
225                if woken {
226                    s.ready.push_back(id);
227                }
228            });
229        }
230        CoroutineResult::Return(()) => {
231            let vm = fiber.vm;
232            drop(fiber);
233            // SAFETY: the coroutine finished; nothing else references its VM.
234            Vm::release_fiber(unsafe { Box::from_raw(vm) });
235        }
236    }
237}
238
239/// Moves woken fibers and expired timers to the ready queue.
240fn absorb() {
241    let now = Instant::now();
242    with_sched(|s| {
243        let woken = std::mem::take(&mut *s.shared.woken.lock());
244        for id in woken {
245            if id != ROOT && s.fibers.contains_key(&id) && !s.ready.contains(&id) {
246                s.ready.push_back(id);
247            }
248        }
249        while let Some(std::cmp::Reverse((t, id))) = s.timers.peek().copied() {
250            if t > now {
251                break;
252            }
253            s.timers.pop();
254            if id == ROOT {
255                s.root_flag.store(true, Ordering::Release);
256            } else if let Some(f) = s.fibers.get(&id) {
257                f.flag.store(true, Ordering::Release);
258                if !s.ready.contains(&id) {
259                    s.ready.push_back(id);
260                }
261            }
262        }
263    });
264}
265
266/// Runs this thread's fibers from its main context until `done()` holds. While nothing is
267/// runnable the thread sleeps in the native state (so collections proceed without it) until a
268/// wakeup or the next timer.
269fn run_until(vm: &mut Vm, mut done: impl FnMut() -> bool) {
270    debug_assert!(!in_fiber());
271    loop {
272        absorb();
273        if done() {
274            return;
275        }
276        if let Some(id) = with_sched(|s| s.ready.pop_front()) {
277            resume(id);
278            catallaxy_gc::safepoint(&vm.m.rec);
279            continue;
280        }
281        let (shared, deadline) = with_sched(|s| (s.shared.clone(), s.timers.peek().map(|r| r.0 .0)));
282        catallaxy_gc::blocking(&vm.m.rec, || {
283            let mut w = shared.woken.lock();
284            if w.is_empty() {
285                match deadline {
286                    Some(t) => {
287                        let now = Instant::now();
288                        if t > now {
289                            shared.cv.wait_for(&mut w, t - now);
290                        }
291                    }
292                    None => {
293                        shared.cv.wait_for(&mut w, Duration::from_millis(100));
294                    }
295                }
296            }
297        });
298    }
299}
300
301/// Suspends the running fiber until its waker fires. Returns at once if it already fired.
302fn park() {
303    let flag = RUNNING_FLAG.with(|f| f.borrow().clone()).expect("park outside a fiber");
304    if flag.swap(false, Ordering::AcqRel) {
305        return;
306    }
307    let y = YIELDER.with(|c| c.get());
308    // SAFETY: set by the running fiber's body; valid while it runs.
309    unsafe { (*y).suspend(()) };
310    flag.store(false, Ordering::Release);
311}
312
313/// Runs every fiber that finished, on any thread, at the end of the thread's main code.
314pub fn drain(vm: &mut Vm) {
315    if in_fiber() || !has_fibers() {
316        return;
317    }
318    run_until(vm, || !has_fibers());
319}
320
321// ---- waiting -----------------------------------------------------------------------------------
322
323/// Waits for an event without blocking the thread when possible. `arm(wake)` must check the
324/// condition and, if it does not hold, arrange for `wake` to be called when it may (under the
325/// same lock as the check); it returns true if the caller must wait. Returns false if this
326/// context cannot wait cooperatively (no fibers, or a thread-keyed lock is held): the caller
327/// then blocks the thread as before. After a true return, re-check the condition.
328pub fn wait_event(vm: &mut Vm, arm: impl FnOnce(Wake) -> bool) -> bool {
329    if !vm.park_allowed() {
330        return false;
331    }
332    if in_fiber() {
333        let w = current_waker();
334        if arm(w.into_wake()) {
335            park();
336        }
337        return true;
338    }
339    if !has_fibers() {
340        return false;
341    }
342    let w = current_waker();
343    let flag = w.flag.clone();
344    if arm(w.into_wake()) {
345        run_until(vm, || flag.swap(false, Ordering::AcqRel));
346    }
347    true
348}
349
350/// Sleeps without blocking the thread when it has fibers (or is one).
351pub fn sleep(vm: &mut Vm, d: Duration) {
352    let deadline = Instant::now() + d;
353    if !vm.park_allowed() || (!in_fiber() && !has_fibers()) {
354        crate::blocking(vm, || std::thread::sleep(d));
355        return;
356    }
357    let id = CURRENT.with(|c| c.get());
358    while Instant::now() < deadline {
359        with_sched(|s| s.timers.push(std::cmp::Reverse((deadline, id))));
360        if in_fiber() {
361            park();
362        } else {
363            let flag = with_sched(|s| s.root_flag.clone());
364            run_until(vm, || flag.swap(false, Ordering::AcqRel) || Instant::now() >= deadline);
365        }
366    }
367}
368
369/// Lets other fibers run. In the main context, runs each ready fiber once.
370pub fn yield_now(vm: &mut Vm) {
371    if in_fiber() {
372        if !vm.park_allowed() {
373            return;
374        }
375        // Suspend with the wake flag raised: the scheduler re-queues us behind the others.
376        let flag = RUNNING_FLAG.with(|f| f.borrow().clone()).expect("running fiber flag");
377        flag.store(true, Ordering::Release);
378        let y = YIELDER.with(|c| c.get());
379        unsafe { (*y).suspend(()) };
380        flag.store(false, Ordering::Release);
381    } else if has_fibers() {
382        let n = with_sched(|s| s.ready.len());
383        for _ in 0..n {
384            absorb();
385            match with_sched(|s| s.ready.pop_front()) {
386                Some(id) => resume(id),
387                None => break,
388            }
389        }
390    }
391}
392
393// ---- joining -----------------------------------------------------------------------------------
394
395/// Waits for fiber `id` (of this thread) and returns its result, or the panic message.
396pub fn join(vm: &mut Vm, id: u64) -> RtResult<Result<Value, String>> {
397    let Some(ctl) = with_sched(|s| s.ctl.get(&id).cloned()) else {
398        return Err(Trap::Panic("FiberError: no such fiber on this thread (fibers are joined on the thread that spawned them)".into()));
399    };
400    loop {
401        if !matches!(*ctl.outcome.lock(), Outcome::Running) {
402            break;
403        }
404        let ctl2 = ctl.clone();
405        let cooperative = wait_event(vm, move |wake| {
406            let o = ctl2.outcome.lock();
407            if !matches!(*o, Outcome::Running) {
408                return false;
409            }
410            ctl2.joiners.lock().push(wake);
411            true
412        });
413        if !cooperative {
414            // Only reachable while holding a thread-keyed lock: the fiber cannot run until the
415            // lock is released, so this would never finish.
416            return Err(Trap::Panic("FiberError: join while holding a lock would deadlock".into()));
417        }
418    }
419    with_sched(|s| s.ctl.remove(&id));
420    let outcome = std::mem::replace(&mut *ctl.outcome.lock(), Outcome::Joined);
421    Ok(match outcome {
422        Outcome::Done => Ok(vm.unpin(id).unwrap_or(Value::NIL)),
423        Outcome::Panicked(m) => Err(m),
424        Outcome::Joined => Err("fiber was already joined".into()),
425        Outcome::Running => unreachable!(),
426    })
427}
428
429/// Whether fiber `id` of this thread has finished.
430pub fn is_done(id: u64) -> bool {
431    with_sched(|s| s.ctl.get(&id).is_none_or(|c| !matches!(*c.outcome.lock(), Outcome::Running)))
432}
433
434// ---- blocking system calls ---------------------------------------------------------------------
435
436type Job = Box<dyn FnOnce() + Send + 'static>;
437
438/// Threads that run blocking system calls for parked fibers (`CATX_IO_THREADS`, default 16).
439/// They never touch the heap.
440struct IoPool {
441    queue: Mutex<VecDeque<Job>>,
442    cv: Condvar,
443}
444
445static IO: LazyLock<IoPool> = LazyLock::new(|| {
446    let n = std::env::var("CATX_IO_THREADS").ok().and_then(|v| v.parse::<usize>().ok()).unwrap_or(16).max(1);
447    for i in 0..n {
448        let _ = std::thread::Builder::new().name(format!("catx-io-{i}")).spawn(|| loop {
449            let job = {
450                let mut q = IO.queue.lock();
451                loop {
452                    if let Some(j) = q.pop_front() {
453                        break j;
454                    }
455                    IO.cv.wait(&mut q);
456                }
457            };
458            job();
459        });
460    }
461    IoPool { queue: Mutex::new(VecDeque::new()), cv: Condvar::new() }
462});
463
464/// Runs a blocking call that does not touch the heap. In a fiber (or a main context that has
465/// fibers) it runs on the I/O pool while other fibers run; otherwise the thread blocks in the
466/// native state, as `crate::blocking` does.
467pub fn blocking_io<R: Send>(vm: &mut Vm, f: impl FnOnce() -> R + Send) -> R {
468    if !vm.park_allowed() || (!in_fiber() && !has_fibers()) {
469        return crate::blocking(vm, f);
470    }
471    let done = Arc::new(AtomicBool::new(false));
472    let mut result: Option<std::thread::Result<R>> = None;
473    let w = current_waker();
474    {
475        let slot = &mut result as *mut Option<std::thread::Result<R>> as usize;
476        let (done2, w2) = (done.clone(), w.clone());
477        let job: Box<dyn FnOnce() + Send + '_> = Box::new(move || {
478            let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
479            // SAFETY: the waiting context stays suspended until `done` is set.
480            unsafe { *(slot as *mut Option<std::thread::Result<R>>) = Some(r) };
481            done2.store(true, Ordering::Release);
482            w2.wake();
483        });
484        // SAFETY: the job borrows from this frame, which stays alive (parked or waiting) until
485        // the job has signaled `done`, after which the job touches nothing.
486        let job: Job = unsafe { std::mem::transmute::<Box<dyn FnOnce() + Send + '_>, Job>(job) };
487        IO.queue.lock().push_back(job);
488        IO.cv.notify_one();
489    }
490    while !done.load(Ordering::Acquire) {
491        if in_fiber() {
492            park();
493        } else {
494            let flag = w.flag.clone();
495            let d = done.clone();
496            run_until(vm, || flag.swap(false, Ordering::AcqRel) || d.load(Ordering::Acquire));
497        }
498    }
499    match result.take().expect("I/O job result") {
500        Ok(r) => r,
501        Err(p) => std::panic::resume_unwind(p),
502    }
503}