Skip to content
Lars Kanis edited this page Sep 29, 2023 · 6 revisions

Ractor - Ruby’s Actor-like concurrent abstraction

Ractor is designed to provide a parallel execution feature of Ruby without thread-safety concerns. The Ractor support in FFI allows libraries and applications to use native / C code in a Ractor context. Usually only little effort is necessary to enable this feature for a given library.

Requirements

  • Ractor support was introduced in ffi-1.16.0.
  • It requires CRuby version 3.1 and up.
  • Ractor support of ruby-3.0 is not sufficient for ffi and will raise Ractor::IsolationError.
  • JRuby and Truffleruby don't provide Ractor support at all (in 2023).

Usage

module Foo
  extend FFI::Library
  ffi_lib FFI::Library::LIBC
  attach_function("cputs", "puts", [ :string ], :int)
  freeze # Freeze the module variables, so that it can be shared across ractors.
end
Ractor.new do
  Foo.cputs("Hello, World via libc puts using FFI in a Ractor")
end.take

Calling freeze after all function, callback, typedef, struct and enum definitions is important to make the module usable in non-main Ractors. It might be possible to use the module to some extend in non-main Ractors without freezing it, but that use case is not supported and might not be stable across ffi versions.

More complex usage

A more complex example of using FFI in Ractor is using C qsort function. It creates a memory block with space for 3 values of type int32 and fills this memory. Then it sorts the values by using a ruby block and prints the sorted values afterwards.

module LibC
  extend FFI::Library
  ffi_lib FFI::Library::LIBC
  callback :qsort_cmp, [ :pointer, :pointer ], :int
  attach_function :qsort, [ :pointer, :ulong, :ulong, :qsort_cmp ], :int

  freeze # Freeze the module variables, so that it can be shared across ractors.
end

p = FFI::MemoryPointer.new(:int, 3)
p.put_array_of_int32(0, [ 2, 3, 1 ])   # Write some unsorted data into the memory
# Ractor.make_shareable(p)             # freeze the pointer to be shared between ractors instead of copied
puts "main  -ptr=#{p.inspect}"
res = Ractor.new(p) do |p|
  puts "ractor-ptr=#{p.inspect}"
  puts "Before qsort #{p.get_array_of_int32(0, 3).join(', ')}"
  LibC.qsort(p, 3, 4) do |p1, p2|
    i1 = p1.get_int32(0)
    i2 = p2.get_int32(0)
    puts "In block: comparing #{i1} and #{i2}"
    i1 < i2 ? -1 : i1 > i2 ? 1 : 0
  end
  puts "After qsort #{p.get_array_of_int32(0, 3).join(', ')}"
end.take

puts "After ractor termination #{p.get_array_of_int32(0, 3).join(', ')}"

The program output looks like so:

main  -ptr=#<FFI::MemoryPointer address=0x0000560e32c26c00 size=12>
ractor-ptr=#<FFI::MemoryPointer address=0x0000560e32c29c20 size=12>
Before qsort 2, 3, 1
In block: comparing 3 and 1
In block: comparing 2 and 1
In block: comparing 2 and 3
After qsort 1, 2, 3
After ractor termination 2, 3, 1

You see the pointer is different between the main-Ractor and the new Ractor. This is because passing a non-frozen FFI::Pointer to a Ractor creates a copy of that memory. The memory outside of the Ractor is still unchanged as show in the last output line.

If you uncomment the line with make_shareable and freeze the FFI::pointer that way, the output changes to:

main  -ptr=#<FFI::MemoryPointer address=0x000055804f0674d0 size=12>
ractor-ptr=#<FFI::MemoryPointer address=0x000055804f0674d0 size=12>
Before qsort 2, 3, 1
In block: comparing 3 and 1
In block: comparing 2 and 1
In block: comparing 2 and 3
After qsort 1, 2, 3
After ractor termination 1, 2, 3

When freezing the FFI::Pointer the memory behind it can no longer be written by Ruby methods. It is read-only now and throws a invalid memory write at address=0xxx error when tried to write. A frozen pointer is directly shared between ractors and no copy is made.

In the new Ractor the memory values are sorted by qsort using the ruby callback like in the output above.

Since the memory is not copied between Ractors, also the outer memory is sorted by the new Ractor. This is a violation of the principle of isolation of Ractor, but shows that the isolation of Ractor is not enforced to low level C libraries, but only to ruby objects. A frozen/write protected memory pointer should not be written by a C function. Such use cases should be avoided as it can lead to side effects that Ractor is made to prevent. Usually it's better to create, read and write memory from a single Ractor only and transfer ruby objects between Ractors.

Features with regard to Ractors

  • In a Ractor it's possible to:
    • load DLLs and call its functions, access its global variables
    • use builtin typedefs
    • use and modify ractor local typedefs
    • define callbacks
    • receive async callbacks from non-ruby threads
    • use frozen FFI::Library based modules with all attributes (enums, structs, typedefs, functions, callbacks)
    • invoke frozen functions and callbacks defined in the main Ractor
    • use FFI::Struct definitions from the main Ractor
  • In a Ractor it's impossible to:
    • create new FFI::Library based modules
    • create new FFI::Struct definitions
    • use custom global typedefs
    • use non-frozen FFI::Library based modules
Clone this wiki locally