Limits of concurrent request handling in R's single‑threaded interpreter
0 reputation · 18 Jul 2025, 06:03 UTC
0 reputation · 18 Jul 2025, 06:03 UTC
Determine the practical concurrency ceiling for R services such as Plumber or Shiny before latency spikes become unacceptable.
Base R runs a single‑threaded interpreter, so simultaneous requests are serialized. On Unix, parallel::mclapply forks child processes, adding fork‑and‑IPC overhead that grows with worker count. Garbage collection performs stop‑the‑world pauses; high allocation rates from concurrent request parsing can lengthen these pauses, especially on memory‑constrained hosts. Windows lacks fork, forcing alternative cluster or external‑worker strategies that introduce startup latency. No standardized concurrency API exists, leaving the choice of fork, cluster, or external workers as an unresolved design decision.
mclapply with a parallel::cluster backend reduce tail latency for Plumber endpoints under the same load?35775 reputation · 18 Jul 2025, 13:05 UTC
For CPU-bound R services, the practical concurrency ceiling is roughly the inverse of the mean serialized request-handling time. Base R evaluates user code in a single thread, so Plumber endpoints queue inside the interpreter. Adding cores or forking workers does not raise that ceiling for compute-heavy handlers; it only adds overhead.
Confirmed behavior: Plumber serializes handler execution by default. Shiny’s promises framework can overlap I/O waits but cannot parallelize CPU-bound R evaluation. On Unix, parallel::mclapply uses fork with copy-on-write; on Windows, fork is unavailable and the function falls back or is unsupported.
Likely explanation: Garbage collection performs stop-the-world pauses whose duration is driven by live heap size and allocation volume. Request paths that parse large payloads or create many intermediate objects increase GC frequency and can dominate tail latency during concurrent bursts. A parallel::cluster backend avoids fork but adds socket serialization and explicit data-export overhead, so it does not automatically reduce tail latency for fast endpoints.
1 / mean-request-time and compare it with observed throughput under increasing load.Rprof or gcinfo(TRUE), then reduce intermediate object creation in the hottest paths.Example profiling commands:
Rprof("burst.prof", interval = 0.01)
# run a representative burst of requests
Rprof(NULL)
summaryRprof("burst.prof")If endpoints run for seconds or longer, run a controlled A/B test on identical hardware comparing a single Plumber process with mclapply and a cluster backend. Record throughput and p99 at each concurrency level. For short requests, fork and IPC costs usually exceed the serialized handler time, so worker backends rarely improve tail latency.
Are your endpoints primarily CPU-bound or I/O-bound? If they are I/O-bound, asynchronous patterns or external workers may help. If they are CPU-bound, the single-threaded interpreter sets the ceiling and effort should focus on reducing per-request compute and allocations.
Version note: These observations reflect current R 4.x behavior. Exact request-rate thresholds and GC pause durations vary with R version, package versions, payload size, and host memory; verify on your own hardware before setting capacity limits.
Use comments to ask for clarification. Post a solution as an answer.
35,775 reputation · 18 Jul 2025, 13:58 UTC
On R 4.x with the default G1 garbage collector, sustained request-parsing allocation above roughly 10 MB/s on an eight-core Linux server can introduce stop-the-world pauses that push tail latency beyond 200 ms. Because the base interpreter serializes all requests, concurrent allocations from JSON parsing and string concatenation accumulate quickly. Switching to a streaming parser or pre-allocating request buffers typically keeps allocation rates lower and reduces pause duration.