ShadowLink

ShadowLink is a Ruby gem that enables the exchange of objects which typically cannot be shared between Ractors by emulating them via proxies and converting them into a shareable data format.

Installation

Install the gem and add to the application's Gemfile by executing:

bundle add shadowlink

If bundler is not being used to manage dependencies, install the gem by executing:

gem install shadowlink

Usage

Objects such as ActiveRecord typically cannot be used within a Ractor.

Ractor.new(Monster.all) do |monsters|
  monsters.first.id # An error occurs.
end

Passing an object that cannot be shared to ShadowLink converts it into a shareable proxy object.

ShadowLink.lurk(Monster.all) do |shadow_monsters|
  Ractor.new(shadow_monsters) do |monsters|
    monsters.first.id
  end.value
end

You can pass multiple objects.

ShadowLink.lurk(Fairy.new, [Octorok.new, Keese.new]) do |shadow_fairy, shadow_monsters|
  Ractor.new(shadow_fairy, shadow_monsters) do |fairy, monsters|
    # anything
  end.value
end

The last argument of the ShadowLink.lurk block is a Shadow object. By using the Shadow object's #sink method, you can shadow an object even after ShadowLink.lurk has executed.

ShadowLink.lurk(Fairy.new) do |shadow_fairy, shadow|
  shadow_octorok = shadow.sink(Octorok.new)
  Ractor.new(shadow_fairy, shadow_octorok, shadow.sink(Keese.all)) do |fairy, octorok, keese|
    # anything
  end.value
end

Exiting ShadowLink.lurk before the processing inside the Ractor completes causes the port to close, resulting in an error. Be sure to wait for the operation to complete using Ractor#join or Ractor#value.

ShadowLink.lurk(Fairy.new) do |shadow_fairy|
  Ractor.new(shadow_fairy) do |fairy|
    sleep 1
    fairy.id # raise 'Ractor::Port#send': The port was already closed (Ractor::ClosedError)
  end # non wait
end

If the result of the ShadowLink.lurk block is a shadowed object, the result of ShadowLink.lurk is converted to the original object and returned.

ShadowLink.lurk(Fairy.new) do |shadow_fairy|
  Ractor.new(shadow_fairy) do |fairy|
    fairy # ShadowLink<Fairy> Object
  end.value # ShadowLink<Fairy> Object
end # Fairy Object

You can also retrieve the original object by using #seek.

ShadowLink.lurk(Fairy.new) do |shadow_fairy, shadow|
  result_fairy = Ractor.new(shadow_fairy) do |fairy|
    fairy # ShadowLink<Fairy> Object
  end.value # ShadowLink<Fairy> Object
  shadow.seek(result_fairy) # Fairy Object
end

When using ShadowLink, an error occurring at the source becomes a Ractor::RuntimeError within ShadowLink.lurk, but upon exiting ShadowLink.lurk, it is converted back into the original error and raised as an exception.

begin
  ShadowLink.lurk(-> { raise 'Gameover' }) do |shadow_process|
    Ractor.new(shadow_process) do |process|
      process.call
    end.value
  rescue
    raise # raise Ractor::RuntimeError
  end
rescue
  raise # raise Gameover (RuntimeError)
end

Typically, the main process runs on a single thread, but you can increase the number of processing threads by specifying the thread count via a keyword argument. If you run multiple Ractors within ShadowLink.lurk and share tasks that involve significant I/O waiting, increasing the number of threads should improve processing efficiency.

ShadowLink.lurk(Fairy.new, thread: 5) do |shadow_fairy|
  5.times.map do
    Ractor.new(shadow_fairy) do |fairy|
      # anything
    end
  end.each(:join)
end

ShadowLink does not convert shareable objects; it passes the data through as-is. Objects that cannot be shared directly are shared via proxy objects. As an exception, String objects are by default converted into shareable objects via Ractor.make_shareable. If there are objects other than Strings that you wish to make shareable using Ractor.make_shareable, you can specify them using keyword arguments.

ShadowLink.lurk(any, make_shareables: [String, Array, Hash]) do |shadow_any|
  Ractor.new(shadow_any) do |obj|
    obj.to_s # Frozen original String object
    obj.to_a # Frozen original Array object
    obj.to_h # Frozen original Hash object
  end.value
end

If you do not want a String to be converted into a shareable object, you can prevent the conversion by specifying an array that does not contain the String. I wouldn't recommend it, as it causes various things to stop working.

string = 'zelda'.dup
ShadowLink.lurk(string, make_shareables: []) do |shadow_string|
  Ractor.new(shadow_string) do |str|
    str.gsub!('zelda', 'sheik')
    # However, you cannot do `puts str`
    puts str == 'sheik' # false
  end.join
end
puts string == 'sheik' # true

Performance

Performance verification is conducted in the following environment.

% system_profiler SPHardwareDataType
Hardware:

    Hardware Overview:

      Model Name: MacBook Pro
      Model Identifier: Mac16,8
      Model Number: Z1FE000FHJ/A
      Chip: Apple M4 Pro
      Total Number of Cores: 12 (8 Performance and 4 Efficiency)
      Memory: 48 GB
      System Firmware Version: 18000.120.36
      OS Loader Version: 18000.120.36

% ruby -v 
ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +PRISM [arm64-darwin25]

For method calls without arguments, execution is possible with the following performance difference.

require 'benchmark'

Benchmark.bm do |x|
  array = (1..10000).to_a
  x.report('non ractor') do
    array.each { array.sum }
  end
  x.report('use frozen object') do
    frozen_array = array.dup.freeze
    array.each do 
      Ractor.new(frozen_array) { |arr| arr.sum }.join
    end
  end
  x.report('use shadowlink') do
    ShadowLink.lurk(array) do |shadow_array|
      array.each do 
        Ractor.new(shadow_array) { |arr| arr.sum }.join
      end
    end
  end
end
                       user     system      total        real
non ractor         0.084500   0.000373   0.084873 (  0.085017)
use frozen object  0.131565   0.071487   0.203052 (  0.203015)
use shadowlink     0.224832   0.189211   0.414043 (  0.379295)

Methods that accept only shareable objects as arguments can be called with the following performance differences.

require 'benchmark'

Benchmark.bm do |x|
  array = (1..10000).to_a
  x.report('non ractor') do
    array.each { array.sum(0) }
  end
  x.report('use frozen object') do
    frozen_array = array.dup.freeze
    array.each do 
      Ractor.new(frozen_array) { |arr| arr.sum(0) }.join
    end
  end
  x.report('use shadowlink') do
    ShadowLink.lurk(array) do |shadow_array|
      array.each do 
        Ractor.new(shadow_array) { |arr| arr.sum(0) }.join
      end
    end
  end
end
                       user     system      total        real
non ractor         0.082363   0.000367   0.082730 (  0.082839)
use frozen object  0.128988   0.073280   0.202268 (  0.202182)
use shadowlink     0.229528   0.192023   0.421551 (  0.385374)

Methods containing objects that cannot be shared can be called with the following performance differences.

require 'benchmark'

Benchmark.bm do |x|
  array = (1..100).to_a
  x.report('non ractor') do
    array.each { array.sum { |i| i * 2 } }
  end
  x.report('use frozen object') do
    frozen_array = array.dup.freeze
    array.each do 
      Ractor.new(frozen_array) { |arr| arr.sum { |i| i * 2 } }.join
    end
  end
  x.report('use shadowlink') do
    ShadowLink.lurk(array) do |shadow_array|
      array.each do 
        Ractor.new(shadow_array) { |arr| arr.sum { |i| i * 2 } }.join
      end
    end
  end
end
                       user     system      total        real
non ractor         0.000415   0.000014   0.000429 (  0.000426)
use frozen object  0.002826   0.002211   0.005037 (  0.004948)
use shadowlink     0.079253   0.076243   0.155496 (  0.149635)

In practice, parallelization can reduce processing time. For example, if you need to read characters 1,000 times with each read taking 0.001 seconds processing them in 10 parallel threads can reduce the execution time to nearly one-tenth of the original.

require 'benchmark'

Benchmark.bm do |x|
  process = -> { sleep 0.001; 'result' }
  x.report('non ractor') do
    1000.times.each { process.call }
  end
  x.report('use thread') do
    10.times.map do
      Thread.new do
        100.times.each { process.call }
      end
    end.each(&:join)
  end
  x.report('use shadowlink') do
    ShadowLink.lurk(process, threads: 10) do |shadow_process|
      10.times.map do
        Ractor.new(shadow_process) do |p|
          100.times.map { p.call }
        end
      end.each(&:join)
    end
  end
end
                    user     system      total        real
non ractor      0.003898   0.010412   0.014310 (  1.269621)
use thread      0.003713   0.013605   0.017318 (  0.127920)
use shadowlink  0.026961   0.033084   0.060045 (  0.140258)

In this example, threads are faster; however, the difference lies in the ability to process conditional branching and basic arithmetic operations in parallel. In cases where calculations are performed simultaneously with data input and output, extremely high-speed data processing becomes possible.

Development

After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and the created tag, and push the .gem file to rubygems.org.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/ucks/shadowlink-rb.