Skip to content

bryanp/goru

Repository files navigation

Concurrent routines for Ruby.

Goru is an experimental concurrency library for Ruby.

  • Lightweight: Goru routines are not backed by fibers or threads. Each routine creates only ~345 bytes of memory overhead.
  • Explicit: Goru requires you to describe exactly how a routine behaves. Less magic makes for fewer bugs when writing concurrent programs.

Goru was intended for low-level programs like http servers and not for direct use in user-facing code.

How It Works

Routines are defined with initial state and a block that does work and (optionally) updates the state of the routine:

3.times do
  Goru::Scheduler.go(:running) { |routine|
    case routine.state
    when :running
      routine.update(:sleeping)
      routine.sleep(rand)
    when :sleeping
      puts "[#{object_id}] woke up at #{Time.now.to_f}"
      routine.update(:running)
    end
  }
end

Routines run concurrently within a reactor, each reactor running in a dedicated thread. Each eligible routine is called once on every tick of the reactor it is scheduled to run in. In the example above, the three routines sleep for a random interval before waking up and printing the current time. Here is some example output:

[1840] woke up at 1677939216.379147
[1860] woke up at 1677939217.059535
[1920] woke up at 1677939217.190349
[1860] woke up at 1677939217.6196458
[1920] woke up at 1677939217.935916
[1840] woke up at 1677939218.033243
[1860] woke up at 1677939218.532908
[1920] woke up at 1677939218.8669178
[1840] woke up at 1677939219.379714
[1860] woke up at 1677939219.522777
[1920] woke up at 1677939220.0475688
[1840] woke up at 1677939220.253979

Each reactor can only run one routine at any given point in time, but if a routine blocks (e.g. by sleeping or performing i/o) the reactor calls another eligible routine before returning to the previously blocked routine on the next tick.

Scheduler

By default Goru routines are scheduled in a global scheduler that waits at the end of the program for all routines to finish. While this is useful for small scripts, most use-cases will involve creating your own scheduler and registering routines directly:

scheduler = Goru::Scheduler.new
scheduler.go { |routine|
  ...
}
scheduler.wait

Routines are scheduled to run immediately after registration.

Tuning

Schedulers default to running a number of reactors matching the number of processors on the current system. Tune this to your needs with the count option when creating a scheduler:

scheduler = Goru::Scheduler.new(count: 3)

State

Routines are initialized with default state that is useful for coordination between ticks. This is perhaps the oddest part of Goru but the explicitness can make it easier to understand exactly how your routines will behave.

Take a look at the examples to get some ideas.

Finishing

Routines will run forever until you say they are finished:

Goru::Scheduler.go { |routine|
  routine.finished
}

Results

When finishing a routine you can provide a final result:

routines = []
scheduler = Goru::Scheduler.new
routines << scheduler.go { |routine| routine.finished(true) }
routines << scheduler.go { |routine| routine.finished(false) }
routines << scheduler.go { |routine| routine.finished(true) }
scheduler.wait

pp routines.map(&:result)
# [true, false, true]

Error Handling

Unhandled errors within a routine cause the routine to enter an :errored state. Calling result on an errored routine causes the error to be re-raised. Routines can handle errors elegantly using the handle method:

Goru::Scheduler.go { |routine|
  routine.handle(StandardError) do |event:|
    # do something with `event`
  end

  ...
}

Sleeping

Goru implements a non-blocking version of sleep that makes the routine ineligible to be called until the sleep time has elapsed. It is important to note that Ruby's built-in sleep method will block the reactor and should not be used.

Goru::Scheduler.go { |routine|
  routine.sleep(3)
}

Unlike Kernel#sleep Goru's sleep method requires a duration.

Channels

Goru offers buffered reading and writing through channels:

channel = Goru::Channel.new

Goru::Scheduler.go(channel: channel, intent: :w) { |routine|
  routine << SecureRandom.hex
}

# This routine is not invoked unless the channel contains data for reading.
#
Goru::Scheduler.go(channel: channel, intent: :r) { |routine|
  value = routine.read
}

Channels are unbounded by default, meaning they can hold an unlimited amount of data. This behavior can be changed by initializing a channel with a specific size. Routines with the intent to write will not be invoked unless the channel has space available for writing.

channel = Goru::Channel.new(size: 3)

# This routine is not invoked if the channel is full.
#
Goru::Scheduler.go(channel: channel, intent: :w) { |routine|
  routine << SecureRandom.hex
}

IO

Goru includes a pattern for non-blocking io. With it you can implement non-blocking servers, clients, etc.

Routines that involve io must be created with an io object and an intent. Possible intents include:

  • :r for reading
  • :w for writing
  • :rw for reading and writing

Here is the beginning of an http server in Goru:

Goru::Scheduler.go(io: TCPServer.new("localhost", 4242), intent: :r) { |server_routine|
  next unless client = server_routine.accept

  Goru::Scheduler.go(io: client, intent: :r) { |client_routine|
    next unless data = client_routine.read(16384)

    # do something with `data`
  }
}

Changing Intents

Intents can be changed after a routine is created, e.g. to switch a routine from reading to writing:

Goru::Scheduler.go(io: io, intent: :r) { |routine|
  routine.intent = :w
}

Bridges

Goru supports coordinated buffered io using bridges:

writer = Goru::Channel.new

Goru::Scheduler.go(io: io, intent: :r) { |routine|
  case routine.intent
  when :r
    routine.bridge(intent: :w, channel: writer) { |bridge|
      bridge << SecureRandom.hex
    }
  when :w
    if (data = writer.read)
      routine.write(data)
    end
  end
}

Using bridges, the io routine is only called again when two conditions are met:

  1. The io object matches the bridged intent (e.g. it is writable).
  2. The channel is in the correct state to reciprocate the intent (e.g. it has data).

See the server example for a more complete use-case.

Credits

Goru was designed while writing a project in Go and imagining what Go-like concurrency might look like in Ruby.