Project

General

Profile

Actions

Bug #18455

open

`IO#close` has poor performance and difficult to understand semantics.

Added by ioquatix (Samuel Williams) almost 3 years ago. Updated 2 months ago.


Description

IO#close should be responsible for closing the file descriptor referred to by the IO instance. When dealing with buffered IO, one can also expect this to flush the internal buffers if possible.

Currently, all blocking IO operations release the GVL and perform the blocking system call using rb_thread_io_blocking_region. The current implementation takes a file descriptor and adds an entry to the VM global waiting_fds list. When the operation is completed, the entry is removed from waiting_fds.

When calling IO#close, this list is traversed and any threads performing blocking operations with a matching file descriptor are interrupted. The performance of this is O(number of blocking IO operations) which in practice the performance of IO#close can take milliseconds with 10,000 threads performing blocking IO. This performance is unacceptable.

#!/usr/bin/env ruby

require 'benchmark'

class Reading
  def initialize
    @r, @w = IO.pipe

    @thread = Thread.new do
      @r.read
    rescue IOError
      # Ignore.
    end
  end

  attr :r
  attr :w

  attr :thread

  def join
    @thread.join
  end
end

def measure(count = 10)
  readings = count.times.map do
    Reading.new
  end

  sleep 10

  duration = Benchmark.measure do
    readings.each do |reading|
      reading.r.close
      reading.w.close
    end
  end

  average = (duration.total / count) * 1000.0
  pp count: count, average: sprintf("%0.2fms", average)

  readings.each(&:join)
end

measure(   10)
measure(  100)
measure( 1000)
measure(10000)

In addition, the semantics of this operation are confusing at best. While Ruby programs are dealing with IO instances, the VM is dealing with file descriptors, in effect performing some internal de-duplication of IO state. In practice, this leads to strange behaviour:

#!/usr/bin/env ruby

r, w = IO.pipe
r2 = IO.for_fd(r.to_i)
pp r: r, r2: r2

t = Thread.new do
  r2.read rescue nil
  r2.read # EBADF
end

sleep 0.5
r.close
t.join rescue nil

pp r: r, r2: r2
# r is closed, r2 is valid but will raise EBADF on any operation.

In addition, this confusing behaviour extends to Ractor and state is leaked between the two:

r, w = IO.pipe

ractor = Ractor.new(r.to_i) do |fd|
  r2 = IO.for_fd(fd)
  r2.read
  # r2.read # EBADF
end

sleep 0.5
r.close

pp take: ractor.take

I propose the following changes to simplify the semantics and improve performance:

  • Move the semantics of waiting_fds from per-fd to per-IO. This means that IO#close only interrupts blocking operations performed on the same IO instance rather than ANY IO which refers to the same file descriptor. I think this behaviour is easier to understand and still protects against the vast majority of incorrect usage.
  • Move the details of struct rb_io_t to internal/io.h so that the implementation details are not part of the public interface.

Benchmarks

Before:

{:count=>10, :average=>"0.19ms"}
{:count=>100, :average=>"0.11ms"}
{:count=>1000, :average=>"0.18ms"}
{:count=>10000, :average=>"1.16ms"}

After:

{:count=>10, :average=>"0.20ms"}
{:count=>100, :average=>"0.11ms"}
{:count=>1000, :average=>"0.15ms"}
{:count=>10000, :average=>"0.68ms"}

After investigating this further I found that the rb_thread_io_blocking_region using ubf_select can be incredibly slow, proportional to the number of threads. I don't know whether it's advisable but:

        BLOCKING_REGION(blocking_node.thread, {
            val = func(data1);
            saved_errno = errno;
        }, NULL /* ubf_select */, blocking_node.thread, FALSE);

Disabling the UBF function and relying on read(fd, ...)/write(fd, ...) blocking operations to fail when close(fd) is invoked might be sufficient? This needs more investigation but after making this change, we have constant-time IO#close.

{:count=>10, :average=>"0.13ms"}
{:count=>100, :average=>"0.06ms"}
{:count=>1000, :average=>"0.04ms"}
{:count=>10000, :average=>"0.09ms"}

Which is ideally what we want.


Related issues 2 (2 open0 closed)

Related to Ruby master - Bug #19039: Closing an IO being select'ed in another thread does not resume the threadOpenActions
Related to Ruby master - Bug #14681: `syswrite': stream closed in another thread (IOError)Assignedioquatix (Samuel Williams)Actions
Actions

Also available in: Atom PDF

Like0
Like0Like0Like0Like0Like0Like0Like0Like0Like0Like0Like0