ruby-concurrency/concurrent-ruby · error · Concurrent::ResourceLimitError
Too many reader holds on this thread
Error message
Too many reader holds on this thread
What it means
ReentrantReadWriteLock tracks per-thread holdings in a thread-local @HeldCount whose low 15 bits count this thread's outstanding read-lock holds (READ_LOCK_MASK = 32767). Reentrant reacquisition increments that counter; when the next acquire would overflow it, acquire_read_lock raises Concurrent::ResourceLimitError. In practice this means unbalanced reentrant acquisition — usually recursion or a loop that acquires more read locks than it releases.
Source
Thrown at lib/concurrent-ruby/concurrent/atomic/reentrant_read_write_lock.rb:164
raise ArgumentError.new('no block given') unless block_given?
acquire_write_lock
begin
yield
ensure
release_write_lock
end
end
# Acquire a read lock. If a write lock is held by another thread, will block
# until it is released.
#
# @return [Boolean] true if the lock is successfully acquired
#
# @raise [Concurrent::ResourceLimitError] if the maximum number of readers
# or per-thread reentrant acquires is exceeded.
def acquire_read_lock
if (held = @HeldCount.value) > 0
raise ResourceLimitError.new('Too many reader holds on this thread') if (held & READ_LOCK_MASK) == READ_LOCK_MASK
# If we already have a lock, there's no need to wait
if held & READ_LOCK_MASK == 0
# But we do need to update the counter, if we were holding a write
# lock but not a read lock
@Counter.update { |c| c + 1 }
end
@HeldCount.value = held + 1
return true
end
while true
c = @Counter.value
raise ResourceLimitError.new('Too many reader threads') if max_readers?(c)
# If a writer is waiting OR running when we first queue up, we need to wait
if waiting_or_running_writer?(c)
# Before going to sleep, check again with the ReadQueue mutex heldView on GitHub (pinned to 0b88d5ff75)
Solutions
- Hoist acquisition out of the recursion: wrap the whole traversal in one with_read_lock and use an internal, unlocked recursive method.
- Replace manual acquire/release with with_read_lock so every acquisition is matched by exactly one ensure-driven release.
- If recursion depth legitimately exceeds 32767, restructure to an iterative traversal — Ruby's stack is a hazard there anyway.
Example fix
// before — one read acquire per recursion level
def walk(node)
lock.with_read_lock { visit(node); node.children.each { |c| walk(c) } }
end
// after — single acquisition for the whole traversal
def walk(node)
lock.with_read_lock { walk_locked(node) }
end
def walk_locked(node)
visit(node)
node.children.each { |c| walk_locked(c) }
end Defensive patterns
Strategy: try-catch
Try / catch
begin
lock.with_read_lock { walk(node) }
rescue Concurrent::ResourceLimitError => e
logger.fatal("per-thread read-hold overflow: #{e.message}")
raise
end Prevention
- Acquire reentrant locks once at the outermost layer; do not re-acquire per recursive step.
- Keep acquisition and release symmetric: one with_read_lock block per acquisition.
- Watch for accidental reacquisition inside callbacks invoked under the lock.
When it happens
Trigger: Recursive methods calling with_read_lock at every level so the per-thread hold count tracks stack depth past 32767; a loop on one thread calling acquire_read_lock with a release missing on one path; refactors that removed an ensure-driven release.
Common situations: Deep recursion over trees or graphs guarded per-level by a reentrant read lock; accidental reacquisition inside callbacks invoked under the lock; mixing manual and block lock APIs with asymmetric release counts.
Related errors
- Too many reader threads
- Too many writer threads
- Too many reader threads
- Too many writer threads
- no block given
AI-assisted analysis of ruby-concurrency/concurrent-ruby@0b88d5ff75 (2026-08-21).
Data as JSON: /api/errors/c38528c244ffddb6.
Report an issue: GitHub.