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 held

View on GitHub (pinned to 0b88d5ff75)

Solutions

  1. Hoist acquisition out of the recursion: wrap the whole traversal in one with_read_lock and use an internal, unlocked recursive method.
  2. Replace manual acquire/release with with_read_lock so every acquisition is matched by exactly one ensure-driven release.
  3. 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

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


AI-assisted analysis of ruby-concurrency/concurrent-ruby@0b88d5ff75 (2026-08-21). Data as JSON: /api/errors/c38528c244ffddb6. Report an issue: GitHub.