Preamble
After Gleam and the BEAM Scheduler Under Load’s load tests, “why did p99 spike?” is the interesting question. On the BEAM, scheduler literacy turns mysterious tail latency into configuration and code changes. This is not an academic VM tour—it is the subset that tends to matter when production traffic misbehaves.
Reductions and preemptive fairness
BEAM processes are scheduled in reduction chunks. When a process consumes its budget, the scheduler preempts it—cooperative fairness with teeth. Long-running CPU loops without reduction opportunities can still hurt the system; busy-wait is a bug in any runtime.
Understanding reductions explains why some “small” tasks still wait: everyone shares schedulers, and migration between scheduler threads adds jitter at high load.
Scheduler threads and CPU topology
Flags like +S tune scheduler thread count relative to cores. More is not always better—false sharing, NUMA effects, and lock contention on run queues appear in ugly ways. Tests belong on hardware similar to production, not only on the laptop.
Native code and NIF pitfalls
NIFs and long C calls can block a scheduler thread. That is the BEAM parallel to blocking the Tokio executor—symptoms differ, the root cause matches. If a driver holds the CPU for milliseconds, you have bought head-of-line blocking wholesale.
Noisy neighbors inside one node
Mixed workloads on one VM—batch jobs beside latency-sensitive RPCs—need process priority and OTP design discipline, sometimes separate nodes. Observability (OpenTelemetry Traces Across Python and Java) still tells the truth faster than intuition.
Code and flags you can run today
Reduction accounting — every scheduled process consumes reductions as it runs; the VM preempts when the budget is exhausted. Inspect a suspect pid:
{reductions, R} = erlang:process_info(Pid, reductions),
{current_function, MFA} = erlang:process_info(Pid, current_function),
{message_queue_len, Q} = erlang:process_info(Pid, message_queue_len).
If R climbs rapidly while Q stays flat, you likely have a CPU-bound loop on that process. If Q grows without bound, you have a mailbox problem, not a mystery scheduler bug.
Scheduler thread count (+S) — set online schedulers vs a reserve (exact flag shapes vary by OTP release; verify with erl +S help on your version):
erl +S 8:8
Interpretation: more schedulers can raise throughput until lock contention on run queues or false sharing dominates—measure on production-class hardware, not the laptop alone.
NIF smell test — if C code runs milliseconds without yielding, it can block a scheduler thread entirely:
%% Pseudocode: prefer short NIF slices or move work to a dirty scheduler / separate OS thread
%% bad: long memcpy + tight loop inside NIF with zero yields
The operational parallel is Tokio: a blocking syscall inside async fn starves the pool—Rust and Tokio: The Same Concurrent Workload in Type-Safe Threads’s spawn_blocking discipline is the Rust-side fix.
Method comparison: preemptive BEAM vs cooperative async
| Mechanism | BEAM | Tokio (Rust) |
|---|---|---|
| Fairness lever | Reduction budget + migration | .await yields + runtime work stealing |
| Foot-gun | Long NIF / busy loops | Blocking I/O / mutex in async task |
| Tuning knob | +S, process priority, node split |
worker_threads, blocking pools, queue depth |
Conclusion
Scheduler literacy converts “the VM is weird” into action: fix blocking calls, tune +S, isolate workloads, or split nodes. Send, Sync, and Fearless Concurrency in Rust returns to Rust with Send/Sync as the compiler’s answer to a different slice of the same problem.