-
Notifications
You must be signed in to change notification settings - Fork 338
Ractors
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.
- 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).
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.
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.
- 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 (custom typedefs must be defined for each Ractor)
- use non-frozen
FFI::Library
based modules
- create new