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::ReadWriteLock#release_read_lock` decrements the running-reader count and raises IllegalOperationError when that count is already zero — it refuses to underflow. Read locks have no owner tracking, so any thread may release, but only while at least one reader holds the lock. Typical causes: a double release, releasing on a code path that never acquired, or releasing from a thread that never participated while no other reader exists.

Source

Thrown at lib/concurrent-ruby/concurrent/atomic/read_write_lock.rb:147

              return if @Counter.compare_and_set(c, c+1)
            end
          end
        else
          break if @Counter.compare_and_set(c, c+1)
        end
      end
      true
    end

    # Release a previously acquired read lock.
    #
    # @return [Boolean] true if the lock is successfully released
    #
    # @raise [Concurrent::IllegalOperationError] if no read lock is currently held.
    def release_read_lock
      while true
        c = @Counter.value
        raise IllegalOperationError, 'Cannot release a read lock which is not held' if running_readers(c) == 0

        if @Counter.compare_and_set(c, c-1)
          # If one or more writers were waiting, and we were the last reader, wake a writer up
          if waiting_writer?(c) && running_readers(c) == 1
            @WriteLock.signal
          end
          break
        end
      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.

View on GitHub (pinned to 0b88d5ff75)

Solutions

  1. Audit call sites so every `release_read_lock` pairs with exactly one successful `acquire_read_lock` on the same path.
  2. Set a `held = true` flag only after acquire succeeds, and release in ensure only when `held`.
  3. Replace manual pairs with `lock.with_read_lock { ... }`, which acquires and releases for you.
  4. For best-effort cleanup where state is unknown, rescue Concurrent::IllegalOperationError and log.

Example fix

// before
lock.acquire_read_lock
begin
  process
ensure
  cleanup
  lock.release_read_lock
end
lock.release_read_lock # stray second release -> raises

// after
lock.with_read_lock { process }
cleanup
Defensive patterns

Strategy: try-catch

Validate before calling

lock.release_read_lock if lock.running_readers? # no such predicate exists; rely on your own pairing instead
# practical pre-check: track acquisition yourself
@read_held = true after lock.acquire_read_lock; lock.release_read_lock if @read_held

Try / catch

begin
  lock.release_read_lock
rescue Concurrent::IllegalOperationError
  # no reader held; safe to continue in cleanup paths
end

Prevention

When it happens

Trigger: Calling `lock.release_read_lock` in both a method body and its ensure block (double release); a conditional `acquire_read_lock` branch paired with an unconditional release; early-return refactor that skips the acquire but still hits the release. Prefer the built-in `with_read_lock { ... }` block form which guarantees pairing.

Common situations: Manual lock management copied from examples; ensure-block cleanup where the exception occurred before acquire; multiple exit paths added over time so release gets reached twice.

Related errors


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