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

Cannot release a read lock which is not held

Error message

Cannot release a read lock which is not held

What it means

`Concurrent::ReentrantReadWriteLock` packs the current thread's held read-lock count into a thread-local `@HeldCount`. `release_read_lock` decrements it; if the read bits come back as READ_LOCK_MASK (all ones) the counter underflowed — this thread held no read lock — and IllegalOperationError is raised instead of corrupting the count. Because the count is thread-local, releasing on a thread that acquired nothing raises even while other threads legitimately hold read locks.

Source

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

        end
      end
      false
    end

    # Release a previously acquired read lock.
    #
    # @return [Boolean] true if the lock is successfully released
    def release_read_lock
      held = @HeldCount.value = @HeldCount.value - 1
      rlocks_held = held & READ_LOCK_MASK
      if rlocks_held == 0
        c = @Counter.update { |counter| counter - 1 }
        # If one or more writers were waiting, and we were the last reader, wake a writer up
        if waiting_or_running_writer?(c) && running_readers(c) == 0
          @WriteQueue.signal
        end
      elsif rlocks_held == READ_LOCK_MASK
        raise IllegalOperationError, "Cannot release a read lock which is not held"
      end
      true
    end

    # Acquire a write lock. Will block and wait for all active readers and writers.
    #
    # @return [Boolean] true if the lock is successfully acquired
    #
    # @raise [Concurrent::ResourceLimitError] if the maximum number of writers
    #   is exceeded.
    def acquire_write_lock
      if (held = @HeldCount.value) >= WRITE_LOCK_HELD
        # if we already have a write (exclusive) lock, there's no need to wait
        @HeldCount.value = held + WRITE_LOCK_HELD
        return true
      end

      while true

View on GitHub (pinned to 0b88d5ff75)

Solutions

  1. Balance the count: every `acquire_read_lock` on a thread needs exactly one matching `release_read_lock` (reentrancy means N acquires need N releases).
  2. Guard releases with a held-flag set only after a successful acquire.
  3. Prefer `lock.with_read_lock { ... }`, whose block form keeps nesting balanced.
  4. In cleanup code where the count is uncertain, rescue Concurrent::IllegalOperationError and continue.

Example fix

// before
def read_through
  @lock.acquire_read_lock unless @in_read
  result = fetch
  @lock.release_read_lock # raises when @in_read was true (no acquire ran)
  result
end

// after
def read_through
  @lock.with_read_lock { fetch }
end
Defensive patterns

Strategy: try-catch

Try / catch

begin
  lock.release_read_lock
rescue Concurrent::IllegalOperationError
  # this thread held no read lock; ignore in cleanup
end

Prevention

When it happens

Trigger: More `release_read_lock` calls than acquires on the current thread (e.g. a nested level popped twice); release placed in ensure on a path whose acquire was skipped by an early return or exception; release executed on a different thread/fiber than the acquire (its count there is zero).

Common situations: Reentrant nesting bookkeeping drifting out of sync as code paths are added; conditional acquire with unconditional release; fiber schedulers or job runners that resume work on another thread.

Related errors


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