Bug #18455
open`IO#close` has poor performance and difficult to understand semantics.
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 thatIO#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
tointernal/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.