Module: AsyncFutures::IOAsync

Defined in:
lib/async_futures/io_async.rb

Overview

This is a simple example mixin module to add async IO to the IO and OpenSSL::SSLSocket classes. You can also include this in other classes so long as they have the methods write_nonblock and read_nonblock and they behave the same way as IO and/or OpenSSL::SSLSocket.

All reads and writes are done on (at least) one background worker thread.

This is not the most efficient implementation. It is just meant to be an example of how one can use the Future class outside of an Executor implementation.

It's only real efficiencies, so to speak, are that it doesn't use a thread per future (which would potentially use up a lot of memory), nor do futures need to wait until a thread opens up on a worker pool (which would block newer IO work until older IO work completely finished). Instead work is picked up immediately on at least one background worker thread, and attempts to read/write are started immediately via read_nonblock and write_nonblock. If read_nonblock/write_nonblock cannot proceed (because they would block) for any particular IO object then the next operation for the next IO object is attempted, and so forth, until all the work is completed (or a timeout happens).

However, all of this is accomplished via a simplistic, unoptimized busy loop. This is less than ideal. There are some simple sleeps and timeouts added to avoid completely eating up the CPU, but this is still a very naive approach.

A better implementation would utilize higher performance OS specific features like FreeBSD's kqueue/aio or Linux's epoll/io_uring. However, the logic for integrating these is beyond the scope of this example code.

This could probably be done using the FFI library fiddle, which is bundled with ruby, so it wouldn't need to reach outside the standard library. However I have a very good reason for not doing that right now: I don't want to.

Instance Method Summary collapse

Instance Method Details

#read_async(maxlen, timeout = nil, sleep_timeout: 0.001) ⇒ Object

Return an incomplete future that will eventually contain the string value read from the IO object or an exception if the IO object could not be read from for some reason.

A string up to maxlen in length is read in a nonblocking fashion on a background worker thread.

The optional timeout argument causes the work to finish the future exceptionally with Timeout::Error if it takes longer than timeout seconds to complete. This is used to avoid having background work that spins forever on IO that may never complete. If nil or no value is given, this means no timeout (i.e. potentially spin indefinitely).

This should not be confused with the Timeout::Error raised via the timeout argument on Future.result and Future.exception. If it matters for your purposes to differentiate between the two, you can do something like the following:

# `join` returns `nil` on timeout
result = if future.join(1.0)
           # If `Timeout::Error` is raised here,
           # it is from the `timeout` parameter to the `*_async` method.
           future.result
         else
           raise Timeout::Error.new('Timed out on `join`')
         end

The optional sleep_timeout keyword argument is used to determine how quickly the worker thread stops polling the input work queue and how much sleep time happens between failed nonblocking IO attempts. It defaults to 1ms. If existing worker(s) have already been spawned, then this argument isn't used.

If the process shuts down before the future can be fully completed, the work may be abandoned even if it partially completed.



172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
# File 'lib/async_futures/io_async.rb', line 172

def read_async(maxlen, timeout = nil, sleep_timeout: 0.001) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity
  Future.new.tap do |ftr|
    clock_timeout = timeout && (Time.now.to_f + timeout)

    work_proc = proc do
      # :nocov:
      break ftr unless ftr.set_running_or_notify_cancel(set_context: true)
      # :nocov:

      to_err = Timeout::Error.new('execution expired')
      to_read_length = maxlen
      retrieved_str = String.new

      loop do
        cur_to = timeout && (Time.now.to_f - clock_timeout)
        break ftr.tap { ftr.set_exception(to_err) } unless timeout.nil? || cur_to.positive?

        retrieved_str << read_nonblock(to_read_length)
        to_read_length = maxlen - retrieved_str.size
        break ftr.tap { ftr.set_result(retrieved_str) } if to_read_length.zero?
      rescue IO::WaitReadable, IO::WaitWritable, Errno::EINTR
        Fiber.yield nil
        retry
      rescue EOFError
        break ftr.tap { ftr.set_result(retrieved_str) }
      rescue Exception => e # rubocop:disable Lint/RescueException
        break ftr.tap { ftr.set_exception(e) }
      end
    end

    io_async_queue.push(work_proc)
    maybe_spawn_worker(sleep_timeout)
  end
end

#write_async(string, timeout = nil, sleep_timeout: 0.001) ⇒ Object

Return an incomplete future that will eventually contain an integer with the number of bytes written or an exception if the string could not be written for some reason.

The string argument is written in a nonblocking fashion on a background worker thread.

The optional timeout argument causes the work to finish the future exceptionally with Timeout::Error if it takes longer than timeout seconds to complete. This is used to avoid having background work that spins forever on IO that may never complete. If nil or no value is given, this means no timeout (i.e. potentially spin indefinitely).

This should not be confused with the Timeout::Error raised via the timeout argument on Future.result and Future.exception. If it matters for your purposes to differentiate between the two, you can do something like the following:

# `join` returns `nil` on timeout
result = if future.join(1.0)
           # If `Timeout::Error` is raised here,
           # it is from the `timeout` parameter to the `*_async` method.
           future.result
         else
           raise Timeout::Error.new('Timed out on `join`')
         end

The optional sleep_timeout keyword argument is used to determine how quickly the worker thread stops polling the input work queue and how much sleep time happens between failed nonblocking IO attempts. It defaults to 1ms. If existing worker(s) have already been spawned, then this argument isn't used.

If the process shuts down before the future can be fully completed, the work may be abandoned even if it partially completed.



98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
# File 'lib/async_futures/io_async.rb', line 98

def write_async(string, timeout = nil, sleep_timeout: 0.001) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity
  Future.new.tap do |ftr|
    clock_timeout = timeout && (Time.now.to_f + timeout)

    work_proc = proc do
      # :nocov:
      break ftr unless ftr.set_running_or_notify_cancel(set_context: true)
      # :nocov:

      to_err = Timeout::Error.new('execution expired')
      all_written = 0

      loop do
        cur_to = timeout && (Time.now.to_f - clock_timeout)
        break ftr.tap { ftr.set_exception(to_err) } unless timeout.nil? || cur_to.positive?

        bytes_written = write_nonblock(string)
        string = string[bytes_written..nil]
        all_written += bytes_written
        break ftr.tap { ftr.set_result(all_written) } if string.empty?
      rescue IO::WaitReadable, IO::WaitWritable, Errno::EINTR
        Fiber.yield nil
        retry
      rescue Exception => e # rubocop:disable Lint/RescueException
        break ftr.tap { ftr.set_exception(e) }
      end
    end

    io_async_queue.push(work_proc)
    maybe_spawn_worker(sleep_timeout)
  end
end