ractor-sharing 0.2.0 → 0.3.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 +4 -4
- data/README.md +204 -36
- data/docs/keylockhash.md +141 -0
- data/docs/lockhash.md +39 -15
- data/docs/lockvar.md +26 -23
- data/docs/tvar.md +53 -10
- data/examples/01_bank_transfer.rb +43 -0
- data/examples/02_seat_booking.rb +40 -0
- data/examples/03_feature_flags.rb +30 -0
- data/examples/04_progress.rb +33 -0
- data/examples/05_exactly_once.rb +38 -0
- data/examples/06_metrics_board.rb +47 -0
- data/examples/07_word_count.rb +36 -0
- data/examples/08_lru_cache.rb +59 -0
- data/examples/09_audit_log.rb +31 -0
- data/examples/10_price_quotes.rb +36 -0
- data/examples/11_webshop.rb +104 -0
- data/examples/12_kvstore_wal.rb +84 -0
- data/examples/13_buffered_logger.rb +85 -0
- data/examples/14_api_gateway.rb +180 -0
- data/examples/15_cache_backend.rb +53 -0
- data/examples/16_session_store.rb +74 -0
- data/examples/README.md +35 -0
- data/ext/ractor/lock/keylockhash.c +445 -0
- data/ext/ractor/lock/lock.c +35 -0
- data/ext/ractor/lock/lock.h +10 -0
- data/ext/ractor/lock/lockhash.c +4 -2
- data/ext/ractor/lock/lockvar.c +4 -25
- data/ext/ractor/tvar/tvar.c +17 -13
- data/lib/ractor/keylockhash.rb +3 -0
- data/lib/ractor/sharing/version.rb +1 -1
- data/lib/ractor/sharing.rb +3 -0
- metadata +21 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fdc8c45349210436be7413d61943f09d01e2a24cf74a2b460a5359f99f3523fb
|
|
4
|
+
data.tar.gz: be41113b5915e787e11e7a93d3f53ee56a264bfcb792f53bab865783aace9c98
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d17a6abcabe3c2d2e0358189d5d3ff34700264096c0a8ac9f189523d79c02086edbe14dab7a54099dcb3441d15d18ffa0b8f47776d5363dd9714dc974da61715
|
|
7
|
+
data.tar.gz: 161edc3cd67b07f34d5e696370c83c5a2056306de4d60672391def61f0fa6f778d1c82fe76e7acba36b91a3875c73160b6ef1fb6b0b742669584ccc9e1b9b489
|
data/README.md
CHANGED
|
@@ -4,18 +4,21 @@ Ways for Ractors to share mutable state.
|
|
|
4
4
|
|
|
5
5
|
Ractors keep their objects to themselves. What crosses between them is either
|
|
6
6
|
frozen or copied, so there is nowhere to put a counter, a registry or a cache
|
|
7
|
-
that
|
|
7
|
+
that multiple Ractors can both read and update. Each class here is such a place.
|
|
8
8
|
|
|
9
9
|
What each one is, in a line:
|
|
10
10
|
|
|
11
11
|
* **[`Ractor::TVar`](docs/tvar.md)** is a *transactional* variable. Read and
|
|
12
|
-
write
|
|
13
|
-
that block
|
|
14
|
-
race is rolled back and run again.
|
|
12
|
+
write as many of them as you like inside one `Ractor.atomically` block, and
|
|
13
|
+
all changes made by that block take effect together or not at all. A block
|
|
14
|
+
that loses a race is rolled back and run again.
|
|
15
15
|
* **[`Ractor::LockVar`](docs/lockvar.md)** is a variable behind a *lock*. An
|
|
16
16
|
update waits for its turn, and then its block runs exactly once.
|
|
17
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
|
|
18
|
+
`synchronize` section is atomic across the keys of that hash, and only that hash.
|
|
19
|
+
* **[`Ractor::KeyLockHash`](docs/keylockhash.md)** is a hash with one lock per
|
|
20
|
+
key: the row lock to LockHash's table lock. Updates to unrelated keys run in
|
|
21
|
+
parallel, and nothing is atomic across two keys.
|
|
19
22
|
* **[`Ractor::ActiveObject`](docs/active_object.md)** is an object that lives in
|
|
20
23
|
a Ractor of its own. It never leaves; callers send it method calls, and the
|
|
21
24
|
owner runs them one at a time.
|
|
@@ -23,14 +26,15 @@ What each one is, in a line:
|
|
|
23
26
|
of its own. Callers send it blocks to run on it.
|
|
24
27
|
|
|
25
28
|
**Start with `Ractor::TVar`.** It takes one variable or several, it cannot
|
|
26
|
-
deadlock, and it is the quickest of these
|
|
27
|
-
|
|
29
|
+
deadlock, and it is the quickest of these under contention. Choose another
|
|
30
|
+
abstraction only when one of the reasons below applies.
|
|
28
31
|
|
|
29
32
|
| | reach for it when | read | write |
|
|
30
33
|
|---|---|---:|---:|
|
|
31
34
|
| [`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
35
|
| [`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 }` |
|
|
36
|
+
| [`Ractor::LockHash`](docs/lockhash.md)<br>`h.synchronize {\|h\| h[k] = v }` | two or more keys must change together, or a snapshot must be consistent | 132 ns | 433 ns |
|
|
37
|
+
| [`Ractor::KeyLockHash`](docs/keylockhash.md)<br>`m.update(k) {\|v\| v + 1 }` | the keys are independent: registries, caches, buckets, idempotency claims. Parallel across keys | 137 ns | 372 ns |
|
|
34
38
|
| [`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
39
|
| [`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
40
|
|
|
@@ -40,12 +44,13 @@ process ends; their write is the figure for a `sync` call, and drops to 1.6 µs
|
|
|
40
44
|
and 1.9 µs when sent without waiting for the reply (`async def`, `async_call`).
|
|
41
45
|
Contended, the order changes: see [Performance](#performance).
|
|
42
46
|
|
|
43
|
-
The first
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
The first four hold **shareable** values -- made shareable for you on the way
|
|
48
|
+
in, so no `.freeze` and no `Ractor.make_shareable` of your own -- and a change
|
|
49
|
+
replaces a value rather than modifying it: `lv.update { it.merge(k => v) }`. When your state is a mutable
|
|
50
|
+
object you intend to keep mutating, such as a Hash you keep writing into or an
|
|
51
|
+
object graph with methods over it, storing it there would freeze it. The last
|
|
52
|
+
two are for exactly that: the object stays mutable and unshareable, in a Ractor
|
|
53
|
+
of its own, and you send it the calls instead of the data.
|
|
49
54
|
|
|
50
55
|
```ruby
|
|
51
56
|
require "ractor/sharing" # all of them
|
|
@@ -62,14 +67,47 @@ require "ractor/actor_hash"
|
|
|
62
67
|
**The default: `TVar`.** One variable or a dozen, and the same code either way:
|
|
63
68
|
whatever a transaction changes, the rest of the program sees all of it or none of
|
|
64
69
|
it. There is no lock to take in the right order, so two transactions can never
|
|
65
|
-
deadlock, and
|
|
70
|
+
deadlock, and under genuine contention it is the quickest thing
|
|
66
71
|
here, because losing a race and retrying beats parking a thread.
|
|
67
72
|
|
|
68
73
|
```ruby
|
|
69
|
-
|
|
70
|
-
|
|
74
|
+
class Service
|
|
75
|
+
def initialize
|
|
76
|
+
@mode = Ractor::TVar.new(:maintenance)
|
|
77
|
+
@notice = Ractor::TVar.new("closed for maintenance")
|
|
78
|
+
Ractor.make_shareable(self) # the TVars already are; this seals the shell
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def open! = Ractor.atomically { @mode.value = :open; @notice.value = "welcome!" }
|
|
82
|
+
def status = Ractor.atomically { [@mode.value, @notice.value] } # one snapshot
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
SERVICE = Service.new # shareable, so a constant every Ractor can use
|
|
86
|
+
|
|
87
|
+
watchers = 4.times.map do
|
|
88
|
+
Ractor.new do
|
|
89
|
+
mixed = 0
|
|
90
|
+
10_000.times do
|
|
91
|
+
state, text = SERVICE.status
|
|
92
|
+
mixed += 1 if (state == :open) != (text == "welcome!")
|
|
93
|
+
end
|
|
94
|
+
mixed
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
SERVICE.open!
|
|
99
|
+
|
|
100
|
+
watchers.sum(&:value) #=> 0
|
|
71
101
|
```
|
|
72
102
|
|
|
103
|
+
Forty thousand snapshots taken across the flip, and not one of them caught the
|
|
104
|
+
mode and the notice disagreeing. And look at the class: an ordinary one that
|
|
105
|
+
makes itself shareable in its own `initialize`, so the instance lives in a
|
|
106
|
+
constant and every Ractor just uses it -- the TVars are where the change
|
|
107
|
+
lives. Its methods run in whichever Ractor calls them: no owner, no message,
|
|
108
|
+
no round trip. That is the pattern to reach for before `ActiveObject` below,
|
|
109
|
+
which exists for state that cannot be frozen at all.
|
|
110
|
+
|
|
73
111
|
The one thing to hold on to: a transaction that loses a race is **rolled back and
|
|
74
112
|
run again**, so its block has to be safe to run twice. Keep it to reading and
|
|
75
113
|
writing TVars.
|
|
@@ -90,22 +128,91 @@ waiting for a turn beats retrying. One shareable value, and the
|
|
|
90
128
|
block runs once by construction.
|
|
91
129
|
|
|
92
130
|
```ruby
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
131
|
+
lock = Ractor::LockVar.new(nil)
|
|
132
|
+
|
|
133
|
+
claims = %w[ann ben cho dee].map do |name|
|
|
134
|
+
Ractor.new(lock, name) do |lv, me|
|
|
135
|
+
won = false
|
|
136
|
+
lv.update {|holder| holder || (won = true; me) } # announce here: it runs once
|
|
137
|
+
won
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
claims.map(&:value).count(true) #=> 1
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Four Ractors race to take the deploy lock; each block runs exactly once, so
|
|
145
|
+
exactly one of them believes it won -- with a `TVar` the losing blocks would
|
|
146
|
+
have run again, and a side effect in them with it.
|
|
147
|
+
|
|
148
|
+
**Keys that change together: `LockHash`.** The everyday shape is a hash that
|
|
149
|
+
holds an index into itself: a session store maps each token to its user *and*
|
|
150
|
+
each user to their tokens, because "log out everywhere" needs the list. Login
|
|
151
|
+
writes two keys; revocation deletes many. Each is one `synchronize`, because
|
|
152
|
+
the gap is a security hole: revoked one by one, a racing request still
|
|
153
|
+
authenticates with a not-yet-deleted token.
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
sessions = Ractor::LockHash.new
|
|
157
|
+
|
|
158
|
+
logins = %w[ann ben].flat_map do |user|
|
|
159
|
+
2.times.map do |device|
|
|
160
|
+
Ractor.new(sessions, user, "sid-#{user}-#{device}") do |s, u, sid|
|
|
161
|
+
s.synchronize {|h| h[sid] = u; h[u] = (h[u] || []) + [sid] }
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
logins.each(&:join)
|
|
166
|
+
|
|
167
|
+
sessions.synchronize do |h| # ann logs out everywhere
|
|
168
|
+
(h["ann"] || []).each {|sid| h.delete(sid) }
|
|
169
|
+
h.delete("ann")
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
sessions["sid-ann-0"] #=> nil
|
|
173
|
+
sessions["sid-ben-1"] #=> "ben"
|
|
96
174
|
```
|
|
97
175
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
176
|
+
Reads need no ceremony; writes go inside `synchronize`, and everything one
|
|
177
|
+
section changes appears at once -- atomic across its own keys, and only that
|
|
178
|
+
hash, with `to_h` a snapshot no write can tear.
|
|
179
|
+
|
|
180
|
+
**Independent keys, in parallel: `KeyLockHash`.** A registry, a cache, a
|
|
181
|
+
scoreboard where each worker owns its row: hashes whose keys never change
|
|
182
|
+
together. There the whole-hash lock above is paying for atomicity nobody asked
|
|
183
|
+
for, with unrelated clients waiting in one queue. `KeyLockHash` locks per key,
|
|
184
|
+
and the everyday shape is a get-or-create cache:
|
|
102
185
|
|
|
103
186
|
```ruby
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
187
|
+
cache = Ractor::KeyLockHash.new
|
|
188
|
+
|
|
189
|
+
readers = 4.times.map do
|
|
190
|
+
Ractor.new(cache) do |c|
|
|
191
|
+
renders = 0
|
|
192
|
+
render = ->(page) { renders += 1; "<p>page #{page}</p>" } # the expensive part
|
|
193
|
+
|
|
194
|
+
100.times do |i|
|
|
195
|
+
page = i % 10
|
|
196
|
+
c.update("page-#{page}") {|html| html || render.(page) }
|
|
197
|
+
end
|
|
198
|
+
renders
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
readers.sum(&:value) #=> 10
|
|
107
203
|
```
|
|
108
204
|
|
|
205
|
+
Four hundred fetches over ten pages, and the render ran exactly ten times.
|
|
206
|
+
`update` holds that key's lock while the block runs, so simultaneous misses on
|
|
207
|
+
one page wait for the first -- and waiting is correct here, because everyone
|
|
208
|
+
waiting would have rendered the same page themselves: the system does the work
|
|
209
|
+
once instead of eight times, and a miss on a *different* page never queues at
|
|
210
|
+
all. (That is the one shape where a long block is the right trade; the rule
|
|
211
|
+
and its price are in [the docs](docs/keylockhash.md).) A per-client tally is one
|
|
212
|
+
`hits.increment(client)`, and check-and-claim is `update` with `:claimed` in
|
|
213
|
+
it.
|
|
214
|
+
|
|
215
|
+
|
|
109
216
|
**A mutable object: `ActiveObject`.** When freezing the state is not on the
|
|
110
217
|
table, give the object a Ractor of its own. It never leaves; callers send method
|
|
111
218
|
calls in, the owner runs them one at a time, and the object goes on being an
|
|
@@ -124,11 +231,21 @@ shareable value, one of the first three will cost you far less.
|
|
|
124
231
|
```ruby
|
|
125
232
|
class People < Ractor::ActiveObject
|
|
126
233
|
def initialize = @db = {}
|
|
127
|
-
async def add(name, age) = @db[name] = age
|
|
128
|
-
sync def find(name) = @db[name]
|
|
234
|
+
async def add(name, age) = @db[name] = age # fire and forget
|
|
235
|
+
sync def find(name) = @db[name] # a question: waits for the answer
|
|
129
236
|
end
|
|
237
|
+
|
|
238
|
+
people = People.new
|
|
239
|
+
people.add("ada", 36)
|
|
240
|
+
people.add("lin", 28)
|
|
241
|
+
|
|
242
|
+
people.find("ada") #=> 36
|
|
243
|
+
Ractor.new(people) {|p| p.find("lin") }.value #=> 28
|
|
130
244
|
```
|
|
131
245
|
|
|
246
|
+
`People.new` returns a shareable proxy: hand it to any Ractor and the calls
|
|
247
|
+
all funnel to the one owner, where `@db` stays an ordinary mutable Hash.
|
|
248
|
+
|
|
132
249
|
**A hash whose values will not be frozen: `ActorHash`.** Same shape as
|
|
133
250
|
`LockHash`, but the entries live in a Ractor of its own, so they can be anything
|
|
134
251
|
and a block changes them in place over there. Reads are questions you ask;
|
|
@@ -136,16 +253,53 @@ changes are work you send, and you need not wait for them.
|
|
|
136
253
|
|
|
137
254
|
```ruby
|
|
138
255
|
h = Ractor::ActorHash.new
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
h
|
|
256
|
+
|
|
257
|
+
loggers = 3.times.map do |i|
|
|
258
|
+
Ractor.new(h, i) do |hash, id|
|
|
259
|
+
hash.increment(:hits)
|
|
260
|
+
hash.async_call(id) {|x, me| (x[:log] ||= []) << me } # mutated in place, over there
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
loggers.each(&:join)
|
|
264
|
+
|
|
265
|
+
h[:hits] #=> 3
|
|
266
|
+
h.call {|x| x[:log].sort } #=> [0, 1, 2]
|
|
142
267
|
```
|
|
143
268
|
|
|
269
|
+
The log is a plain mutable Array that never leaves the owner; the blocks go to
|
|
270
|
+
it, not the other way around.
|
|
271
|
+
|
|
144
272
|
Two signs you picked the wrong one. Reaching for two `LockVar`s at once is
|
|
145
273
|
refused, with a message pointing here: that is the sign you wanted a `TVar`.
|
|
146
274
|
Finding yourself freezing a copy of a collection on every update is the sign you
|
|
147
275
|
wanted an `ActiveObject`.
|
|
148
276
|
|
|
277
|
+
### In database terms
|
|
278
|
+
|
|
279
|
+
If you think in database vocabulary, the first four unbundle what a database
|
|
280
|
+
ships as one engine:
|
|
281
|
+
|
|
282
|
+
| in a database | here |
|
|
283
|
+
|---|---|
|
|
284
|
+
| an MVCC read, taking no lock | `TVar#value` outside a transaction |
|
|
285
|
+
| a serializable transaction, retried on conflict | `Ractor.atomically` |
|
|
286
|
+
| a row lock | `KeyLockHash` -- sold separately: no transaction spans two of them |
|
|
287
|
+
| a table lock | `LockHash#synchronize` |
|
|
288
|
+
| the deadlock detector | not shipped: a second lock raises `Ractor::NestedLockError` at the door |
|
|
289
|
+
|
|
290
|
+
Databases can default to row locks because a transaction manager acquires many
|
|
291
|
+
of them and a deadlock detector cleans up when that cycles. There is no
|
|
292
|
+
detector here, so the second lock is refused instead, and work that spans keys
|
|
293
|
+
goes to the table lock or to the transactions.
|
|
294
|
+
|
|
295
|
+
One thing no detector catches, there or here: taking too **few** locks. A
|
|
296
|
+
deadlock is a cycle in who-waits-for-whom; locking one key, releasing it and
|
|
297
|
+
then locking another produces no wait and no cycle, just an invariant quietly
|
|
298
|
+
broken -- databases only catch that shape at serializable, because a declared
|
|
299
|
+
transaction tells them what was supposed to be atomic. The declaration is the
|
|
300
|
+
protection: keys that must agree go inside one `synchronize` or one
|
|
301
|
+
`Ractor.atomically`, and no runtime is going to notice for you.
|
|
302
|
+
|
|
149
303
|
|
|
150
304
|
## Performance
|
|
151
305
|
|
|
@@ -160,6 +314,7 @@ operation, counted across all Ractors, on 16 cores.
|
|
|
160
314
|
| `TVar#value` | 68 | **9** | 9 |
|
|
161
315
|
| `LockVar#value` | 74 | 365 | 10 |
|
|
162
316
|
| `LockHash#[]` | 132 | 489 | 23 |
|
|
317
|
+
| `KeyLockHash#[]` | 137 | 478 | 18 |
|
|
163
318
|
| `ActiveObject` sync method | 2300 | 1380 | 742 |
|
|
164
319
|
| `ActorHash#[]` | 2179 | 1451 | 730 |
|
|
165
320
|
| no sharing at all | 78 | n/a | 9 |
|
|
@@ -178,13 +333,14 @@ machine's limit.
|
|
|
178
333
|
| `TVar` transaction | 351 | **509** | 108 |
|
|
179
334
|
| `LockVar#update` | 352 | 1102 | **50** |
|
|
180
335
|
| `LockHash#synchronize` | 433 | 1238 | 58 |
|
|
336
|
+
| `KeyLockHash#update` | 372 | 1173 | 52 |
|
|
181
337
|
| `ActiveObject` async method | 1555 | 839 | 179 |
|
|
182
338
|
| `ActiveObject` sync method | 2789 | 1587 | 760 |
|
|
183
339
|
| `ActorHash#async_call` | 1919 | 1032 | 219 |
|
|
184
340
|
| `ActorHash#call` | 3166 | 1740 | 747 |
|
|
185
341
|
| no sharing at all | 117 | n/a | 18 |
|
|
186
342
|
|
|
187
|
-
**
|
|
343
|
+
**Under contention, nothing scales and `TVar` stays about 2× ahead**, because losing a
|
|
188
344
|
race and running a short block again is cheaper than parking a thread and waking
|
|
189
345
|
it, and a transaction that keeps losing backs off, about 100 ns per consecutive
|
|
190
346
|
loss, spinning rather than sleeping, before running again. That cell is the
|
|
@@ -198,6 +354,12 @@ Ractor (16 on their own, sync against async above); on one shared object the
|
|
|
198
354
|
serialisation at the owner leaves it under 2×, and from a single caller it is
|
|
199
355
|
about 1.7×.
|
|
200
356
|
|
|
357
|
+
Neither of these two conditions shows what `KeyLockHash` is for: on one shared
|
|
358
|
+
key it is the same lock as everyone else, and separate maps share nothing. Its
|
|
359
|
+
condition is **one shared map with a key per Ractor**, where the table lock
|
|
360
|
+
pays for every neighbour and the key lock does not: 123 ns against `LockHash`'s
|
|
361
|
+
1212 at four Ractors, 248 against 1312 at sixteen.
|
|
362
|
+
|
|
201
363
|
The `no sharing at all` row is the machine's own ceiling: about 8× is as far as
|
|
202
364
|
anything here scales. Called from the main Ractor rather than a worker, the two
|
|
203
365
|
Ractor backed classes cost about 8.9 µs instead of 2.6, because that thread has a
|
|
@@ -211,8 +373,13 @@ that path rather than the class. It gets a table of its own:
|
|
|
211
373
|
|
|
212
374
|
| | one Ractor (ns) | 16 on one object (ns) | 16 on their own (ns) |
|
|
213
375
|
|---|---:|---:|---:|
|
|
214
|
-
| `LockVar#increment` |
|
|
215
|
-
| `TVar#increment` |
|
|
376
|
+
| `LockVar#increment` | 80 | 348 | **9** |
|
|
377
|
+
| `TVar#increment` | 76 | **159** | 75 |
|
|
378
|
+
| `KeyLockHash#increment` | 144 | 518 | 16 |
|
|
379
|
+
|
|
380
|
+
One hot counter belongs in a `LockVar` (or a `TVar`, contended); counters that
|
|
381
|
+
spread over keys belong in the `KeyLockHash`, which pays the hash and the guard
|
|
382
|
+
per call and scales across its keys like everything else it does.
|
|
216
383
|
|
|
217
384
|
`benchmark/family.rb` produces all of these, sweeping 1, 2, 4, 8 and 16 Ractors
|
|
218
385
|
over read, write and a 9:1 mix, under both conditions. Every worker reports ready
|
|
@@ -254,7 +421,8 @@ Ruby 4.0 or later (`Ractor::Port`, and Ractors that are worth using).
|
|
|
254
421
|
rake # compile both extensions and run every test
|
|
255
422
|
```
|
|
256
423
|
|
|
257
|
-
Documentation for each class is in [docs/](docs/)
|
|
424
|
+
Documentation for each class is in [docs/](docs/), and ten runnable,
|
|
425
|
+
self-checking examples are in [examples/](examples/).
|
|
258
426
|
|
|
259
427
|
## License
|
|
260
428
|
|
data/docs/keylockhash.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# Ractor::KeyLockHash
|
|
2
|
+
|
|
3
|
+
A hash with one lock per key. In database terms, the row lock to
|
|
4
|
+
[`Ractor::LockHash`](lockhash.md)'s table lock:
|
|
5
|
+
|
|
6
|
+
| | atomic unit | unrelated keys | two keys together |
|
|
7
|
+
|---|---|---|---|
|
|
8
|
+
| `LockHash` | the whole hash | wait for each other | atomic, its whole point |
|
|
9
|
+
| `KeyLockHash` | one key | run in parallel | **never atomic** here |
|
|
10
|
+
|
|
11
|
+
Reach for it when the keys are independent of one another and appear at
|
|
12
|
+
runtime: token buckets per client, a get-or-create cache, idempotency keys,
|
|
13
|
+
anything shaped "look up my key, change my key, never two at once".
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
require "ractor/keylockhash"
|
|
17
|
+
|
|
18
|
+
buckets = Ractor::KeyLockHash.new
|
|
19
|
+
|
|
20
|
+
buckets[:quota] = 100 # a plain write: no ceremony needed
|
|
21
|
+
buckets.update(:quota) { |v| v - 1 } # read-modify-write, atomic per key
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`update` exists for one reason: computing the new value from the old one. It
|
|
25
|
+
yields the current value (nil for a missing key) and stores what the block
|
|
26
|
+
returns, all under one hold of that key's lock, and the block runs exactly
|
|
27
|
+
once. Being told nil is being told you created the key, so put-if-absent needs
|
|
28
|
+
nothing more:
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
jobs = Ractor::KeyLockHash.new
|
|
32
|
+
req_id = "order-1701" # a bare String key is dup'd and frozen for you, like Hash's
|
|
33
|
+
mine = false
|
|
34
|
+
jobs.update(req_id) { |v| v ? v : (mine = true; :claimed) }
|
|
35
|
+
mine #=> true
|
|
36
|
+
jobs.update(req_id) { |v| v ? v : (mine = :again; :claimed) }
|
|
37
|
+
mine #=> true
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## API
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
m = Ractor::KeyLockHash.new(initial = nil)
|
|
44
|
+
|
|
45
|
+
m[key] # read, under that key's lock
|
|
46
|
+
m.fetch(key, default) # the usual default / block / KeyError; both run unlocked
|
|
47
|
+
m.key?(key)
|
|
48
|
+
m.keys / m.to_h # a copy; consistent per key, NOT a whole-map snapshot
|
|
49
|
+
m.inspect
|
|
50
|
+
|
|
51
|
+
m[key] = value # write, under that key's lock
|
|
52
|
+
m.update(key) {|v| new_v } # read-modify-write under one hold; v is nil if absent
|
|
53
|
+
m.increment(key, by = 1) # update with the block written for you; missing counts as 0
|
|
54
|
+
m.delete(key) # returns the old value, or nil
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Values are **made shareable on the way in**, so neither `.freeze` nor
|
|
58
|
+
`Ractor.make_shareable` is yours to write (deep-frozen in place; a value
|
|
59
|
+
that cannot be raises `Ractor::IsolationError`). Keys must be shareable
|
|
60
|
+
already, `ArgumentError` otherwise, with one courtesy borrowed from `Hash`
|
|
61
|
+
itself: a bare String key is stored as a frozen copy, and yours stays yours.
|
|
62
|
+
The map itself is frozen and shareable, so it can be passed to any Ractor.
|
|
63
|
+
|
|
64
|
+
## One key at a time
|
|
65
|
+
|
|
66
|
+
Touching any second lock inside `update` raises `Ractor::NestedLockError`,
|
|
67
|
+
another key of the same map included: the shape "lock A, then grab B" is where
|
|
68
|
+
deadlocks come from, and this class refuses it at the door. Two keys that must
|
|
69
|
+
change together are [`Ractor::LockHash`](lockhash.md)'s job; several objects
|
|
70
|
+
changing together are [`Ractor::TVar`](tvar.md)'s.
|
|
71
|
+
|
|
72
|
+
For the same reason, `keys` and `to_h` are not a snapshot of the whole map at
|
|
73
|
+
one moment: they visit the keys' locks one at a time. A consistent cross-key
|
|
74
|
+
snapshot is again LockHash territory.
|
|
75
|
+
|
|
76
|
+
## How it is built
|
|
77
|
+
|
|
78
|
+
Lock striping: keys hash onto 64 shards, each a table behind a
|
|
79
|
+
[lock of its own](lockvar.md#implementation-notes), so two keys occasionally
|
|
80
|
+
share one. Databases would call the finer design a bucket **latch** plus a row
|
|
81
|
+
**lock**; if shard collisions ever show up in a profile, that is the upgrade
|
|
82
|
+
path, and the API would not change. One thing databases bundle with their row
|
|
83
|
+
locks stays unbundled here: a transaction manager. They can let one
|
|
84
|
+
transaction take many row locks because a deadlock detector cleans up the
|
|
85
|
+
cycles; this class refuses the second lock at the door instead.
|
|
86
|
+
|
|
87
|
+
A key's own `#hash` or `#eql?` that reaches back into the same map raises
|
|
88
|
+
`NestedLockError` rather than deadlocking or being let in: the inner call may
|
|
89
|
+
want a shard this thread does not hold.
|
|
90
|
+
|
|
91
|
+
## Performance
|
|
92
|
+
|
|
93
|
+
The number this class exists for, one **shared** map with a key per Ractor,
|
|
94
|
+
updates only (median of three, ns per completed update across all Ractors):
|
|
95
|
+
|
|
96
|
+
| Ractors | `KeyLockHash#update`, own key (ns) | `LockHash`, own key (ns) |
|
|
97
|
+
|---:|---:|---:|
|
|
98
|
+
| 1 | 451 | 476 |
|
|
99
|
+
| 4 | **123** | 1212 |
|
|
100
|
+
| 16 | **248** | 1312 |
|
|
101
|
+
|
|
102
|
+
The table lock pays for every neighbour; the key lock does not. (Sixteen keys
|
|
103
|
+
on 64 shards collide now and then, which is why 16 sits above 4.)
|
|
104
|
+
|
|
105
|
+
Everywhere else it costs what `LockHash` costs: an uncontended update is
|
|
106
|
+
372 ns, a read 137 ns, and sixteen Ractors fighting over one *single* key are
|
|
107
|
+
one lock's queue again, 1173 ns per update. `increment` is 144 ns uncontended
|
|
108
|
+
and 16 ns per op with a key per Ractor at sixteen -- same shape, block written
|
|
109
|
+
for you. Measured on 16 cores, governor
|
|
110
|
+
`performance`, ruby 4.1.0dev; `benchmark/family.rb` and its `ownkey`
|
|
111
|
+
companion in the trials record produce these.
|
|
112
|
+
|
|
113
|
+
## Keep the block short
|
|
114
|
+
|
|
115
|
+
The family rule applies unchanged: the block holds that key's lock, so every
|
|
116
|
+
other user of *that key* waits for it. Compute the new value and nothing more.
|
|
117
|
+
|
|
118
|
+
The one deliberate exception is get-or-create caching, where holding the lock
|
|
119
|
+
through the computation is the point -- it is what stops eight simultaneous
|
|
120
|
+
misses computing eight times. The arithmetic is what justifies it: everyone
|
|
121
|
+
waiting would have run the *identical* computation themselves, so each waits
|
|
122
|
+
at most what it would have burned, and the system does the work once instead
|
|
123
|
+
of eight times. That argument covers exactly this case and no other -- it is
|
|
124
|
+
not licence for unrelated slow work under the lock -- and it assumes the
|
|
125
|
+
computation reliably finishes: one render stuck on the network wedges its key,
|
|
126
|
+
and, keys being striped over 64 shards, the occasional innocent neighbour.
|
|
127
|
+
See [examples/15_cache_backend.rb](../examples/15_cache_backend.rb) for the
|
|
128
|
+
trade priced and tested.
|
|
129
|
+
|
|
130
|
+
## When something else fits better
|
|
131
|
+
|
|
132
|
+
* Two keys that change together, or a consistent snapshot:
|
|
133
|
+
[`Ractor::LockHash`](lockhash.md).
|
|
134
|
+
* One key so read-hot that even its own lock is a queue: put that value in a
|
|
135
|
+
[`Ractor::TVar`](tvar.md), whose reads take nothing.
|
|
136
|
+
* Values you will not freeze: [`Ractor::ActorHash`](actor_hash.md).
|
|
137
|
+
|
|
138
|
+
A worked cache backend, dog-pile protection included, is
|
|
139
|
+
[examples/15_cache_backend.rb](../examples/15_cache_backend.rb).
|
|
140
|
+
|
|
141
|
+
Part of [ractor-sharing](../README.md).
|
data/docs/lockhash.md
CHANGED
|
@@ -1,31 +1,44 @@
|
|
|
1
1
|
# Ractor::LockHash
|
|
2
2
|
|
|
3
|
-
A Hash that Ractors can share
|
|
4
|
-
through `synchronize`, and whatever one
|
|
5
|
-
all of it or none of it.
|
|
3
|
+
A Hash that Ractors can share, for **keys that change together**. Reads are
|
|
4
|
+
allowed anywhere; every write goes through `synchronize`, and whatever one
|
|
5
|
+
`synchronize` changes, other Ractors see all of it or none of it.
|
|
6
6
|
|
|
7
7
|
```ruby
|
|
8
8
|
require "ractor/lockhash"
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
sessions = Ractor::LockHash.new
|
|
11
|
+
|
|
12
|
+
# login: the token and the user's index move together
|
|
13
|
+
sessions.synchronize do |h|
|
|
14
|
+
h["sid-1"] = "ann"
|
|
15
|
+
h["ann"] = (h["ann"] || []) + ["sid-1"]
|
|
16
|
+
end
|
|
11
17
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
18
|
+
# "log out everywhere": revoke every token AND the index, one section.
|
|
19
|
+
# Revoked one by one, a racing request could still authenticate with a
|
|
20
|
+
# not-yet-deleted token while the account believes it logged out.
|
|
21
|
+
sessions.synchronize do |h|
|
|
22
|
+
(h["ann"] || []).each {|sid| h.delete(sid) }
|
|
23
|
+
h.delete("ann")
|
|
24
|
+
end
|
|
17
25
|
|
|
18
|
-
|
|
26
|
+
sessions.to_h #=> {}
|
|
19
27
|
```
|
|
20
28
|
|
|
29
|
+
That is the shape this class is for: a hash that holds an index into itself,
|
|
30
|
+
where a write is two keys or ten and a reader must never see them halfway.
|
|
31
|
+
Keys that never change together do not need this lock, or its queue: they
|
|
32
|
+
belong in [`Ractor::KeyLockHash`](keylockhash.md).
|
|
33
|
+
|
|
21
34
|
## Why not a LockVar holding a Hash
|
|
22
35
|
|
|
23
36
|
You can put a frozen Hash in a [`Ractor::LockVar`](lockvar.md), but then changing
|
|
24
37
|
one entry copies the whole thing:
|
|
25
38
|
|
|
26
39
|
```ruby
|
|
27
|
-
lv = Ractor::LockVar.new({}
|
|
28
|
-
lv.update { it.merge(k: 1)
|
|
40
|
+
lv = Ractor::LockVar.new({})
|
|
41
|
+
lv.update { it.merge(k: 1) } # O(n) per write
|
|
29
42
|
```
|
|
30
43
|
|
|
31
44
|
`LockHash` keeps a real Hash and writes into it, so one entry costs one entry.
|
|
@@ -54,7 +67,12 @@ released is already stale, and inside a section `keys` says the same thing.
|
|
|
54
67
|
that block **after** the lookup has released the lock, so a default that reads
|
|
55
68
|
this hash again is fine.
|
|
56
69
|
|
|
57
|
-
|
|
70
|
+
Values are **made shareable on the way in**, so neither `.freeze` nor
|
|
71
|
+
`Ractor.make_shareable` is yours to write (deep-frozen in place; a value
|
|
72
|
+
that cannot be raises `Ractor::IsolationError`). Keys must be shareable
|
|
73
|
+
already, `ArgumentError` otherwise, with one courtesy borrowed from `Hash`
|
|
74
|
+
itself: a bare String key is stored as a frozen copy, and yours stays yours.
|
|
75
|
+
The LockHash
|
|
58
76
|
itself is frozen and shareable, so it can be passed to any Ractor. `keys` and
|
|
59
77
|
`to_h` return plain mutable copies, yours to reshape; everything inside them is
|
|
60
78
|
shareable already, so `Ractor.make_shareable(h.to_h)` is all it takes to hand
|
|
@@ -130,8 +148,9 @@ written; the lock is released, nothing more.
|
|
|
130
148
|
|
|
131
149
|
One lock covers the whole hash, so **writes to unrelated keys wait for each
|
|
132
150
|
other**, and a snapshot is O(n). A hash written to constantly is better modelled
|
|
133
|
-
as one [`Ractor::
|
|
134
|
-
|
|
151
|
+
as one lock per key -- [`Ractor::KeyLockHash`](keylockhash.md), the row lock to
|
|
152
|
+
this class's table lock -- which scales with the cores. That is open to you
|
|
153
|
+
whenever you never need two keys to change together.
|
|
135
154
|
|
|
136
155
|
**Reads take the lock too, so they do not scale either.** Sixteen Ractors reading
|
|
137
156
|
one shared LockHash cost 489 ns per read, against 23 ns when each has a hash of
|
|
@@ -150,4 +169,9 @@ The block holds the lock while it runs, so every other reader and writer of this
|
|
|
150
169
|
hash waits for it. Change the entries and nothing else: no IO, no waiting on
|
|
151
170
|
anything, no calling out to code that might.
|
|
152
171
|
|
|
172
|
+
A worked example is a session store with "log out everywhere":
|
|
173
|
+
[examples/16_session_store.rb](../examples/16_session_store.rb). The token and
|
|
174
|
+
the per-user index live in one hash, and revoking them one by one would leave
|
|
175
|
+
a gap where a logged-out token still authenticates.
|
|
176
|
+
|
|
153
177
|
Part of [ractor-sharing](../README.md).
|