Project

General

Profile

Actions

Feature #20610

open

Float::INFINITY as IO.select timeout argument

Added by akr (Akira Tanaka) 5 months ago. Updated 4 months ago.

Status:
Open
Assignee:
-
Target version:
-
[ruby-core:118467]

Description

I propose IO.select accepts Float::INFINITY as a timeout argument.
It behaves the same as nil which means IO.select will block indefinitely.

Motivation:

Currently, the Ruby convention to indicate no timeout is using nil.
This practice often forces us to treat the nil case separately.

Conceptually, no timeout can be thought of as an infinite timeout.
So I propose to accept Float::INFINITY as a timeout.

It makes us less conditionals when we need to calculate or compare timeouts.

Assume now method as follows to read the following examples.

def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)

Example 1: absolute timeout

Sometimes we maintain timeout as an absolute clock.

The following method takes a relative timeout as an argument.
It invokes IO.select several times and raises if the timeout is reached.

Assuming a user must specify a finite timeout, the following definition is possible.

# user_timeout is the required argument
def method(..., user_timeout)
  abs_timeout = now + user_timeout
  loop {
    IO.select(rs, ws, es, (abs_timeout - now).clamp(0..))
    raise "timeout" if abs_timeout < now
    ...
  }
end

Consider we need to make user_timeout optional.
If user_timeout is not given, no timeout occurs.
The implementation is as follows.
I think the following implementation is typical.
It needs 3 more conditionals than the above.

# user_timeout is an optional argument. nil means no timeout.
def method(..., user_timeout=nil)
  abs_timeout = user_timeout ? now + user_timeout : nil
  loop {
    IO.select(rs, ws, es, user_timeout ? (abs_timeout - now).clamp(0..) : nil)
    raise "timeout" if abs_timeout && (abs_timeout < now)
    ...
  }
end

It is possible to reduce a conditional if we use Float::INFINITY.
(abs_timeout && (abs_timeout < now) is changed to abs_timeout < now)

# user_timeout is an optional argument. nil means no timeout.
def method(..., user_timeout=nil)
  abs_timeout = user_timeout ? now + user_timeout : Float::INFINITY
  loop {
    IO.select(rs, ws, es, abs_timeout != Float::INFINITY ? (abs_timeout - now).clamp(0..) : nil)
    raise "timeout" if abs_timeout < now
    ...
  }
end

If IO.select accepts Float::INFINITY as a timeout argument (this proposal),
we can reduce one more conditional as follows.

# user_timeout is an optional argument. nil means no timeout.
def method(..., user_timeout=nil)
  abs_timeout = user_timeout ? now + user_timeout : Float::INFINITY
  loop {
    IO.select(rs, ws, es, (abs_timeout - now).clamp(0..))
    raise "timeout" if abs_timeout < now
    ...
  }
end

Example 2: minimum of timeouts

Sometimes we need to choose the minimum of several timeouts.
I think many event-driven programs use this strategy to determine the timeout for select function.

If "no timeout" is represented as nil, [t1, t2, t3, ...].compact.min is the minimum.

If "no timeout" is represented as Float::INFINITY, we can remove compact:
[t1, t2, t3, ...].min

However, Float::INFINITY must be converted to nil for IO.select.
This proposal removes this conversion.

Example 3: maximum of timeouts

Sometimes we need to choose the maximum of several timeouts.

We encountered this situation with a Happy Eyeballs implementation.
There are two timeouts for getaddrinfo and connect.
We need to wait the longer timeout because a timeout for one doesn't stop another.
Also, we don't ignore results after a timeout as long as the algorithm waits for something.

If "no timeout" is represented as nil, ts = [t1, t2, t3, ...]; ts.include?(nil) ? nil : ts.max is the maximum.

If "no timeout" is represented as Float::INFINITY, we can compute the maximum more easily: [t1, t2, t3, ...].max
It makes code simpler.

However, Float::INFINITY must be converted to nil for IO.select.
This proposal removes this conversion.

Several Consideration:

Consideration 1: Methods other than IO.select.

Several methods take a timeout.
An incomplete list of methods is as follows.
(I searched rb_time_interval.)

  • IO.select(rs, ws, es, timeout)
  • sleep(secs)
  • TCPSocket.new(connect_timeout:)
  • io.wait_readable(timeout)
  • io.wait_writable(timeout)
  • io.wait_priority(timeout)
  • mutex.sleep(timeout)

Do we want to modify them consistently to accept Float::INFINITY?

Consideration 2: C-level API

If we want to change the timeout of many methods, we would wish to new C-level API similar to rb_time_interval but can return NULL.

Unfortunately, rb_time_interval cannot return NULL because the return type is struct timeval.

Note that ext/io/wait/wait.c contains get_timeout function.
It seems a good first step for such API.

Consideration 3: IEEE 754 dependency.

Minor platforms (such as VAX) use non-IEEE 754 floating point numbers without infinity.
Note that NetBSD/vax still works. (And there is an emulator, simh).

Consideration 4: It seems no major languages accept infinity as select's timeout.

I found Perl, Python, and OCaml take a floating point number as a timeout of select function.
But they don't accept infinity.


Files

select-timeout-infinity.patch (1.73 KB) select-timeout-infinity.patch akr (Akira Tanaka), 07/07/2024 02:01 AM

Updated by akr (Akira Tanaka) 5 months ago

This is an experimental patch (IO.select only).

Updated by ioquatix (Samuel Williams) 5 months ago

I'm okay with this proposal but I think we should clearly implement:

If we want to change the timeout of many methods, we would wish to new C-level API similar to rb_time_interval but can return NULL.

I previously discussed some related ideas: https://bugs.ruby-lang.org/issues/19055#note-2 and https://bugs.ruby-lang.org/issues/18774#note-9

Having a standard interface for this is essential IMHO.

Please note that nil timeout can mean use the default timeout.

So, it's not the same as Float::INFINITY as currently proposed.

Updated by Eregon (Benoit Daloze) 5 months ago ยท Edited

Regarding the general timeout loop, I would write it like this:

# timeout is an optional argument. nil means no timeout.
def method(..., timeout: nil)
  if timeout
    now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
    deadline = now + timeout
    begin
      ios = IO.select(rs, ws, es, deadline - now)
      if ios
        # logic
        return ... 
      end

      now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
    end while now < deadline
    raise "timeout"
  else
    IO.select(rs, ws, es)
    # logic
  end
end

So I would split the no-timeout case, because it doesn't need to get the current time (which is not that cheap to get) and is so much simpler.
Also it avoids getting the current time multiple times in the loop, that makes the logic more complicated (e.g. that clamp).

If there is significant logic after the IO.select call, I would just rename method to helper and then method would be:

def method(..., timeout: nil)
  result = helper(..., timeout: nil)
  # logic
end

That way we don't need so many conditionals, without needing to accept Float::INFINITY as a "duration".


One other concern is floating-point math is generally much slower than integer math, and the rounding can be problematic.
So for such timeout loops like above I would typically use an integer number of nanoseconds, and only do one conversion Float seconds -> Integer nanoseconds, for such loops written in TruffleRuby.
But unfortunately that's only really possible if the method being called handles some kind of integer as Timeout, which is rare for Ruby methods (OTOH, most(/all?) libc/kernel functions use integer timeouts in varying units).
So I guess that's mostly out of scope/orthogonal to this issue.

Updated by Eregon (Benoit Daloze) 5 months ago

There is another issue with accepting Float::INFINITY, it means it needs to be checked explicitly in places where the timeout needs to be converted to a finite duration, to be treated as "no timeout", otherwise that conversation would raise as it does currently.
I think it's not good to use Float::INFINITY for this, better have a value like nil or some Symbol which is easier to check for.

Updated by akr (Akira Tanaka) 4 months ago

ioquatix (Samuel Williams) wrote in #note-2:

Please note that nil timeout can mean use the default timeout.

So, it's not the same as Float::INFINITY as currently proposed.

I see.
Regexp.new(timeout: nil) means Regexp.new(timeout: Regexp.timeout).

Thank you.

Such a use case should be supported if the new C-level API is introduced.

Actions

Also available in: Atom PDF

Like1
Like0Like0Like0Like0Like0