ractor-sharing 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Ractor
4
+ class ActiveObject
5
+ # Result handle returned by +future+ invocations.
6
+ #
7
+ # Only the Ractor that issued the invocation may call #value / #wait,
8
+ # because the underlying reply port belongs to that Ractor.
9
+ class Future
10
+ def initialize(port)
11
+ @port = port
12
+ @mutex = Mutex.new
13
+ @state = :pending # :pending | :fulfilled | :rejected
14
+ @result = nil
15
+ end
16
+
17
+ # Pre-resolved futures, used when the caller is already the owner.
18
+ def self.__ao_fulfilled__(value) = new(nil).tap { |f| f.__ao_settle__(:fulfilled, value) }
19
+ def self.__ao_rejected__(exc) = new(nil).tap { |f| f.__ao_settle__(:rejected, exc) }
20
+
21
+ # Waits for completion. Returns self; never raises for a rejected future.
22
+ def wait
23
+ @mutex.synchronize do
24
+ if @state == :pending
25
+ status, payload, backtrace = @port.receive
26
+ @port = nil
27
+ if status == :ok
28
+ __ao_settle__(:fulfilled, payload)
29
+ else
30
+ __ao_settle__(:rejected, ActiveObject.__ao_restore_exception__(payload, backtrace))
31
+ end
32
+ end
33
+ end
34
+ self
35
+ end
36
+
37
+ # Waits for completion and returns the method's return value, or
38
+ # re-raises the exception the method raised on the owner Ractor.
39
+ def value
40
+ wait
41
+ raise @result if @state == :rejected
42
+ @result
43
+ end
44
+
45
+ # True once #wait / #value has observed the result. Does not poll.
46
+ def resolved? = @state != :pending
47
+
48
+ def rejected? = @state == :rejected
49
+
50
+ def __ao_settle__(state, result)
51
+ @state = state
52
+ @result = result
53
+ end
54
+
55
+ def inspect = "#<#{self.class} #{@state}>"
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,311 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "active_object/future"
4
+
5
+ class Ractor
6
+ # Active Object pattern on Ractors: each instance's state lives in its own
7
+ # owner Ractor. `Foo.new` returns a proxy that publishes only the methods
8
+ # declared with sync/async/future; they are executed on the owner one at a
9
+ # time. See README.md.
10
+ class ActiveObject
11
+ class Error < ::Ractor::Error; end
12
+
13
+ POLICIES = %i[sync async future].freeze
14
+
15
+ DEF_NAME = /\A(?:[[:alpha:]_][[:alnum:]_]*[?!=]?|[+\-*\/%&|^<>~!`]|\*\*|[=!]=|<=|>=|<=>|===|=~|!~|<<|>>|\[\]=?|[+\-]@)\z/
16
+ private_constant :DEF_NAME
17
+
18
+ # Shared by proxies and servants: explicit *_send and the remote protocol.
19
+ module Dispatch
20
+ # The Ractor that owns the object's state.
21
+ def owner = @__ao_owner__
22
+
23
+ # True when called from the owner Ractor.
24
+ def owner? = Ractor.current.equal?(@__ao_owner__)
25
+
26
+ def sync_send(name, *args, **kwargs, &block) = __ao_call__(:sync, name.to_sym, args, kwargs, block)
27
+ def async_send(name, *args, **kwargs, &block) = __ao_call__(:async, name.to_sym, args, kwargs, block)
28
+ def future_send(name, *args, **kwargs, &block) = __ao_call__(:future, name.to_sym, args, kwargs, block)
29
+
30
+ private
31
+
32
+ def __ao_call__(policy, name, args, kwargs, block)
33
+ return __ao_remote__(policy, name, args, kwargs, block) unless Ractor.current.equal?(@__ao_owner__)
34
+
35
+ servant = Ractor[:__ao_servant__]
36
+ case policy
37
+ when :sync then servant.__send__(name, *args, **kwargs, &block)
38
+ when :async then servant.__send__(name, *args, **kwargs, &block); nil
39
+ when :future
40
+ begin
41
+ Future.__ao_fulfilled__(servant.__send__(name, *args, **kwargs, &block))
42
+ rescue Exception => e
43
+ Future.__ao_rejected__(e)
44
+ end
45
+ end
46
+ end
47
+
48
+ def __ao_remote__(policy, name, args, kwargs, block)
49
+ port = @__ao_port__
50
+ raise Error, "#{self.class} instance was not created by .new (no owner Ractor)" unless port
51
+ raise ArgumentError, "block is not supported for remote invocation (#{name})" if block
52
+
53
+ case policy
54
+ when :sync
55
+ status, payload, backtrace = ActiveObject.__ao_sync_call__ do |reply|
56
+ __ao_post__(port, [:sync, reply, name, args, kwargs])
57
+ end
58
+ raise ActiveObject.__ao_restore_exception__(payload, backtrace) if status == :raise
59
+ payload
60
+ when :async
61
+ __ao_post__(port, [:async, nil, name, args, kwargs])
62
+ nil
63
+ when :future
64
+ reply = Ractor::Port.new
65
+ __ao_post__(port, [:future, reply, name, args, kwargs])
66
+ Future.new(reply)
67
+ else
68
+ raise ArgumentError, "unknown invocation policy: #{policy.inspect}"
69
+ end
70
+ end
71
+
72
+ def __ao_post__(port, request)
73
+ port << request
74
+ rescue Ractor::ClosedError
75
+ raise Error, "owner Ractor #{@__ao_owner__.inspect} terminated"
76
+ end
77
+ end
78
+
79
+ # Base of the per-class proxy classes (Foo::Proxy). A proxy is frozen and
80
+ # shareable and has one method per declared (published) method of Foo.
81
+ class Proxy
82
+ include Dispatch
83
+
84
+ def initialize(owner, port)
85
+ @__ao_owner__ = owner
86
+ @__ao_port__ = port
87
+ end
88
+
89
+ # The Ractor::ActiveObject subclass this proxy stands for.
90
+ def active_object_class = self.class.active_object_class
91
+
92
+ def inspect = "#<#{self.class} owner=#{@__ao_owner__.inspect}>"
93
+
94
+ class << self
95
+ attr_reader :active_object_class
96
+
97
+ def __ao_bind__(klass)
98
+ @active_object_class = klass
99
+ klass.const_set(:Proxy, self) # named Foo::Proxy
100
+ self
101
+ end
102
+ end
103
+ end
104
+
105
+ include Dispatch
106
+
107
+ @__ao_policies__ = {}.freeze
108
+ @__ao_proxy_class__ = Proxy
109
+
110
+ class << self
111
+ # Creates the owner Ractor, runs +initialize+ there and returns a
112
+ # shareable proxy which forwards declared methods to the owner.
113
+ def new(*args, **kwargs, &block)
114
+ raise ArgumentError, "#{self}.new does not accept a block (it cannot be sent to the owner Ractor)" if block
115
+
116
+ boot = Ractor::Port.new
117
+ owner = Ractor.new(self, args, kwargs, boot, name: "ActiveObject(#{self})") do |klass, a, kw, b|
118
+ Ractor::ActiveObject.__ao_run__(klass, a, kw, b)
119
+ end
120
+ # Only here: the owner may die before it can answer (e.g. .allocate fails).
121
+ src, (status, payload, backtrace) = Ractor.select(boot, owner)
122
+ raise Error, "owner Ractor #{owner.inspect} terminated during initialization" if src.equal?(owner)
123
+ raise __ao_restore_exception__(payload, backtrace) if status == :raise
124
+
125
+ Ractor.make_shareable(proxy_class.new(owner, payload))
126
+ end
127
+
128
+ # Class of the proxies returned by .new (a subclass of ActiveObject::Proxy).
129
+ def proxy_class = @__ao_proxy_class__
130
+
131
+ # --- policy DSL -------------------------------------------------------
132
+
133
+ # Publish already defined methods on the proxy with the given policy:
134
+ # sync def foo ... end
135
+ # async :bar, :baz
136
+ def sync(*names) = __ao_declare__(:sync, names)
137
+ def async(*names) = __ao_declare__(:async, names)
138
+ def future(*names) = __ao_declare__(:future, names)
139
+
140
+ # Policy of +name+ (:sync, :async or :future), or nil if not published.
141
+ def invocation_policy(name)
142
+ name = name.to_sym
143
+ klass = self
144
+ while klass
145
+ table = klass.instance_variable_get(:@__ao_policies__)
146
+ return table[name] if table&.key?(name)
147
+ klass = klass.superclass
148
+ end
149
+ nil
150
+ end
151
+
152
+ def inherited(subclass)
153
+ super
154
+ subclass.instance_variable_set(:@__ao_policies__, {}.freeze)
155
+ subclass.instance_variable_set(:@__ao_proxy_class__, Class.new(proxy_class).__ao_bind__(subclass))
156
+ end
157
+
158
+ # --- internals --------------------------------------------------------
159
+
160
+ def __ao_declare__(policy, names)
161
+ raise ArgumentError, "#{policy} requires method name(s), e.g. `#{policy} def foo; end`" if names.empty?
162
+ raise TypeError, "#{self} is not a subclass of Ractor::ActiveObject" if equal?(Ractor::ActiveObject)
163
+
164
+ names = names.flatten.map(&:to_sym)
165
+ names.each do |n|
166
+ unless method_defined?(n) || private_method_defined?(n)
167
+ raise NameError.new("undefined method '#{n}' for class '#{self}'", n)
168
+ end
169
+ # The table is read from any Ractor, so replace it by a shareable copy.
170
+ @__ao_policies__ = Ractor.make_shareable(@__ao_policies__.merge(n => policy))
171
+ __ao_define_proxy_method__(n, policy)
172
+ end
173
+ names.size == 1 ? names[0] : names
174
+ end
175
+ private :__ao_declare__
176
+
177
+ def __ao_define_proxy_method__(name, policy)
178
+ proxy = proxy_class
179
+ sym = name.inspect
180
+ # A string def has no closure, so the method is callable from any Ractor.
181
+ body = <<~RUBY
182
+ if Ractor.current.equal?(@__ao_owner__)
183
+ Ractor[:__ao_servant__].__send__(#{sym}, *args, **kwargs, &block)
184
+ else
185
+ __ao_remote__(#{policy.inspect}, #{sym}, args, kwargs, block)
186
+ end
187
+ RUBY
188
+ proxy.remove_method(name) if proxy.method_defined?(name, false)
189
+ if name.match?(DEF_NAME)
190
+ proxy.class_eval("def #{name}(*args, **kwargs, &block)\n#{body}end", __FILE__, __LINE__)
191
+ else
192
+ proxy.class_eval("define_method(#{sym}, &Ractor.shareable_proc { |*args, **kwargs, &block|\n#{body}})", __FILE__, __LINE__)
193
+ end
194
+ end
195
+ private :__ao_define_proxy_method__
196
+
197
+ # Owner Ractor body: build the servant, run initialize, then serve requests.
198
+ def __ao_run__(klass, args, kwargs, boot)
199
+ port = Ractor::Port.new
200
+ servant = klass.allocate
201
+ servant.instance_variable_set(:@__ao_owner__, Ractor.current)
202
+ servant.instance_variable_set(:@__ao_port__, port)
203
+ Ractor[:__ao_servant__] = servant
204
+
205
+ begin
206
+ servant.__send__(:initialize, *args, **kwargs)
207
+ rescue Exception => e
208
+ __ao_reply__(boot, :raise, e)
209
+ return
210
+ end
211
+ boot << [:ok, port]
212
+
213
+ proxy = klass.proxy_class.new(Ractor.current, port).freeze
214
+ pending = pending_name = nil
215
+ begin
216
+ loop do
217
+ kind, reply, name, rargs, rkwargs = port.receive
218
+ pending, pending_name = (kind == :async ? nil : reply), name
219
+ begin
220
+ result = servant.__send__(name, *rargs, **rkwargs)
221
+ rescue Exception => e
222
+ if kind == :async
223
+ pending = nil
224
+ __ao_async_exception__(servant, e, name)
225
+ else
226
+ # Cleared only once the reply is out: killed in between, the
227
+ # ensure below would find nothing pending and answer nobody.
228
+ __ao_reply__(reply, :raise, e)
229
+ pending = nil
230
+ end
231
+ next
232
+ end
233
+ if kind == :async
234
+ pending = nil
235
+ next
236
+ end
237
+ result = proxy if result.equal?(servant) # returning self yields the proxy, not a copy
238
+ __ao_reply__(reply, :ok, result)
239
+ pending = nil
240
+ end
241
+ ensure
242
+ # Going down (e.g. the method killed this thread) with a request in
243
+ # flight: its caller waits on the reply port only, so answer it.
244
+ if pending
245
+ __ao_reply__(pending, :raise, Error.new("owner Ractor terminated during #{klass}##{pending_name}"))
246
+ end
247
+ end
248
+ end
249
+
250
+ def __ao_async_exception__(servant, exc, name)
251
+ servant.on_async_exception(exc, name)
252
+ rescue Exception => e
253
+ warn "#{servant.class}#on_async_exception raised #{e.class}: #{e.message}"
254
+ end
255
+ private :__ao_async_exception__
256
+
257
+ # Send a result/exception back; values that cannot cross Ractors are
258
+ # converted into an Error so the caller never hangs.
259
+ def __ao_reply__(port, status, payload)
260
+ if status == :raise
261
+ port << [:raise, payload, payload.backtrace]
262
+ else
263
+ port << [:ok, payload]
264
+ end
265
+ rescue Ractor::ClosedError
266
+ nil # caller gave up; nothing to do
267
+ rescue StandardError => e
268
+ msg = if status == :raise
269
+ "#{payload.class}: #{payload.message} (exception could not be transferred: #{e.message})"
270
+ else
271
+ "return value could not be transferred: #{e.message}"
272
+ end
273
+ port << [:raise, Error.new(msg), (payload.backtrace if status == :raise)]
274
+ end
275
+ private :__ao_reply__
276
+
277
+ # Round trip on a reply port taken from a Ractor-local pool; the block
278
+ # sends the request. Waiting on the reply port alone (rather than also
279
+ # watching the owner Ractor) keeps the call cheap; a dying owner answers
280
+ # its in-flight request from __ao_run__, and later sends fail with
281
+ # ClosedError. A port is private to the caller while in use, so
282
+ # concurrent threads never mix replies; one whose wait was interrupted
283
+ # may still get a reply later, so it is not returned to the pool.
284
+ def __ao_sync_call__
285
+ pool = Ractor.store_if_absent(:__ao_reply_ports__) { [] }
286
+ reply = pool.pop || Ractor::Port.new # Array#pop/push are atomic under the GVL
287
+ yield reply
288
+ msg = reply.receive
289
+ pool.push(reply)
290
+ msg
291
+ end
292
+
293
+ # Backtraces are lost when exceptions cross Ractors; rebuild: owner frames, then caller frames.
294
+ def __ao_restore_exception__(exc, backtrace)
295
+ exc = exc.dup if exc.frozen? # a frozen shareable exception crosses by reference
296
+ exc.set_backtrace(Array(backtrace) + caller(2))
297
+ exc
298
+ rescue FrozenError
299
+ exc # un-dupable: better without the backtrace than masked
300
+ end
301
+ end
302
+
303
+ # Called on the owner Ractor when an +async+ invocation raises.
304
+ # Override to supervise; the default only reports it.
305
+ def on_async_exception(exception, method_name)
306
+ lines = ["#{self.class}##{method_name} (async) raised #{exception.class}: #{exception.message}"]
307
+ lines.concat(Array(exception.backtrace).map { |l| "\tfrom #{l}" })
308
+ warn lines.join("\n")
309
+ end
310
+ end
311
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "active_object"
4
+
5
+ class Ractor
6
+ # A Hash kept by a Ractor of its own. Reading it is a question you ask; every
7
+ # change is work you send, and the work runs where the Hash is.
8
+ #
9
+ # h = Ractor::ActorHash.new
10
+ # h.increment(:hits) # send it and carry on
11
+ # h[:hits] # ask
12
+ # h.async_call {|h| (h[:log] ||= []) << line }
13
+ #
14
+ # Because the entries never leave that Ractor except as copies, they do not
15
+ # have to be shareable the way Ractor::LockHash's do: a value can be an Array
16
+ # you go on appending to.
17
+ class ActorHash < ActiveObject
18
+ def initialize(initial = nil)
19
+ @h = initial.nil? ? {} : initial.to_hash.dup
20
+ end
21
+
22
+ # Sent and not waited for: the arguments travel as arguments, so unlike a
23
+ # block they can be anything, reassigned locals included.
24
+ async def set(key, value)
25
+ @h[key] = value
26
+ end
27
+
28
+ async def increment(key, by = 1)
29
+ @h[key] = (@h[key] || 0) + by
30
+ end
31
+
32
+ sync def [](key) = @h[key]
33
+ sync def key?(key) = @h.key?(key)
34
+ sync def keys = @h.keys
35
+ sync def to_h = @h.dup
36
+
37
+ # Not published, so they stay off the proxy's API; the proxy reaches them
38
+ # with sync_send, which gets at any method the way __send__ does.
39
+
40
+ # [found, value]; keeps a stored nil apart from a missing key.
41
+ def __lookup__(key) = @h.key?(key) ? [true, @h[key]] : [false, nil]
42
+
43
+ # Runs the block on the owner, handing it the real Hash -- not a copy and
44
+ # not a proxy. It cannot escape: the block runs here, and a return value is
45
+ # copied on its way back, so what the caller gets is never this Hash.
46
+ #
47
+ # The proc arrives as a plain argument, not as a block: blocks cannot cross
48
+ # Ractors, isolated procs can.
49
+ def __call__(prc, args) = prc.call(@h, *args)
50
+
51
+ # The caller-side half. These live on the proxy because that is what
52
+ # ActiveObject hands out.
53
+ class Proxy
54
+ # Which Ractor keeps it is nobody's business out here.
55
+ undef_method :owner, :owner?
56
+
57
+ # Sends the block to the owner and carries on. Returns nil. An exception
58
+ # reaches the owner's #on_async_exception.
59
+ #
60
+ # h.async_call {|h| h[:hits] += 1 }
61
+ # h.async_call(line) {|h, line| h[:log] << line }
62
+ #
63
+ # The block must be isolatable: it may read outer variables that are never
64
+ # reassigned, and anything else has to be passed as an argument.
65
+ def async_call(*args, &block) = async_send(:__call__, isolate(block), args)
66
+
67
+ # Sends the block and waits, returning what it returned, copied back.
68
+ def call(*args, &block) = sync_send(:__call__, isolate(block), args)
69
+
70
+ # Sends the block and returns a Future straight away.
71
+ def future_call(*args, &block) = future_send(:__call__, isolate(block), args)
72
+
73
+ def fetch(key, *default, &block)
74
+ raise ArgumentError, "wrong number of arguments (given #{default.size + 1}, expected 1..2)" if default.size > 1
75
+
76
+ found, value = sync_send(:__lookup__, key)
77
+ return value if found
78
+ return block.call(key) if block # Hash#fetch prefers the block too
79
+ return default.first if default.size == 1
80
+
81
+ raise KeyError.new("key not found: #{key.inspect}", key: key, receiver: self)
82
+ end
83
+
84
+ def inspect = "#<#{active_object_class} #{to_h.inspect}>"
85
+
86
+ private
87
+
88
+ # A block that is already shareable is already isolated; making another one
89
+ # allocates a Proc on every call, which on a hot path is most of the cost.
90
+ def isolate(block)
91
+ raise LocalJumpError, "no block given (yield)" unless block
92
+ return block if Ractor.shareable?(block)
93
+
94
+ Ractor.shareable_proc(&block)
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ractor/lock.so"
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ractor/lock.so"
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Ractor
4
+ module Sharing
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "sharing/version"
4
+ require_relative "lockvar"
5
+ require_relative "lockhash"
6
+ require_relative "tvar"
7
+ require_relative "active_object"
8
+ require_relative "actor_hash"
9
+
10
+ class Ractor
11
+ # Ways for Ractors to share mutable state. Each one is shareable itself and
12
+ # keeps state that any Ractor may read and change; they differ in how much
13
+ # state moves at once:
14
+ #
15
+ # Ractor::TVar one or more variables, changed together -- start here
16
+ # Ractor::LockVar one variable, and a block that runs exactly once
17
+ # Ractor::LockHash a hash of those, atomic across its own keys
18
+ # Ractor::ActiveObject an object of your own, owned by one Ractor
19
+ # Ractor::ActorHash the same, with the interface already chosen: a hash
20
+ #
21
+ # Require this file for all of them, or one at a time:
22
+ #
23
+ # require "ractor/tvar"
24
+ # require "ractor/lockvar"
25
+ # require "ractor/lockhash"
26
+ # require "ractor/active_object"
27
+ # require "ractor/actor_hash"
28
+ module Sharing
29
+ end
30
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ractor/tvar.so'
4
+
5
+ class Ractor
6
+ class TVar
7
+ def __increment__ inc
8
+ Ractor::atomically do
9
+ self.value += inc
10
+ end
11
+ end
12
+ end
13
+ end
metadata ADDED
@@ -0,0 +1,66 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ractor-sharing
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Koichi Sasada
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: 'Ractor::LockVar, Ractor::TVar and Ractor::ActiveObject: shareable objects
13
+ that hold state any Ractor may read and change.'
14
+ email:
15
+ - ko1@atdot.net
16
+ executables: []
17
+ extensions:
18
+ - ext/ractor/lock/extconf.rb
19
+ - ext/ractor/tvar/extconf.rb
20
+ extra_rdoc_files: []
21
+ files:
22
+ - LICENSE.txt
23
+ - README.md
24
+ - docs/active_object.md
25
+ - docs/actor_hash.md
26
+ - docs/lockhash.md
27
+ - docs/lockvar.md
28
+ - docs/tvar.md
29
+ - ext/ractor/lock/extconf.rb
30
+ - ext/ractor/lock/lock.c
31
+ - ext/ractor/lock/lock.h
32
+ - ext/ractor/lock/lockhash.c
33
+ - ext/ractor/lock/lockvar.c
34
+ - ext/ractor/tvar/extconf.rb
35
+ - ext/ractor/tvar/tvar.c
36
+ - lib/ractor/active_object.rb
37
+ - lib/ractor/active_object/future.rb
38
+ - lib/ractor/actor_hash.rb
39
+ - lib/ractor/lockhash.rb
40
+ - lib/ractor/lockvar.rb
41
+ - lib/ractor/sharing.rb
42
+ - lib/ractor/sharing/version.rb
43
+ - lib/ractor/tvar.rb
44
+ homepage: https://github.com/ko1/ractor-sharing
45
+ licenses:
46
+ - MIT
47
+ metadata:
48
+ source_code_uri: https://github.com/ko1/ractor-sharing
49
+ rdoc_options: []
50
+ require_paths:
51
+ - lib
52
+ required_ruby_version: !ruby/object:Gem::Requirement
53
+ requirements:
54
+ - - ">="
55
+ - !ruby/object:Gem::Version
56
+ version: '4.0'
57
+ required_rubygems_version: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ requirements: []
63
+ rubygems_version: 4.0.6
64
+ specification_version: 4
65
+ summary: Ways for Ractors to share mutable state
66
+ test_files: []