-
Notifications
You must be signed in to change notification settings - Fork 0
research concurrency alternatives comparison thread per call
Spawn a brand-new Thread.new { ... } for every single Duckling.parse
invocation, .value (or .join) it from the calling Fiber, and let the
thread exit when the call completes. No persistent state, no lifecycle to
manage.
def self.parse(text, **kwargs)
Thread.new { parse_native(text, **kwargs) }.value
end(parse_native standing in for whatever the actual GVL-released native
call ends up being named — that naming/implementation detail belongs to
the topic actually implementing issue #57, not this comparison.)
CRuby's Thread.new creates a genuine native OS thread — see the
Thread class docs
("Threads are implemented as native OS threads"). Two independent data
points on what that costs:
-
Measured locally, Ruby 3.3.6 (
x86_64-darwin24), this sandbox: 2000 iterations ofThread.new{}.joinin a tight loop averaged ~70 microseconds per thread (139ms / 2000). This is an empty block — pure create+schedule+teardown cost, no work done inside the thread. -
Per-thread memory reservation:
ruby(1)'s documented defaults for thread stack sizes (RUBY_THREAD_VM_STACK_SIZE/RUBY_THREAD_MACHINE_STACK_SIZE), pinned to thev3_3_6tag matching this repo's CI Ruby version — seeman/ruby.1#L727-L741: on a 64-bit CPU, a new thread reserves ~1MB for the VM stack and ~1MB for the machine stack by default (~2MB total), vs. a Fiber's ~128KB VM stack- ~512KB machine stack (~640KB total) — roughly 3x more per-thread reservation than a fiber, though both are virtual-memory reservations, not necessarily fully-committed physical pages.
Neither number is alarming for this gem's use case: Duckling.parse calls
are not fired in tight loops of thousands-per-second in the way a
web-request-per-fiber workload might be (that's the whole
camping_trip_email-shaped worst case in the benchmark table in the parent
doc — a single call already costs 791ms; the ~70µs spawn overhead is
0.009% of that). The one scenario where spawn overhead is proportionally
significant is the fastest inputs (empty at 24.1µs, no_match at
213.4µs) — see the parent README's recommendation section for why that
tradeoff is acceptable given issue #57's explicit non-goal of throughput
optimization.
Two shapes are possible for the spawned thread's block:
-
Spawn thread does everything, including arg parsing: the Ruby
method takes raw args, immediately does
Thread.new { Duckling.parse_native(*args, **kwargs) }.value, and all Magnus-sideTryConvert/scan_argswork (seeext/duckling/src/lib.rs's currentscan_args/get_kwargscalls) happens on the spawned thread. -
Main thread does arg parsing, spawned thread only does the
GVL-released native call: validate/convert
locale,dims,reference_time,with_latentinto their Rust-side representations (Locale,Vec<DimensionKind>,Context,Options) on the calling thread first, then hand only those already-converted,Send-safe, non-Ruby-Valuevalues into the spawned thread's closure, which does nothing but callduckling::parse(with the GVL released) and convert the resultingVec<Entity>back to Ruby values afterward (on which thread, exactly, is itself a further sub-question — converting back to RubyValues requires holding the GVL, so that conversion has to happen either right before the spawned thread exits, while it still holds the GVL back, or after.valuereturns control to the calling thread).
Shape 2 is meaningfully safer: it confines the spawned thread's Ruby-object
surface to the smallest possible window (ideally: none at all, if entity
results can be converted back to plain Rust-owned data — String,
integers, enum tags — and only turned into Ruby Values after control
returns to the original calling thread/Fiber). This sidesteps having to
reason carefully about Magnus's Value/GC-safety rules
(a magnus Value must not be stashed across a boundary where the GC could
run without the VM being able to see it as a GC root) on a thread whose
whole lifecycle is "spawn, do one call, die" — precisely the kind of
short-lived, easy-to-get-subtly-wrong scenario worth avoiding by
construction rather than by careful discipline. This is a design decision
for whichever topic actually implements the fix, not settled here — but
shape 2 is the safer starting assumption.
Either shape's ArgumentError-raising validation (invalid locale:,
invalid dims: — see the existing
parse_locale/parse_dims
functions) doesn't need to run on a spawned thread at all under shape 2 —
which also means invalid-argument errors can fail fast, before ever paying
the thread-spawn cost.
Two independent panic-catching layers are already relevant here, established by other research in this issue's tree:
-
Magnus's own FFI dispatch already wraps every registered method call
in
std::panic::catch_unwind.ext/duckling/src/lib.rsregistersparseviafunction!(parse, -1)(init), with signaturefn parse(ruby: &Ruby, args: &[Value]) -> Result<RArray, Error>— this matches magnus 0.8.2'sRubyFunctionCArytrait, whosecall_handle_errorwraps the call incatch_unwindand converts any caught panic into a raised Ruby exception viaError::from_panic+raise, unconditionally (not gated behind any Cargo profile) — seemethod.rs#L1390-L1421in the magnus crate (pinned to the0.8.2tag, matchingmagnus = "0.8"in this gem'sCargo.toml). This applies identically regardless of which OS thread is running the call — the calling thread today, or aThread.new-spawned thread once this fix lands — because it's Magnus's own trampoline around the Rust function body, unrelated to which thread invokes it. -
This does not cover the GVL-released callback itself. Per the
sibling
duckling-crate-thread-safetyresearch (research-duckling-crate-thread-safety-panic-safety), a rawextern "C"callback handed torb_thread_call_without_gvl(the mechanism the fix is expected to use to actually release the GVL) is a separate FFI boundary Magnus's outercatch_unwinddoesn't protect on its own — unwinding a panic throughrb_thread_call_without_gvl's own C stack frame is undefined behavior regardless of an outer Rustcatch_unwindfurther up the call stack, so the fix needs its owncatch_unwindimmediately around that callback, unconditionally. This is orthogonal to dispatch strategy: it's needed whether the outer call runs on the main thread, a thread-per-call spawned thread, or a worker-pool thread.
Given both layers exist (magnus's outer one already, and the fix's own
inner one around the without_gvl callback), a panic anywhere in the call
graph becomes an ordinary raised Ruby exception on whichever thread is
executing — by the time it would reach the spawned thread's block boundary,
it is already a normal Ruby exception, not an in-flight unwind.
Propagation via Thread#value: an unhandled exception raised inside a
Thread.new block is captured by that Thread object, not silently
dropped and not automatically re-raised elsewhere — calling #value (or
#join) on that thread from the caller re-raises it in the caller's
context. This is documented, standard behavior:
When an unhandled exception is raised inside a thread, it will terminate. [...] The bang version of the exception raising methods (
Thread#raise), when called with no thread, raises inThread.current. [Thread#value] has the same properties as#joinregarding unhandled exceptions.
— Thread class docs (see
#join and #value; Thread.new{ raise "x" }.value re-raises
RuntimeError: x in the caller, confirmed against the documented
behavior). No explicit handling is needed on top of this: a straightforward
Thread.new { ... }.value already gets correct exception propagation for
free, for both ordinary Err(Error) returns and caught panics converted to
exceptions by either catch_unwind layer above.
One caveat: Thread.abort_on_exception and $DEBUG global settings, if
enabled process-wide by the embedding application, change how an unhandled
exception in any thread is handled (immediate propagation to the main
thread rather than deferred to #join/#value). This gem doesn't need to
set or rely on either — plain #value is sufficient and doesn't depend on
global interpreter state the gem doesn't control.
| Concern | Verdict |
|---|---|
| Spawn/teardown cost | ~70µs measured locally; negligible-to-moderate depending on scenario (see parent README) |
| Memory overhead | ~2MB reserved (mostly virtual) per in-flight call, 64-bit default |
| Arg-conversion thread | Prefer: validate/convert on caller thread, spawned thread only does the native call |
| Panic propagation | Free via Thread#value, once the fix adds its own catch_unwind around the without_gvl callback (a requirement independent of dispatch strategy) |
| Concurrency ceiling | One real OS thread per in-flight call — bounded only by the OS and the wrapped crate's confirmed thread-safety |