You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Zstd.decompress holds the GVL, blocking other threads for the whole call #155
I've been using zstd-ruby to decompress security-advisory archives inside Sidekiq workers, which run several threads per process. Zstd.decompress on a large payload blocks every other thread in the process for the full duration of the call. It turns out the one-shot decompress path holds Ruby's GVL (Global VM Lock) throughout the libzstd call, whereas the rest of the gem — compression, streaming decompress, streaming compress — releases it.
Root cause
Zstd.decompress runs its decode loop in the C function decode_one_frame, which calls ZSTD_decompressStream directly and so keeps the GVL for the whole operation. By contrast, Zstd.compress and both streaming classes route their libzstd calls through the GVL-releasing wrappers in common.h (zstd_compress, zstd_stream_compress, zstd_stream_decompress). One-shot decompress is the only hot path that does not — the streaming code already does the right thing.
Reproduction
A heartbeat thread records a timestamp every ~0.5 ms. The main thread then runs one large Zstd.decompress, and afterwards we count how many heartbeat samples landed inside the decompress window. If the GVL is held throughout, the heartbeat is starved and no samples land.
require'zstd-ruby'defmonoProcess.clock_gettime(Process::CLOCK_MONOTONIC)endsize=(ENV['MB'] || '2048').to_i * 1024 * 1024payload=Zstd.compress("\0" * size)# ~64 KB compressed, 2 GiB decompressedstop=falsestamps=[]hb=Thread.newdountilstopstamps << monosleep0.0005endendsleep0.2t0=monoZstd.decompress(payload)t1=monostop=truehb.joininside=stamps.select{ |s| s >= t0 && s <= t1}bounds=[t0, *inside,t1]max_gap=bounds.each_cons(2).map{ |a,b| b - a}.maxprintf"decompress_time = %.3f s\n",t1 - t0printf"heartbeat samples in run = %d\n",inside.sizeprintf"largest heartbeat stall = %.3f s\n",max_gap
Environment: zstd-ruby at main (39894ff), Ruby 3.3.11, macOS arm64.
Results
Before (current main):
decompress_time = 0.467 s
heartbeat samples in run = 0
largest heartbeat stall = 0.467 s — the whole call; the other thread was frozen start to finish.
After (with the one-line fix below):
decompress_time = 0.46–0.52 s (unchanged)
heartbeat samples in run ≈ 700
largest heartbeat stall ≤ 0.016 s
Proposed fix
One line in decode_one_frame (ext/zstdruby/zstdruby.c): call the existing zstd_stream_decompress wrapper instead of ZSTD_decompressStream directly, so the GVL is released once per output chunk. There's no measurable throughput cost. I'll open a PR with it.
Hi @SpringMT, any update on #156? Avoiding a pause in every other Ruby thread during decompression would be a helpful improvement for threaded applications.
Could you also publish a patch release with the already merged hang fix in #143? 2.0.8 is still the latest gem, and truncated input can leave the whole process stuck with Ruby timeouts unable to interrupt it. That seems worth releasing even if the GVL change here needs more time.
Activity
ZilvinasKucinskas commentedon Sep 10, 2026
Hi @SpringMT, any update on #156? Avoiding a pause in every other Ruby thread during decompression would be a helpful improvement for threaded applications.
Could you also publish a patch release with the already merged hang fix in #143? 2.0.8 is still the latest gem, and truncated input can leave the whole process stuck with Ruby timeouts unable to interrupt it. That seems worth releasing even if the GVL change here needs more time.
Thanks for maintaining the gem!