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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +261 -0
- data/docs/active_object.md +211 -0
- data/docs/actor_hash.md +144 -0
- data/docs/lockhash.md +150 -0
- data/docs/lockvar.md +280 -0
- data/docs/tvar.md +124 -0
- data/ext/ractor/lock/extconf.rb +2 -0
- data/ext/ractor/lock/lock.c +288 -0
- data/ext/ractor/lock/lock.h +59 -0
- data/ext/ractor/lock/lockhash.c +407 -0
- data/ext/ractor/lock/lockvar.c +259 -0
- data/ext/ractor/tvar/extconf.rb +2 -0
- data/ext/ractor/tvar/tvar.c +869 -0
- data/lib/ractor/active_object/future.rb +58 -0
- data/lib/ractor/active_object.rb +311 -0
- data/lib/ractor/actor_hash.rb +98 -0
- data/lib/ractor/lockhash.rb +3 -0
- data/lib/ractor/lockvar.rb +3 -0
- data/lib/ractor/sharing/version.rb +7 -0
- data/lib/ractor/sharing.rb +30 -0
- data/lib/ractor/tvar.rb +13 -0
- metadata +66 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 88fa8b206f015cc52f9fb975060cd60ad960b4156a9b827aeb553a31ae34cf39
|
|
4
|
+
data.tar.gz: 00adc02e6a7c43bb44ec0c1b701bd3d8b6b1681df54cb3a4163a0b981a087da6
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: '0156339ebc1d6924032c142bd106036a9852c5564d01d25e4e198e7b5e11fc006bcfbdcfabe0ec3b26cb925acaa8c102f3690b7f05d5098d4cdf4b318967ee5b'
|
|
7
|
+
data.tar.gz: 7cdc6b398b61e1c202653116d6faad460f84a248d0867dde5905f27e6aee288278f91a3d16f0c4444316a4396105ade08a9562b7a8a064d6f627580ddc8e80cc
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Koichi Sasada
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# ractor-sharing
|
|
2
|
+
|
|
3
|
+
Ways for Ractors to share mutable state.
|
|
4
|
+
|
|
5
|
+
Ractors keep their objects to themselves. What crosses between them is either
|
|
6
|
+
frozen or copied, so there is nowhere to put a counter, a registry or a cache
|
|
7
|
+
that several Ractors both read and change. Each class here is such a place.
|
|
8
|
+
|
|
9
|
+
What each one is, in a line:
|
|
10
|
+
|
|
11
|
+
* **[`Ractor::TVar`](docs/tvar.md)** is a *transactional* variable. Read and
|
|
12
|
+
write any number of them inside one `Ractor.atomically` block, and everything
|
|
13
|
+
that block changed takes effect together or not at all. A block that loses a
|
|
14
|
+
race is rolled back and run again.
|
|
15
|
+
* **[`Ractor::LockVar`](docs/lockvar.md)** is a variable behind a *lock*. An
|
|
16
|
+
update waits for its turn, and then its block runs exactly once.
|
|
17
|
+
* **[`Ractor::LockHash`](docs/lockhash.md)** is a hash behind one lock. A
|
|
18
|
+
`synchronize` section is atomic across the keys of that hash, and only those.
|
|
19
|
+
* **[`Ractor::ActiveObject`](docs/active_object.md)** is an object that lives in
|
|
20
|
+
a Ractor of its own. It never leaves; callers send it method calls, and the
|
|
21
|
+
owner runs them one at a time.
|
|
22
|
+
* **[`Ractor::ActorHash`](docs/actor_hash.md)** is a hash that lives in a Ractor
|
|
23
|
+
of its own. Callers send it blocks to run on it.
|
|
24
|
+
|
|
25
|
+
**Start with `Ractor::TVar`.** It takes one variable or several, it cannot
|
|
26
|
+
deadlock, and it is the quickest of these when a variable is fought over. Move
|
|
27
|
+
off it only for a reason the others below name.
|
|
28
|
+
|
|
29
|
+
| | reach for it when | read | write |
|
|
30
|
+
|---|---|---:|---:|
|
|
31
|
+
| [`Ractor::TVar`](docs/tvar.md)<br>`Ractor.atomically { a.value += 1 }` | always, unless a row below says otherwise. One variable or a dozen, with no lock order to get wrong | 68 ns | 351 ns |
|
|
32
|
+
| [`Ractor::LockVar`](docs/lockvar.md)<br>`lv.update {\|v\| v + 1 }` | the block must run **exactly once**, because it logs, sends, or does anything else a retry would repeat | 74 ns | 352 ns |
|
|
33
|
+
| [`Ractor::LockHash`](docs/lockhash.md)<br>`h.synchronize {\|h\| h[k] = v }` | the same, but the keys are not known in advance | 132 ns | 433 ns |
|
|
34
|
+
| [`Ractor::ActiveObject`](docs/active_object.md)<br>`sync def add(k, v) = @db[k] = v` | the values will not be frozen, and the state deserves methods of its own | 2.3 µs | 2.8 µs |
|
|
35
|
+
| [`Ractor::ActorHash`](docs/actor_hash.md)<br>`h.call {\|h\| h[:hits] += 1 }` | the same, and a plain hash is all the interface you need | 2.2 µs | 3.2 µs |
|
|
36
|
+
|
|
37
|
+
One uncontended operation from a single Ractor on 16 cores, replacing a frozen
|
|
38
|
+
record. The two at the bottom also start a Ractor apiece, which runs until the
|
|
39
|
+
process ends; their write is the figure for a `sync` call, and drops to 1.6 µs
|
|
40
|
+
and 1.9 µs when sent without waiting for the reply (`async def`, `async_call`).
|
|
41
|
+
Contended, the order changes: see [Performance](#performance).
|
|
42
|
+
|
|
43
|
+
The first three hold **shareable** values, so a change replaces a value rather
|
|
44
|
+
than modifying it: `lv.update { it.merge(k => v).freeze }`. When your state is a
|
|
45
|
+
mutable object you have no intention of freezing, such as a Hash you keep writing
|
|
46
|
+
into or an object graph with methods over it, it cannot go in any of them. The last two
|
|
47
|
+
are for exactly that: the object stays mutable and unshareable, in a Ractor of
|
|
48
|
+
its own, and you send it the calls instead of the data.
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
require "ractor/sharing" # all of them
|
|
52
|
+
|
|
53
|
+
require "ractor/tvar" # or one at a time
|
|
54
|
+
require "ractor/lockvar"
|
|
55
|
+
require "ractor/lockhash"
|
|
56
|
+
require "ractor/active_object"
|
|
57
|
+
require "ractor/actor_hash"
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Which one
|
|
61
|
+
|
|
62
|
+
**The default: `TVar`.** One variable or a dozen, and the same code either way:
|
|
63
|
+
whatever a transaction changes, the rest of the program sees all of it or none of
|
|
64
|
+
it. There is no lock to take in the right order, so two transactions can never
|
|
65
|
+
deadlock, and when a variable is genuinely fought over it is the quickest thing
|
|
66
|
+
here, because losing a race and retrying beats parking a thread.
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
from, to = Ractor::TVar.new(100), Ractor::TVar.new(0)
|
|
70
|
+
Ractor.atomically { from.value -= 10; to.value += 10 }
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The one thing to hold on to: a transaction that loses a race is **rolled back and
|
|
74
|
+
run again**, so its block has to be safe to run twice. Keep it to reading and
|
|
75
|
+
writing TVars.
|
|
76
|
+
|
|
77
|
+
That retry is also what contention costs it. With sixteen Ractors on one variable
|
|
78
|
+
a minimal block runs about 1.8 times per completed update, which is cheap enough
|
|
79
|
+
that `TVar` is still the quickest thing here. The factor climbs with the length
|
|
80
|
+
of the block, though: a few microseconds of work in there and it runs closer to
|
|
81
|
+
five times, and most of the machine is doing work that gets thrown away. A
|
|
82
|
+
variable that is both hot and not trivial to update is the case for `LockVar`
|
|
83
|
+
below, which waits its turn and runs the block once.
|
|
84
|
+
|
|
85
|
+
Everything below is a reason to leave `TVar` behind.
|
|
86
|
+
|
|
87
|
+
**When the block must run exactly once: `LockVar`.** If the block has a side
|
|
88
|
+
effect a retry would repeat, such as writing a line or sending a message, then
|
|
89
|
+
waiting for a turn beats retrying. One shareable value, and the
|
|
90
|
+
block runs once by construction.
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
counter = Ractor::LockVar.new(0)
|
|
94
|
+
4.times.map { Ractor.new(counter) {|c| 1000.times { c.increment } } }.each(&:join)
|
|
95
|
+
counter.value #=> 4000
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
**The same, for a hash: `LockHash`.** A registry, a cache, a scoreboard each
|
|
99
|
+
worker writes a row of, where the keys are not known in advance. Reads need no
|
|
100
|
+
ceremony; writes go inside `synchronize`, and everything one section changes
|
|
101
|
+
appears at once. Atomic across its own keys, and only those.
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
board = Ractor::LockHash.new
|
|
105
|
+
board.synchronize {|b| b[:worker_1] = 42 }
|
|
106
|
+
board.to_h #=> {worker_1: 42}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
**A mutable object: `ActiveObject`.** When freezing the state is not on the
|
|
110
|
+
table, give the object a Ractor of its own. It never leaves; callers send method
|
|
111
|
+
calls in, the owner runs them one at a time, and the object goes on being an
|
|
112
|
+
ordinary mutable Ruby object.
|
|
113
|
+
|
|
114
|
+
Know what that costs. Each instance **starts a Ractor**, which lives until the
|
|
115
|
+
process ends, since there is no way to stop one, so this is for a handful of
|
|
116
|
+
long-lived objects, not for many small ones. And every call from another Ractor
|
|
117
|
+
is a message round trip: a `sync` call is about **2.6 µs** from a worker Ractor,
|
|
118
|
+
against **0.35 µs** for an uncontended `LockVar#update` on the same machine. An
|
|
119
|
+
`async` call does not wait for the reply and costs about **1.6 µs**. Calls
|
|
120
|
+
to one object are also serialized through its owner, so the object is a
|
|
121
|
+
throughput limit as well as a home for the state. If your state does fit in a
|
|
122
|
+
shareable value, one of the first three will cost you far less.
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
class People < Ractor::ActiveObject
|
|
126
|
+
def initialize = @db = {}
|
|
127
|
+
async def add(name, age) = @db[name] = age
|
|
128
|
+
sync def find(name) = @db[name]
|
|
129
|
+
end
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
**A hash whose values will not be frozen: `ActorHash`.** Same shape as
|
|
133
|
+
`LockHash`, but the entries live in a Ractor of its own, so they can be anything
|
|
134
|
+
and a block changes them in place over there. Reads are questions you ask;
|
|
135
|
+
changes are work you send, and you need not wait for them.
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
h = Ractor::ActorHash.new
|
|
139
|
+
h.increment(:hits)
|
|
140
|
+
h.async_call {|h| (h[:log] ||= []) << "a line" }
|
|
141
|
+
h[:log]
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Two signs you picked the wrong one. Reaching for two `LockVar`s at once is
|
|
145
|
+
refused, with a message pointing here: that is the sign you wanted a `TVar`.
|
|
146
|
+
Finding yourself freezing a copy of a collection on every update is the sign you
|
|
147
|
+
wanted an `ActiveObject`.
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
## Performance
|
|
151
|
+
|
|
152
|
+
The workload is one shared record, a frozen `{status:, seq:}`: a **read** takes it
|
|
153
|
+
out, a **write** puts a new frozen one in its place. Nanoseconds for one
|
|
154
|
+
operation, counted across all Ractors, on 16 cores.
|
|
155
|
+
|
|
156
|
+
### Reading
|
|
157
|
+
|
|
158
|
+
| | one Ractor (ns) | 16 on one object (ns) | 16 on their own (ns) |
|
|
159
|
+
|---|---:|---:|---:|
|
|
160
|
+
| `TVar#value` | 68 | **9** | 9 |
|
|
161
|
+
| `LockVar#value` | 74 | 365 | 10 |
|
|
162
|
+
| `LockHash#[]` | 132 | 489 | 23 |
|
|
163
|
+
| `ActiveObject` sync method | 2300 | 1380 | 742 |
|
|
164
|
+
| `ActorHash#[]` | 2179 | 1451 | 730 |
|
|
165
|
+
| no sharing at all | 78 | n/a | 9 |
|
|
166
|
+
|
|
167
|
+
**Reads of a shared object scale on `TVar` and do not on the two locks.** A
|
|
168
|
+
`TVar` read outside a transaction takes nothing, so sixteen Ractors reading one
|
|
169
|
+
`TVar` cost the same as sixteen reading their own. `LockVar#value` and
|
|
170
|
+
`LockHash#[]` take the lock, so those sixteen readers stand in a queue: 365 ns
|
|
171
|
+
against 9. Give each Ractor its own object and every one of them scales to the
|
|
172
|
+
machine's limit.
|
|
173
|
+
|
|
174
|
+
### Writing
|
|
175
|
+
|
|
176
|
+
| | one Ractor (ns) | 16 on one object (ns) | 16 on their own (ns) |
|
|
177
|
+
|---|---:|---:|---:|
|
|
178
|
+
| `TVar` transaction | 351 | **509** | 108 |
|
|
179
|
+
| `LockVar#update` | 352 | 1102 | **50** |
|
|
180
|
+
| `LockHash#synchronize` | 433 | 1238 | 58 |
|
|
181
|
+
| `ActiveObject` async method | 1555 | 839 | 179 |
|
|
182
|
+
| `ActiveObject` sync method | 2789 | 1587 | 760 |
|
|
183
|
+
| `ActorHash#async_call` | 1919 | 1032 | 219 |
|
|
184
|
+
| `ActorHash#call` | 3166 | 1740 | 747 |
|
|
185
|
+
| no sharing at all | 117 | n/a | 18 |
|
|
186
|
+
|
|
187
|
+
**Fought over, nothing scales and `TVar` stays about 2× ahead**, because losing a
|
|
188
|
+
race and running a short block again is cheaper than parking a thread and waking
|
|
189
|
+
it, and a transaction that keeps losing backs off, about 100 ns per consecutive
|
|
190
|
+
loss, spinning rather than sleeping, before running again. That cell is the
|
|
191
|
+
volatile one: between sweeps it lands anywhere from 500 to 870 ns, so its row is
|
|
192
|
+
the median of seven runs where every other cell is the median of three.
|
|
193
|
+
**Spread out, the locks scale as far as the machine does and `TVar` does not**:
|
|
194
|
+
7.0× for `LockVar` against 3.3×, because every committing transaction takes one
|
|
195
|
+
process wide lock to allocate its version number. **Not waiting for the reply is
|
|
196
|
+
worth 3× to 4× when the objects are spread out** on the two classes that keep a
|
|
197
|
+
Ractor (16 on their own, sync against async above); on one shared object the
|
|
198
|
+
serialisation at the owner leaves it under 2×, and from a single caller it is
|
|
199
|
+
about 1.7×.
|
|
200
|
+
|
|
201
|
+
The `no sharing at all` row is the machine's own ceiling: about 8× is as far as
|
|
202
|
+
anything here scales. Called from the main Ractor rather than a worker, the two
|
|
203
|
+
Ractor backed classes cost about 8.9 µs instead of 2.6, because that thread has a
|
|
204
|
+
native thread to itself and waking it is a syscall.
|
|
205
|
+
|
|
206
|
+
### Not increment
|
|
207
|
+
|
|
208
|
+
`TVar#increment` and `LockVar#increment` each take a fast path that adds two
|
|
209
|
+
Fixnums without running any Ruby, so a benchmark built on `increment` measures
|
|
210
|
+
that path rather than the class. It gets a table of its own:
|
|
211
|
+
|
|
212
|
+
| | one Ractor (ns) | 16 on one object (ns) | 16 on their own (ns) |
|
|
213
|
+
|---|---:|---:|---:|
|
|
214
|
+
| `LockVar#increment` | 77 | 338 | **8** |
|
|
215
|
+
| `TVar#increment` | 89 | **144** | 75 |
|
|
216
|
+
|
|
217
|
+
`benchmark/family.rb` produces all of these, sweeping 1, 2, 4, 8 and 16 Ractors
|
|
218
|
+
over read, write and a 9:1 mix, under both conditions. Every worker reports ready
|
|
219
|
+
before the clock starts, and every run is checked afterwards against the number
|
|
220
|
+
of writes that went in, so a lost update cannot report itself as a fast run.
|
|
221
|
+
These numbers are from ruby 4.1.0dev (master 69b49ac7ae) on 16 cores with the CPU
|
|
222
|
+
governor fixed at `performance`.
|
|
223
|
+
|
|
224
|
+
**Each cell is the median of three runs** (`REPS=3`), and how much to trust a
|
|
225
|
+
small difference depends on the row. Between two independent sweeps the
|
|
226
|
+
`TVar`, `LockVar` and `LockHash` cells moved by 0% to 10%, so their comparisons
|
|
227
|
+
hold. The two async rows moved by 12% to 23%, which is why the claim above is 3×
|
|
228
|
+
to 4× and not a figure with a decimal in it.
|
|
229
|
+
|
|
230
|
+
## What is not here
|
|
231
|
+
|
|
232
|
+
These classes hold state. They are not a way for Ractors to wait for each other.
|
|
233
|
+
|
|
234
|
+
A Ractor waits in one place, `Ractor::Port#receive`, and that is the design, not
|
|
235
|
+
an accident. Waiting for another Ractor to produce something, hand work over or
|
|
236
|
+
reach a point is a conversation between them, and it is held in messages. So
|
|
237
|
+
there is no queue here that several Ractors take work from, no barrier, no
|
|
238
|
+
semaphore: those would be a second place to wait, and a rendezvous dressed up as
|
|
239
|
+
a data structure.
|
|
240
|
+
|
|
241
|
+
The one wait they make you do is for a lock, and even that is a `receive`: a
|
|
242
|
+
thread that cannot take a lock parks on a `Ractor::Port` of its own until the
|
|
243
|
+
holder sends it a wakeup. (Inside the extensions there are native mutexes, held
|
|
244
|
+
for a few instructions and never across Ruby code, and `ActiveObject` waits on
|
|
245
|
+
`Ractor.select` while its owner starts. Neither is a place your program waits.)
|
|
246
|
+
|
|
247
|
+
## Requirements
|
|
248
|
+
|
|
249
|
+
Ruby 4.0 or later (`Ractor::Port`, and Ractors that are worth using).
|
|
250
|
+
|
|
251
|
+
## Development
|
|
252
|
+
|
|
253
|
+
```
|
|
254
|
+
rake # compile both extensions and run every test
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Documentation for each class is in [docs/](docs/).
|
|
258
|
+
|
|
259
|
+
## License
|
|
260
|
+
|
|
261
|
+
MIT. See [LICENSE.txt](LICENSE.txt).
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# Ractor::ActiveObject
|
|
2
|
+
|
|
3
|
+
`Ractor::ActiveObject` runs the [Active Object pattern](https://en.wikipedia.org/wiki/Active_object)
|
|
4
|
+
on Ractors: each instance's state is owned by a dedicated *owner Ractor*, and
|
|
5
|
+
method calls from any other Ractor are forwarded to it and executed there one
|
|
6
|
+
at a time. The model is "move the request to the data owner", not "move the
|
|
7
|
+
data to the computation".
|
|
8
|
+
|
|
9
|
+
From the user's point of view it is an ordinary Ruby class whose public
|
|
10
|
+
interface is *published* with `sync` / `async` / `future`.
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
require "ractor/active_object"
|
|
14
|
+
|
|
15
|
+
class People < Ractor::ActiveObject
|
|
16
|
+
def initialize
|
|
17
|
+
@db = {}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
async def add(name, age) # fire-and-forget
|
|
21
|
+
@db[name] = age
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
sync def find(name) # wait for the result
|
|
25
|
+
@db[name]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
future def load_all # returns a Future immediately
|
|
29
|
+
@db.dup
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
PEOPLE = People.new # proxy is shareable: usable from any Ractor
|
|
34
|
+
PEOPLE.add("ko1", 46)
|
|
35
|
+
|
|
36
|
+
Ractor.new do
|
|
37
|
+
p PEOPLE.find("ko1") # => 46
|
|
38
|
+
f = PEOPLE.load_all
|
|
39
|
+
p f.value # => {"ko1" => 46}
|
|
40
|
+
end.join
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Requires Ruby 4.0 or later (`Ractor::Port`).
|
|
44
|
+
|
|
45
|
+
## What it costs
|
|
46
|
+
|
|
47
|
+
Each instance starts a Ractor of its own, and that Ractor runs until the process
|
|
48
|
+
ends: there is no shutdown. Creating active objects in a loop leaks them.
|
|
49
|
+
|
|
50
|
+
Every `sync` call from another Ractor is a message round trip, about **2.6 µs**
|
|
51
|
+
measured on 16 cores, where the same update on an uncontended
|
|
52
|
+
`Ractor::LockVar` is **0.35 µs**. From the main Ractor rather than a worker it is
|
|
53
|
+
**8.9 µs**, because that thread has a native thread to itself and waking it is a
|
|
54
|
+
syscall. Calls to one object run one at a time on its owner, so a single hot
|
|
55
|
+
object caps how fast callers get through it.
|
|
56
|
+
|
|
57
|
+
**An `async` method is the one to reach for when nothing needs the answer**, since
|
|
58
|
+
the round trip is most of the cost: sixteen Ractors with an object each get
|
|
59
|
+
through `async` calls at **0.18 µs**, against 0.76 µs for the same method declared
|
|
60
|
+
`sync`. Reads have to be `sync`, because the answer is the point.
|
|
61
|
+
|
|
62
|
+
None of that applies to calls made *inside* the owner: those are plain Ruby
|
|
63
|
+
calls. And if the state you are guarding fits in a shareable value,
|
|
64
|
+
[`Ractor::LockVar`](lockvar.md) or [`Ractor::TVar`](tvar.md) will be much
|
|
65
|
+
cheaper.
|
|
66
|
+
|
|
67
|
+
## Invocation policies
|
|
68
|
+
|
|
69
|
+
| modifier | explicit form | caller waits? | returns | exception |
|
|
70
|
+
|----------|------------------------|---------------|----------------------|-------------------------------|
|
|
71
|
+
| `sync` | `obj.sync_send(m, …)` | yes | the method's result | re-raised in the caller |
|
|
72
|
+
| `async` | `obj.async_send(m, …)` | no | `nil` | `#on_async_exception` (owner) |
|
|
73
|
+
| `future` | `obj.future_send(m, …)`| no | `Future` | raised by `Future#value` |
|
|
74
|
+
|
|
75
|
+
* `sync`, `async` and `future` are class-level DSL methods that take method
|
|
76
|
+
names, so they compose like `private`: `async def foo…`, `async :foo, :bar`,
|
|
77
|
+
`sync attr_reader :size`. Declaring a method *publishes* it on the proxy,
|
|
78
|
+
regardless of its visibility in the class.
|
|
79
|
+
* **`Foo.new` returns a proxy (an instance of `Foo::Proxy`), not a `Foo`.**
|
|
80
|
+
The proxy has exactly the published methods plus `inspect` and `sync_send` / `async_send`
|
|
81
|
+
/ `future_send`, `owner`, `owner?`, `active_object_class`. Undeclared methods
|
|
82
|
+
raise `NoMethodError` on the proxy; `*_send` can still reach them, like
|
|
83
|
+
`__send__`.
|
|
84
|
+
* `*_send` always overrides the declared policy.
|
|
85
|
+
* `Klass.invocation_policy(:name)` returns `:sync` / `:async` / `:future`
|
|
86
|
+
(or `nil` if not published); `Klass.proxy_class` is the proxy class.
|
|
87
|
+
|
|
88
|
+
### Calls on the owner Ractor
|
|
89
|
+
|
|
90
|
+
Inside the owner Ractor the object is a plain `Foo` instance (the *servant*):
|
|
91
|
+
calls between its methods are ordinary Ruby calls, with no mailbox and no policy;
|
|
92
|
+
`async` methods run immediately and return their real value. A proxy used
|
|
93
|
+
inside its own owner Ractor (e.g. through a constant) also calls the servant
|
|
94
|
+
directly. `owner?` tells you which side you are on; `owner` is the owner
|
|
95
|
+
Ractor. A method that returns `self` hands the caller the proxy.
|
|
96
|
+
|
|
97
|
+
### Ordering
|
|
98
|
+
|
|
99
|
+
Requests from one thread are executed in the order they were sent, one at a
|
|
100
|
+
time per object. A `sync` call therefore works as a barrier after `async`
|
|
101
|
+
calls:
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
class Cache < Ractor::ActiveObject
|
|
105
|
+
def initialize = @c = {}
|
|
106
|
+
async def set(k, v) = @c[k] = v
|
|
107
|
+
sync def nop = nil
|
|
108
|
+
end
|
|
109
|
+
cache = Cache.new
|
|
110
|
+
|
|
111
|
+
cache.set(:x, 1) # async
|
|
112
|
+
cache.sync_send(:nop) # everything above has been applied
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Arguments and results
|
|
116
|
+
|
|
117
|
+
Arguments and results cross Ractors with the normal `Ractor#send` semantics
|
|
118
|
+
(copy, or share by reference if shareable). Values that cannot be sent raise
|
|
119
|
+
`TypeError` at the caller (arguments) or come back as
|
|
120
|
+
`Ractor::ActiveObject::Error` (results / exceptions), so a caller never hangs.
|
|
121
|
+
Exceptions keep the owner-side backtrace, followed by the caller's frames.
|
|
122
|
+
|
|
123
|
+
Blocks cannot be passed to remote invocations (`ArgumentError`); they work for
|
|
124
|
+
calls made on the owner Ractor.
|
|
125
|
+
|
|
126
|
+
`sync` calls take their reply port from a Ractor-local pool
|
|
127
|
+
(`Ractor[:__ao_reply_ports__]`, an Array), so steady-state calls allocate no
|
|
128
|
+
port. A port is owned exclusively by one caller while in use, so concurrent
|
|
129
|
+
threads of a Ractor never see each other's replies; the pool grows to the
|
|
130
|
+
number of concurrent callers. A port whose wait was interrupted (e.g. by
|
|
131
|
+
`Timeout`) is discarded, never reused. `future` calls always get a fresh port.
|
|
132
|
+
|
|
133
|
+
A caller waits on its reply port alone (`Port#receive`), not on
|
|
134
|
+
`Ractor.select(reply, owner)`: watching the owner as well costs ≈4 % of a
|
|
135
|
+
2.5 µs `sync` call. The owner is not expected to die, since its request loop
|
|
136
|
+
rescues `Exception`, and if it does go down with a request in flight it
|
|
137
|
+
answers that caller with an `ActiveObject::Error` from an `ensure`, while any
|
|
138
|
+
later send fails fast with `Ractor::ClosedError` → `ActiveObject::Error`.
|
|
139
|
+
|
|
140
|
+
The gap this leaves is a caller whose request was still queued when the owner
|
|
141
|
+
died: it stays blocked in `receive`. Waking it would need either the
|
|
142
|
+
per-call `Ractor.select`, or `Ractor#monitor` on the reply port, but
|
|
143
|
+
`monitor` delivers a bare `:exited` that does not say which Ractor exited, so
|
|
144
|
+
reply ports would have to be pooled per owner, and that bookkeeping costs the
|
|
145
|
+
same ≈4 %. (`Port#close` is not an option: it cannot be called from another
|
|
146
|
+
Ractor, and even from the owning Ractor it does not wake a thread already
|
|
147
|
+
blocked in `receive`.) Both were measured and rejected.
|
|
148
|
+
|
|
149
|
+
### Futures
|
|
150
|
+
|
|
151
|
+
```ruby
|
|
152
|
+
class Slow < Ractor::ActiveObject
|
|
153
|
+
future def compute(x) = x * 2
|
|
154
|
+
end
|
|
155
|
+
obj = Slow.new
|
|
156
|
+
|
|
157
|
+
f = obj.future_send(:compute, 1)
|
|
158
|
+
f.value # waits; returns the result or raises the method's exception
|
|
159
|
+
f.wait # waits; returns self, never raises
|
|
160
|
+
f.resolved? # true once the result has been observed by #value / #wait
|
|
161
|
+
f.rejected?
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
A `Future` can be consumed only in the Ractor that created it (its reply port
|
|
165
|
+
belongs to that Ractor). Threads inside that Ractor may share it.
|
|
166
|
+
|
|
167
|
+
### Async errors
|
|
168
|
+
|
|
169
|
+
An exception raised by an `async` invocation is passed to
|
|
170
|
+
`on_async_exception(exception, method_name)` on the owner. The default
|
|
171
|
+
implementation prints a warning; override it to supervise:
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
class Worker < Ractor::ActiveObject
|
|
175
|
+
def initialize = @failures = []
|
|
176
|
+
sync def failures = @failures.map(&:first)
|
|
177
|
+
|
|
178
|
+
def on_async_exception(e, name)
|
|
179
|
+
@failures << [name, e]
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### Lifecycle
|
|
185
|
+
|
|
186
|
+
`Klass.new(*args, **kwargs)` starts the owner Ractor, runs `initialize` there
|
|
187
|
+
(exceptions propagate to the caller) and returns a frozen, shareable proxy.
|
|
188
|
+
The owner Ractor serves requests until the process exits. If it terminates
|
|
189
|
+
for another reason, the in-flight call and every later call raise
|
|
190
|
+
`Ractor::ActiveObject::Error`; requests already queued behind it, whose
|
|
191
|
+
callers are blocked on their reply ports, are the one case that can hang.
|
|
192
|
+
|
|
193
|
+
## Implementation notes and limitations
|
|
194
|
+
|
|
195
|
+
* `Foo::Proxy` is created when `Foo` is defined (`Foo < Bar` gives
|
|
196
|
+
`Foo::Proxy < Bar::Proxy`, so published methods are inherited). Declaring a
|
|
197
|
+
method defines a forwarding method on the proxy class. A subclass that
|
|
198
|
+
overrides a published method keeps the parent's policy unless it declares
|
|
199
|
+
the method again.
|
|
200
|
+
* The servant is untouched: no wrappers, `super` and private calls work as in
|
|
201
|
+
any Ruby class.
|
|
202
|
+
* Subclasses must be defined in the main Ractor (class definition needs to
|
|
203
|
+
store class-level state, which non-main Ractors cannot do).
|
|
204
|
+
* `proxy.is_a?(Foo)` is false; use `proxy.active_object_class` or
|
|
205
|
+
`Foo.proxy_class === proxy`.
|
|
206
|
+
* `Future#ready?` (non-blocking check) is not provided: `Ractor::Port` has no
|
|
207
|
+
non-blocking receive.
|
|
208
|
+
* Ruby prints "Ractor API is experimental" on the first `Ractor.new`; silence
|
|
209
|
+
it with `Warning[:experimental] = false` if you want.
|
|
210
|
+
|
|
211
|
+
Part of [ractor-sharing](../README.md).
|
data/docs/actor_hash.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# Ractor::ActorHash
|
|
2
|
+
|
|
3
|
+
A Hash kept by a Ractor of its own. Reading it is a question you ask; every
|
|
4
|
+
change is work you send, and the work runs where the Hash is.
|
|
5
|
+
|
|
6
|
+
```ruby
|
|
7
|
+
require "ractor/actor_hash"
|
|
8
|
+
|
|
9
|
+
h = Ractor::ActorHash.new
|
|
10
|
+
h.async_call {|h| h[:hits] = (h[:hits] || 0) + 1 } # send it and carry on
|
|
11
|
+
h[:hits] # ask
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Why not LockHash
|
|
15
|
+
|
|
16
|
+
[`Ractor::LockHash`](lockhash.md) can only hold **shareable** values, so every
|
|
17
|
+
change replaces a value with a frozen new one. Here the entries never leave the
|
|
18
|
+
owner except as copies, so they can be anything, and a block changes them in
|
|
19
|
+
place over there:
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
h.async_call {|h| (h[:log] ||= []) << "a line" } # a value it goes on appending to
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The price is the owner: one Ractor per ActorHash, running until the process
|
|
26
|
+
ends, and about **2.2 µs** for a round trip, where a LockHash operation is a few
|
|
27
|
+
hundred **ns**. Reach for this one when the state genuinely will not be frozen;
|
|
28
|
+
otherwise LockHash is far cheaper.
|
|
29
|
+
|
|
30
|
+
A write that does not need an answer should be `async_call` or `set` rather than
|
|
31
|
+
`call`: not waiting for the reply is worth about 3× on sixteen Ractors with a
|
|
32
|
+
hash each (219 ns against 747) and under 2× on sixteen sharing one (1032 ns
|
|
33
|
+
against 1740). Reads pay the full round trip regardless, since a read is the
|
|
34
|
+
answer.
|
|
35
|
+
|
|
36
|
+
## API
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
h = Ractor::ActorHash.new(initial = nil)
|
|
40
|
+
|
|
41
|
+
h[key] # read; an unshareable value comes back as a copy
|
|
42
|
+
h.fetch(key) # KeyError when missing; also fetch(key, default) and fetch(key) { }
|
|
43
|
+
h.key?(key)
|
|
44
|
+
h.keys / h.to_h # a copy of the whole thing
|
|
45
|
+
h.inspect
|
|
46
|
+
|
|
47
|
+
h.set(key, value) # send a write; returns nil
|
|
48
|
+
h.increment(key, by = 1) # send an add; returns nil
|
|
49
|
+
|
|
50
|
+
h.async_call {|h, *args| ... } # send it, do not wait; returns nil
|
|
51
|
+
h.call {|h, *args| ... } # send it and wait; returns what the block returned
|
|
52
|
+
h.future_call {|h, *args| ... } # send it, get a Future straight away
|
|
53
|
+
|
|
54
|
+
h.sync_send(:name, *args) # the same three, by method name
|
|
55
|
+
h.async_send(:name, *args)
|
|
56
|
+
h.future_send(:name, *args)
|
|
57
|
+
h.active_object_class # Ractor::ActorHash
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
There is no `size` and no `empty?`, for the reason
|
|
61
|
+
[`LockHash`](lockhash.md) has none: a count is stale by the time it reaches you.
|
|
62
|
+
|
|
63
|
+
**What comes back is subject to the ordinary Ractor rules**, not always a copy: a
|
|
64
|
+
shareable value crosses by reference, an unshareable but copyable one is copied,
|
|
65
|
+
and one that cannot be moved or copied raises
|
|
66
|
+
`Ractor::ActiveObject::Error`. The practical point stands, which is that mutating
|
|
67
|
+
what you got back does not reach the owner's hash.
|
|
68
|
+
|
|
69
|
+
There is no `h[key] = value`. A change is a message you send, and an assignment
|
|
70
|
+
does not look like one; more to the point, having it invites the two round trips
|
|
71
|
+
with a gap in the middle:
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
h[:n] = h[:n] + 1 # not available, and it was a lost update
|
|
75
|
+
h.increment(:n) # one message, and you do not wait for it
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`set` and `increment` are not shorthand for the block forms. **Their arguments
|
|
79
|
+
travel as arguments**, so they are not held to what an isolated block may capture:
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
key = :a
|
|
83
|
+
key = :b # a local assigned more than once
|
|
84
|
+
h.async_call {|h| h[key] = 1 } # => Ractor::IsolationError
|
|
85
|
+
h.set(key, 1) # fine
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
For anything else, such as deleting, clearing, or changing a value in place, send
|
|
89
|
+
a block.
|
|
90
|
+
|
|
91
|
+
`async` and `future` mean what they mean for
|
|
92
|
+
[`Ractor::ActiveObject`](active_object.md), which this is built on: an exception
|
|
93
|
+
in an `async_call` reaches `#on_async_exception` on the owner, and one in a
|
|
94
|
+
`future_call` is raised by `Future#value`. Calls from one Ractor arrive in
|
|
95
|
+
order, and the owner runs them one at a time, so an `async_call` has landed by
|
|
96
|
+
the time the next call is answered.
|
|
97
|
+
|
|
98
|
+
Prefer `async_call`. A change you do not need an answer to costs you nothing to
|
|
99
|
+
send, where waiting costs the round trip.
|
|
100
|
+
|
|
101
|
+
### What comes back is a copy
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
h.async_call {|h| h[:list] = [1] }
|
|
105
|
+
got = h[:list]
|
|
106
|
+
got << 2 # changes the copy, not the Hash
|
|
107
|
+
h[:list] #=> [1]
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
That is the same bargain ETS makes, and it is what lets the values be mutable at
|
|
111
|
+
all. To change one, change it where it lives.
|
|
112
|
+
|
|
113
|
+
The block is handed the **real Hash**, not a copy and not a proxy, so every Hash
|
|
114
|
+
method is there. It cannot escape either, since the block runs on the owner and
|
|
115
|
+
anything returned is copied on the way back.
|
|
116
|
+
|
|
117
|
+
### The block is isolated
|
|
118
|
+
|
|
119
|
+
It crosses to another Ractor, so `Ractor.shareable_proc` has to accept it: it may
|
|
120
|
+
read outer variables that are **never reassigned** anywhere in their scope, and
|
|
121
|
+
everything else has to be passed as an argument.
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
h.set(:total, 0)
|
|
125
|
+
n = 10
|
|
126
|
+
h.async_call {|h| h[:total] += n } # fine: n is never reassigned
|
|
127
|
+
h.async_call(10) {|h, n| h[:total] += n } # always fine
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
"Never reassigned" is judged from the whole enclosing scope: one later assignment
|
|
131
|
+
to `n` makes every block that reads it impossible to isolate.
|
|
132
|
+
|
|
133
|
+
## Where it sits
|
|
134
|
+
|
|
135
|
+
| | shareable values, no Ractor | any values, a Ractor of its own |
|
|
136
|
+
|---|---|---|
|
|
137
|
+
| one value | [`LockVar`](lockvar.md) | |
|
|
138
|
+
| a hash | [`LockHash`](lockhash.md) | **`ActorHash`** |
|
|
139
|
+
| your own class | | [`ActiveObject`](active_object.md) |
|
|
140
|
+
|
|
141
|
+
`ActorHash` is an `ActiveObject` with the interface already chosen, and is built
|
|
142
|
+
on one. Use `ActiveObject` directly when the state deserves methods of its own.
|
|
143
|
+
|
|
144
|
+
Part of [ractor-sharing](../README.md).
|