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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f667d77a73e96a72c9cbaa28e3d45b7511aa5d3ecafe79d23923c89918d634a6
4
- data.tar.gz: 99ae5d37d366f37c0d81da184a5ff15abe681a56af5cf4383841e9acb4a091e2
3
+ metadata.gz: fdc8c45349210436be7413d61943f09d01e2a24cf74a2b460a5359f99f3523fb
4
+ data.tar.gz: be41113b5915e787e11e7a93d3f53ee56a264bfcb792f53bab865783aace9c98
5
5
  SHA512:
6
- metadata.gz: ace4ea48c92eef46604b3743138609ff081325857138c1c581538aefb28a1636e993f0ce6a80c80796ad66beff047df648a829578767219476c970d233f58b3c
7
- data.tar.gz: db8a6797917215ee087193ed345a75d7e619896a978e6a552ab7e2f85c19f017ccc33278b7e21acc452471b210247765629ef5420cfe34a9f7becc7686085a43
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 several Ractors both read and change. Each class here is such a place.
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 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.
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 those.
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 when a variable is fought over. Move
27
- off it only for a reason the others below name.
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 }` | the same, but the keys are not known in advance | 132 ns | 433 ns |
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 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.
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 when a variable is genuinely fought over it is the quickest thing
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
- from, to = Ractor::TVar.new(100), Ractor::TVar.new(0)
70
- Ractor.atomically { from.value -= 10; to.value += 10 }
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
- counter = Ractor::LockVar.new(0)
94
- 4.times.map { Ractor.new(counter) {|c| 1000.times { c.increment } } }.each(&:join)
95
- counter.value #=> 4000
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
- **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.
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
- board = Ractor::LockHash.new
105
- board.synchronize {|b| b[:worker_1] = 42 }
106
- board.to_h #=> {worker_1: 42}
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
- h.increment(:hits)
140
- h.async_call {|h| (h[:log] ||= []) << "a line" }
141
- h[:log]
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
- **Fought over, nothing scales and `TVar` stays about 2× ahead**, because losing a
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` | 77 | 338 | **8** |
215
- | `TVar#increment` | 89 | **144** | 75 |
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
 
@@ -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. Reads are allowed anywhere; every write goes
4
- through `synchronize`, and whatever one `synchronize` changes, other Ractors see
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
- board = Ractor::LockHash.new
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
- 4.times.map do |i|
13
- Ractor.new(board, i) do |b, id|
14
- 100.times {|n| b.synchronize {|x| x[id] = n } }
15
- end
16
- end.each(&:join)
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
- board.to_h.sort.to_h #=> {0 => 99, 1 => 99, 2 => 99, 3 => 99} # whoever wrote first is first
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({}.freeze)
28
- lv.update { it.merge(k: 1).freeze } # O(n) per write
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
- Keys and values must be **shareable**; `ArgumentError` otherwise. The LockHash
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::LockVar`](lockvar.md) per key, which scales with the cores.
134
- That is open to you whenever you never need two keys to change together.
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).