ruby-concurrency/concurrent-ruby · error · Concurrent::ResourceLimitError

Too many reader threads

Error message

Too many reader threads

What it means

ReentrantReadWriteLock keeps global state in @Counter whose low 15 bits count active readers across all threads (MAX_READERS = 32767); each thread's first read hold bumps the global counter. acquire_read_lock raises Concurrent::ResourceLimitError when that packed reader field is full. Because reentrancy lets a single thread's leak grow the global count on its own, this almost always means unbalanced acquire/release somewhere.

Source

Thrown at lib/concurrent-ruby/concurrent/atomic/reentrant_read_write_lock.rb:178

    # @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
          @ReadQueue.synchronize do
            @ReadQueue.ns_wait if waiting_or_running_writer?
          end
          # Note: the above 'synchronize' block could have used #wait_until,
          #   but that waits repeatedly in a loop, checking the wait condition
          #   each time it wakes up (to protect against spurious wakeups)
          # But we are already in a loop, which is only broken when we successfully
          #   acquire the lock! So we don't care about spurious wakeups, and would
          #   rather not pay the extra overhead of using #wait_until

          # After a reader has waited once, they are allowed to "barge" ahead of waiting writers
          # But if a writer is *running*, the reader still needs to wait (naturally)
          while true
            c = @Counter.value

View on GitHub (pinned to 0b88d5ff75)

Solutions

  1. Use with_read_lock/with_write_lock block forms everywhere; each block performs exactly one acquire and one guaranteed release.
  2. Audit for unbalanced release counts: the reentrant lock requires one release per acquisition, not one per thread.
  3. Instrument the lock (log thread id plus acquire/release pairs) to find the leaking caller.
  4. Bound the number of threads taking read locks (e.g. run work through a fixed ThreadPoolExecutor) if genuine reader concurrency is the issue.

Example fix

// before — manual pairing that leaks when read_all raises
lock.acquire_read_lock
rows = read_all
lock.release_read_lock

// after
lock.with_read_lock { read_all }
Defensive patterns

Strategy: try-catch

Try / catch

begin
  lock.with_read_lock { read_all }
rescue Concurrent::ResourceLimitError => e
  logger.fatal("global reader saturation, probable unbalanced acquire/release: #{e.message}")
  raise
end

Prevention

When it happens

Trigger: A thread takes its first read hold via with_read_lock and never returns from the block (infinite loop); manual acquire_read_lock on many threads without matching releases; releasing fewer times than acquiring so counts ratchet upward.

Common situations: Background worker threads each leaking a read lock per job over time; long-running loops inside read critical sections; migration from plain ReadWriteLock where release accounting differs.

Related errors


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