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
data/docs/lockhash.md
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# Ractor::LockHash
|
|
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.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
require "ractor/lockhash"
|
|
9
|
+
|
|
10
|
+
board = Ractor::LockHash.new
|
|
11
|
+
|
|
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)
|
|
17
|
+
|
|
18
|
+
board.to_h.sort.to_h #=> {0 => 99, 1 => 99, 2 => 99, 3 => 99} # whoever wrote first is first
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Why not a LockVar holding a Hash
|
|
22
|
+
|
|
23
|
+
You can put a frozen Hash in a [`Ractor::LockVar`](lockvar.md), but then changing
|
|
24
|
+
one entry copies the whole thing:
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
lv = Ractor::LockVar.new({}.freeze)
|
|
28
|
+
lv.update { it.merge(k: 1).freeze } # O(n) per write
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`LockHash` keeps a real Hash and writes into it, so one entry costs one entry.
|
|
32
|
+
|
|
33
|
+
## API
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
h = Ractor::LockHash.new(initial = nil)
|
|
37
|
+
|
|
38
|
+
h[key] # read
|
|
39
|
+
h.fetch(key, default) # read, with the usual default / block / KeyError
|
|
40
|
+
h.key?(key)
|
|
41
|
+
h.keys / h.to_h # a frozen, shareable snapshot of the whole hash
|
|
42
|
+
h.inspect
|
|
43
|
+
|
|
44
|
+
h.synchronize {|h| ... } # the only place writes are allowed; yields the LockHash
|
|
45
|
+
h[key] = value # inside synchronize
|
|
46
|
+
h.delete(key) # inside synchronize
|
|
47
|
+
h.clear # inside synchronize
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
There is no `size` and no `empty?`. A count handed back after the lock is
|
|
51
|
+
released is already stale, and inside a section `keys` says the same thing.
|
|
52
|
+
|
|
53
|
+
`fetch` prefers its block to an explicit default, as `Hash#fetch` does, and runs
|
|
54
|
+
that block **after** the lookup has released the lock, so a default that reads
|
|
55
|
+
this hash again is fine.
|
|
56
|
+
|
|
57
|
+
Keys and values must be **shareable**; `ArgumentError` otherwise. The LockHash
|
|
58
|
+
itself is frozen and shareable, so it can be passed to any Ractor.
|
|
59
|
+
|
|
60
|
+
### Writes only inside `synchronize`
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
h = Ractor::LockHash.new
|
|
64
|
+
h[:a] = 1
|
|
65
|
+
# => NoMethodError: '[]=' is only allowed inside Ractor::LockHash#synchronize
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
It is a `NoMethodError` for the same reason calling a private method is: the
|
|
69
|
+
method is there, but not callable from where you are.
|
|
70
|
+
|
|
71
|
+
Every write being inside a block is what makes reading an entry and writing it
|
|
72
|
+
back one step rather than two:
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
# WRONG: refused, and it was a lost update anyway
|
|
76
|
+
h[:hits] = h[:hits] + 1
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
h = Ractor::LockHash.new(hits: 0)
|
|
81
|
+
h.synchronize {|h| h[:hits] = h[:hits] + 1 } # right
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The block is handed the LockHash, **never the Hash inside it**, so no reference
|
|
85
|
+
to the state can escape and be written to later, or from another Ractor.
|
|
86
|
+
|
|
87
|
+
### The two idioms
|
|
88
|
+
|
|
89
|
+
Almost everything a shared hash gets used for is one of these, and both are one
|
|
90
|
+
`synchronize`:
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
h = Ractor::LockHash.new
|
|
94
|
+
k = :key
|
|
95
|
+
|
|
96
|
+
h.synchronize {|h| h[k] ||= 42 } # memoize: computed once, by one caller
|
|
97
|
+
h.synchronize {|h| h[:n] = (h[:n] || 0) + 1 } # count: read and write in one step
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
There is no `compute` or `fetch_or_store` here. One lock covers the whole hash,
|
|
101
|
+
so a dedicated method for one key would run at exactly the same speed as the
|
|
102
|
+
block above, and only spend a name. Such methods start to mean something when a
|
|
103
|
+
lock can be taken per key, and this one is not. See below.
|
|
104
|
+
|
|
105
|
+
### What one `synchronize` gives you
|
|
106
|
+
|
|
107
|
+
Everything it changes becomes visible together. A reader calling `[]` or `to_h`
|
|
108
|
+
waits for a section in flight rather than looking inside one:
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
board = Ractor::LockHash.new
|
|
112
|
+
i = 1
|
|
113
|
+
board.synchronize {|b| b[:x] = i; b[:y] = i } # a reader never sees x != y
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
That is atomicity **across the keys of this hash**. Across separate objects it is
|
|
117
|
+
not: taking a *different* LockHash, or a LockVar, from inside a `synchronize`
|
|
118
|
+
raises `Ractor::NestedLockError`, because that is where lock-order deadlocks come
|
|
119
|
+
from. Several objects that must change together are [`Ractor::TVar`](tvar.md)'s
|
|
120
|
+
job. Nesting `synchronize` on the *same* hash is allowed, so a section may call a
|
|
121
|
+
method that takes one again.
|
|
122
|
+
|
|
123
|
+
There is no rollback either. A block that raises keeps whatever it had already
|
|
124
|
+
written; the lock is released, nothing more.
|
|
125
|
+
|
|
126
|
+
## The cost of that atomicity
|
|
127
|
+
|
|
128
|
+
One lock covers the whole hash, so **writes to unrelated keys wait for each
|
|
129
|
+
other**, and a snapshot is O(n). A hash written to constantly is better modelled
|
|
130
|
+
as one [`Ractor::LockVar`](lockvar.md) per key, which scales with the cores.
|
|
131
|
+
That is open to you whenever you never need two keys to change together.
|
|
132
|
+
|
|
133
|
+
**Reads take the lock too, so they do not scale either.** Sixteen Ractors reading
|
|
134
|
+
one shared LockHash cost 489 ns per read, against 23 ns when each has a hash of
|
|
135
|
+
its own; a [`Ractor::TVar`](tvar.md) read costs 9 ns whether it is shared or not,
|
|
136
|
+
because it takes no lock. One exclusive lock covers the whole hash, so a reader cannot be let in beside a
|
|
137
|
+
writer the way a TVar's single slot can. If your load is read heavy
|
|
138
|
+
and the hash is shared, that is the number that will decide it.
|
|
139
|
+
|
|
140
|
+
Acquisition is not FIFO: a thread may barge ahead of waiters, so readers hammering
|
|
141
|
+
a hash in a tight loop can starve a writer. Keep sections short, and give busy
|
|
142
|
+
reader loops something else to do between reads.
|
|
143
|
+
|
|
144
|
+
## Keep the block short
|
|
145
|
+
|
|
146
|
+
The block holds the lock while it runs, so every other reader and writer of this
|
|
147
|
+
hash waits for it. Change the entries and nothing else: no IO, no waiting on
|
|
148
|
+
anything, no calling out to code that might.
|
|
149
|
+
|
|
150
|
+
Part of [ractor-sharing](../README.md).
|
data/docs/lockvar.md
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
# Ractor::LockVar
|
|
2
|
+
|
|
3
|
+
**One** variable that Ractors can share. It holds one shareable object; any
|
|
4
|
+
Ractor can read it, and any Ractor can replace what is in it, one at a time.
|
|
5
|
+
Several variables that have to change together are `Ractor::TVar`'s job, and a
|
|
6
|
+
whole hash of them is [`Ractor::LockHash`](lockhash.md)'s.
|
|
7
|
+
|
|
8
|
+
```ruby
|
|
9
|
+
require "ractor/lockvar"
|
|
10
|
+
|
|
11
|
+
counter = Ractor::LockVar.new(0)
|
|
12
|
+
|
|
13
|
+
rs = 4.times.map do
|
|
14
|
+
Ractor.new(counter) do |c|
|
|
15
|
+
1000.times { c.update {|n| n + 1 } }
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
rs.each(&:join)
|
|
19
|
+
|
|
20
|
+
p counter.value #=> 4000
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## API
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
lv = Ractor::LockVar.new(initial = nil)
|
|
27
|
+
|
|
28
|
+
lv.value # read, under the lock -- a snapshot, never an input to update
|
|
29
|
+
lv.update {|v| new_v } # replace the value, under the lock; returns new_v
|
|
30
|
+
lv.increment(n = 1) # add n under the lock, as update {|v| v + n } would
|
|
31
|
+
lv.inspect
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`inspect` does not take the lock, so it never blocks and never raises: call it
|
|
35
|
+
from a debugger, from inside another update, from anywhere. The value it shows
|
|
36
|
+
may be one update out of date, which for a display is the right trade.
|
|
37
|
+
|
|
38
|
+
A variable, not a lock: there is no lock, unlock, or owner query. `value` and
|
|
39
|
+
`update` are the whole of it, and `increment` is there because a counter is what
|
|
40
|
+
a shared variable most often is.
|
|
41
|
+
|
|
42
|
+
### The value must be shareable
|
|
43
|
+
|
|
44
|
+
A LockVar holds one **shareable** object, and so does everything you store into
|
|
45
|
+
it. Anything else raises `ArgumentError`:
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
Ractor::LockVar.new({}) # => ArgumentError: only shareable object are allowed
|
|
49
|
+
lv.update { [1, 2] } # => ArgumentError
|
|
50
|
+
lv.update { [1, 2].freeze } # fine
|
|
51
|
+
lv.update { {a: 1}.freeze } # fine
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
That is what makes a LockVar safe to hand to any Ractor: it is frozen and
|
|
55
|
+
shareable itself, and the value inside it is too, so nothing reachable through it
|
|
56
|
+
can be mutated behind the lock's back. It also means an update replaces the value
|
|
57
|
+
rather than modifying it: `lv.update { it.merge(k => v).freeze }`, not
|
|
58
|
+
`lv.value[k] = v`.
|
|
59
|
+
|
|
60
|
+
A rejected value leaves the variable as it was.
|
|
61
|
+
|
|
62
|
+
### Reads and updates
|
|
63
|
+
|
|
64
|
+
Both take the lock, so an update's **whole block** is atomic as far as readers
|
|
65
|
+
are concerned, not just its final store. That is what an update block needs when
|
|
66
|
+
it makes anything else observable:
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
lv = Ractor::LockVar.new(0)
|
|
70
|
+
lv.update {|v| $last = v; v + 1 } # every reader sees lv.value > $last
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Blind assignment is `lv.update { x }` (the block just ignores the old value).
|
|
74
|
+
There is no `value=`: an unlocked write would silently discard a concurrent
|
|
75
|
+
`update` that had already read the old value.
|
|
76
|
+
|
|
77
|
+
Note that the block's result *is* the new value, so a block that forgets to
|
|
78
|
+
return it clears the variable: `lv.update {|v| puts v }` stores `nil`.
|
|
79
|
+
|
|
80
|
+
`increment` is there because adding to a number is the most common update of all;
|
|
81
|
+
it is the block form with the block written for you, and behaves the same way in
|
|
82
|
+
every respect, including refusing to store a sum that is not shareable.
|
|
83
|
+
|
|
84
|
+
## Read-modify-write belongs inside the block
|
|
85
|
+
|
|
86
|
+
Any new value computed from the current one has to be computed inside `update`,
|
|
87
|
+
from the value the block is given. Reading outside and writing inside is broken:
|
|
88
|
+
another update lands in between, and yours discards it. Counting is only the
|
|
89
|
+
smallest example. The same goes for appending to a frozen array, merging into a
|
|
90
|
+
frozen hash, clamping, toggling, anything that reads before it writes.
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
# WRONG
|
|
94
|
+
v = lv.value
|
|
95
|
+
lv.update { v + 1 }
|
|
96
|
+
|
|
97
|
+
h = lv.value
|
|
98
|
+
lv.update { h.merge(key => 1).freeze }
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
# RIGHT
|
|
103
|
+
lv = Ractor::LockVar.new(0)
|
|
104
|
+
lv.update { it + 1 }
|
|
105
|
+
|
|
106
|
+
h = Ractor::LockVar.new({}.freeze)
|
|
107
|
+
h.update { it.merge(key: 1).freeze }
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Four Ractors incrementing 500 times each:
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
wrong: 829 / 2000 (1171 updates lost)
|
|
114
|
+
right: 2000 / 2000
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`value` is for looking: a snapshot, true when it was taken and possibly stale by
|
|
118
|
+
the time you use it. **Never feed it back into `update`.**
|
|
119
|
+
|
|
120
|
+
Nothing in the library can catch this for you, so the warning is the whole
|
|
121
|
+
defence.
|
|
122
|
+
|
|
123
|
+
## One variable, and how that differs from TVar
|
|
124
|
+
|
|
125
|
+
The unit here is a single variable. That is the whole distinction between this
|
|
126
|
+
and its neighbour, not optimistic versus pessimistic, which is only how each one
|
|
127
|
+
happens to be built.
|
|
128
|
+
|
|
129
|
+
| | `Ractor::LockVar` | [`Ractor::TVar`](tvar.md) |
|
|
130
|
+
|---|---|---|
|
|
131
|
+
| synchronizes | one variable | several variables together |
|
|
132
|
+
| written as | `lv.update {\|v\| ... }` | `Ractor.atomically { ... }` |
|
|
133
|
+
| on conflict | waits its turn | rolls back and runs the block again |
|
|
134
|
+
| the block runs | exactly once | as many times as it takes |
|
|
135
|
+
| so the block may | have side effects | only compute |
|
|
136
|
+
| lock ordering | refused: one variable at a time | not a question |
|
|
137
|
+
|
|
138
|
+
A transaction only starts to mean something once there is a second variable, so
|
|
139
|
+
for one variable there is nothing to express beyond a read-modify-write, which
|
|
140
|
+
is why reading and updating are all there is to it.
|
|
141
|
+
|
|
142
|
+
The row that decides most cases is the rollback. A `TVar` transaction that loses
|
|
143
|
+
a race is discarded and run again, so its block has to be safe to run twice:
|
|
144
|
+
anything it did that was not a `TVar` write has already happened and will happen
|
|
145
|
+
again. A `LockVar` update waits for its turn instead, and then runs once.
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
$log = []
|
|
149
|
+
lv = Ractor::LockVar.new(0)
|
|
150
|
+
lv.update {|v| $log << v; v + 1 } # logs exactly once
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Touching another LockVar from inside an update is refused:
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
a = Ractor::LockVar.new(1)
|
|
157
|
+
b = Ractor::LockVar.new(2)
|
|
158
|
+
a.update {|v| b.value }
|
|
159
|
+
# => Ractor::NestedLockError:
|
|
160
|
+
# already inside another Ractor::LockVar;
|
|
161
|
+
# use Ractor::TVar to change several of them together
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Lock ordering is where locking goes wrong, and refusing the first nesting turns a
|
|
165
|
+
rare production deadlock into a deterministic error. Reaching for a second
|
|
166
|
+
variable is the sign that you wanted a transaction: `Ractor::TVar` logs reads and
|
|
167
|
+
writes and retries on conflict, so it needs no lock order at all.
|
|
168
|
+
|
|
169
|
+
No LockVar can be touched from inside an update, its own included. The block is
|
|
170
|
+
handed the value it needs, and a nested update's write would be discarded by the
|
|
171
|
+
outer block's result anyway. The holder is tracked per **thread**.
|
|
172
|
+
|
|
173
|
+
## Keep the block short
|
|
174
|
+
|
|
175
|
+
The block holds the lock while it runs, so everything else waiting on this
|
|
176
|
+
variable waits for it. Compute the new value and nothing more: no IO, no waiting
|
|
177
|
+
on anything, no calling out to code that might. This is not a `LockVar`
|
|
178
|
+
restriction so much as the rule for any critical section, and `TVar` wants the
|
|
179
|
+
same thing for its own reason: a transaction is validated against the version it
|
|
180
|
+
read when it started, so a long block is a long window for somebody else to
|
|
181
|
+
invalidate it.
|
|
182
|
+
|
|
183
|
+
## Performance
|
|
184
|
+
|
|
185
|
+
The workload is one shared value, a frozen `{status:, seq:}` record: a **read**
|
|
186
|
+
takes it out, an **update** puts a new frozen one in its place. Numbers are **ns
|
|
187
|
+
per completed operation across all Ractors**, so one that halves when the Ractors
|
|
188
|
+
double means it scaled. Measured on 16 cores with the CPU governor fixed at
|
|
189
|
+
`performance`, on ruby 4.1.0dev; `benchmark/family.rb` runs the same comparison
|
|
190
|
+
and checks after every run that no update was lost. Each cell is the median of
|
|
191
|
+
three runs, and these cells moved by 0% to 10% between independent sweeps.
|
|
192
|
+
|
|
193
|
+
### Reading
|
|
194
|
+
|
|
195
|
+
```ruby
|
|
196
|
+
n.times.map {|i| Ractor.new(vars[i]) {|v| K.times { v.value } } }.each(&:join)
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
| Ractors | shared `LockVar#value` (ns) | shared `TVar#value` (ns) | own `LockVar#value` (ns) | own `TVar#value` (ns) |
|
|
200
|
+
|---:|---:|---:|---:|---:|
|
|
201
|
+
| 1 | 77 | 74 | 74 | 68 |
|
|
202
|
+
| 2 | 115 | 43 | 47 | 42 |
|
|
203
|
+
| 4 | 297 | 23 | 23 | 32 |
|
|
204
|
+
| 8 | 347 | 17 | 12 | 10 |
|
|
205
|
+
| 16 | 365 | 9 | 10 | 9 |
|
|
206
|
+
|
|
207
|
+
**A shared `LockVar` does not scale for reading, and this is the number to know
|
|
208
|
+
before choosing it.** `#value` takes the lock, so sixteen Ractors reading one
|
|
209
|
+
variable stand in a queue and the read costs 365 ns instead of 10. `TVar#value`
|
|
210
|
+
outside a transaction takes nothing, so it reads the same whether the variable is
|
|
211
|
+
shared or not. Give each Ractor a variable of its own and both scale to the
|
|
212
|
+
machine's limit.
|
|
213
|
+
|
|
214
|
+
What the lock buys is the guarantee in *Reads and updates* above: a reader waits
|
|
215
|
+
for an update in flight, so an update block is atomic to readers and not just its
|
|
216
|
+
final store. If your load is read heavy and shared, that guarantee is expensive.
|
|
217
|
+
|
|
218
|
+
### Updating
|
|
219
|
+
|
|
220
|
+
```ruby
|
|
221
|
+
v.update {|rec| { status: rec[:status], seq: rec[:seq] + 1 }.freeze }
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
| Ractors | shared `LockVar#update` (ns) | shared `TVar` `atomically` (ns) | own `LockVar#update` (ns) | own `TVar` `atomically` (ns) |
|
|
225
|
+
|---:|---:|---:|---:|---:|
|
|
226
|
+
| 1 | 359 | 358 | 352 | 351 |
|
|
227
|
+
| 2 | 823 | 366 | 209 | 204 |
|
|
228
|
+
| 4 | 1014 | 384 | 106 | 132 |
|
|
229
|
+
| 8 | 1017 | 462 | 56 | 99 |
|
|
230
|
+
| 16 | 1102 | 509 | 50 | 108 |
|
|
231
|
+
|
|
232
|
+
**Fought over, neither scales and `TVar` stays about 2× ahead**, because the
|
|
233
|
+
loser of a race retries a short block where `LockVar` parks the thread and wakes
|
|
234
|
+
it through a port, which costs more than the block did; and a transaction that
|
|
235
|
+
keeps losing backs off, a 100 ns spin per consecutive loss, before running
|
|
236
|
+
again. The shared-16 `TVar` cell is the volatile one, landing anywhere from 500
|
|
237
|
+
to 870 ns between sweeps; it is quoted as the median of seven runs.
|
|
238
|
+
|
|
239
|
+
**Spread out, `LockVar` scales and `TVar` does not**: 352 ns down to 50 is 7.0×,
|
|
240
|
+
against 3.3× for `TVar`. Every committing transaction takes one process wide lock
|
|
241
|
+
to allocate the next version number, whichever variable it touched, and that lock
|
|
242
|
+
is the ceiling.
|
|
243
|
+
|
|
244
|
+
**Do not choose `LockVar` for speed on a contended variable.** How much any of
|
|
245
|
+
this matters depends on how often your variable is actually contended and how
|
|
246
|
+
much of your load is reads, which are properties of your program rather than of
|
|
247
|
+
either class, so measure yours. What `LockVar` gives you regardless is that the
|
|
248
|
+
block runs once.
|
|
249
|
+
|
|
250
|
+
### Not increment
|
|
251
|
+
|
|
252
|
+
`LockVar#increment` and `TVar#increment` each take a fast path that adds two
|
|
253
|
+
Fixnums without running any Ruby, so they are not a measurement of either class:
|
|
254
|
+
77 ns and 89 ns for one Ractor on its own variable, 338 ns and 144 ns for
|
|
255
|
+
sixteen on one.
|
|
256
|
+
|
|
257
|
+
## Implementation notes
|
|
258
|
+
|
|
259
|
+
* The lock state is protected by a native mutex that is held for a few
|
|
260
|
+
instructions only and **never across Ruby code**, so a waiter can never keep
|
|
261
|
+
another Ractor from reaching a GC safepoint.
|
|
262
|
+
* A thread that has to wait parks on a `Ractor::Port` of its own rather than on a
|
|
263
|
+
condition variable. `Port#receive` goes through the VM scheduler, so the wait
|
|
264
|
+
**rides the M:N scheduler** (enabled by default on non-main Ractors) and stays
|
|
265
|
+
interruptible: `Thread#kill` on a waiter works and leaves the lock untouched.
|
|
266
|
+
* An uncontended `update` touches the native mutex only: no Port is created and
|
|
267
|
+
no message is sent.
|
|
268
|
+
* `update` and `value` are written in C so that no interrupt can be delivered
|
|
269
|
+
between taking the lock and arming the `ensure` that releases it. (With the
|
|
270
|
+
block form written in Ruby, a `Thread#kill` landing in that window stranded the
|
|
271
|
+
lock forever; it reproduced within a couple of iterations.)
|
|
272
|
+
* Only the first waiter is woken, and waiters stay queued until they wake by
|
|
273
|
+
themselves, so a wakeup lost to an interrupt is retried by the next release; a
|
|
274
|
+
waiter that leaves without taking the lock passes the wakeup on. A waiter whose
|
|
275
|
+
Ractor has ended has a closed port, so the wakeup is skipped and the next
|
|
276
|
+
waiter is tried instead.
|
|
277
|
+
* Acquisition is **not FIFO**: a thread may barge ahead of queued waiters.
|
|
278
|
+
* `inspect` never takes the lock, so it neither blocks nor raises.
|
|
279
|
+
|
|
280
|
+
Part of [ractor-sharing](../README.md).
|
data/docs/tvar.md
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# Ractor::TVar
|
|
2
|
+
|
|
3
|
+
A variable Ractors can share, and the one to reach for first.
|
|
4
|
+
[Software transactional memory](https://en.wikipedia.org/wiki/Software_transactional_memory)
|
|
5
|
+
for Ractors and Threads: read and write as many TVars as you like inside
|
|
6
|
+
`Ractor.atomically`, and everything that block changes takes effect together or
|
|
7
|
+
not at all.
|
|
8
|
+
|
|
9
|
+
A TVar holds any shareable object, not only a number:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
require "ractor/tvar"
|
|
13
|
+
|
|
14
|
+
config = Ractor::TVar.new({ mode: :idle }.freeze)
|
|
15
|
+
version = Ractor::TVar.new("v1".freeze)
|
|
16
|
+
|
|
17
|
+
Ractor.atomically do
|
|
18
|
+
config.value = config.value.merge(mode: :running).freeze
|
|
19
|
+
version.value = "v2".freeze # nobody sees v1 running, or v2 idle
|
|
20
|
+
end
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
One variable is a transaction with one variable in it, and reads the same way:
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
seen = Ractor::TVar.new([].freeze)
|
|
27
|
+
Ractor.atomically { seen.value = (seen.value + [:x]).freeze }
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Where two variables have to agree, that is the whole point:
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
from = Ractor::TVar.new(100)
|
|
34
|
+
to = Ractor::TVar.new(0)
|
|
35
|
+
|
|
36
|
+
Ractor.atomically do
|
|
37
|
+
from.value -= 10
|
|
38
|
+
to.value += 10 # no one ever sees the money in neither account
|
|
39
|
+
end
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Nothing is locked while the block runs. Each transaction reads a consistent
|
|
43
|
+
snapshot and, at the end, commits only if nothing it read has changed since;
|
|
44
|
+
otherwise it is **rolled back and run again**. So a block may run more than once,
|
|
45
|
+
and must be safe to: keep it to reading and writing TVars, with no side effects
|
|
46
|
+
and no waiting.
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
tv = Ractor::TVar.new(0)
|
|
50
|
+
rs = 4.times.map { Ractor.new(tv) {|t| 10_000.times { Ractor.atomically { t.value += 1 } } } }
|
|
51
|
+
rs.each(&:join)
|
|
52
|
+
tv.value #=> 40000
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## API
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
tv = Ractor::TVar.new(initial = nil) # any shareable object
|
|
59
|
+
|
|
60
|
+
Ractor.atomically { ... } # everything inside is one transaction
|
|
61
|
+
|
|
62
|
+
tv.value # read; inside a transaction or out
|
|
63
|
+
tv.value = v # write; only inside a transaction
|
|
64
|
+
tv.increment(n = 1) # add in one step; inside or out, for values that answer to +
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Values must be shareable; `ArgumentError` otherwise.
|
|
68
|
+
|
|
69
|
+
**A write needs a transaction.** `tv.value = v` on its own raises
|
|
70
|
+
`Ractor::TransactionError`, "can not set without transaction". There is no
|
|
71
|
+
one-off write, because a write on its own is where a lost update comes from:
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
Ractor.atomically { tv.value = tv.value + 1 } # right
|
|
75
|
+
tv.increment # right, and shorter
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
A read outside a transaction is allowed, and returns the value as it stands.
|
|
79
|
+
`increment` is allowed too: outside a transaction it does the whole add in one
|
|
80
|
+
step, under the variable's own lock when both sides are Fixnums, and as a one-off
|
|
81
|
+
transaction otherwise.
|
|
82
|
+
|
|
83
|
+
`Ractor::TransactionError` is raised for a transaction that cannot proceed;
|
|
84
|
+
`Ractor::RetryTransaction` is what a rollback is made of.
|
|
85
|
+
|
|
86
|
+
## When something else fits better
|
|
87
|
+
|
|
88
|
+
* One variable, or a block that must not run twice:
|
|
89
|
+
[`Ractor::LockVar`](lockvar.md), which waits its turn instead of retrying.
|
|
90
|
+
* State you do not want to freeze, a mutable object updated in place:
|
|
91
|
+
[`Ractor::ActorHash`](actor_hash.md) or [`Ractor::ActiveObject`](active_object.md).
|
|
92
|
+
|
|
93
|
+
## Scaling
|
|
94
|
+
|
|
95
|
+
**Reads outside a transaction cost nothing and scale.** Sixteen Ractors reading
|
|
96
|
+
one shared TVar cost 9 ns per read, the same as sixteen reading their own, where
|
|
97
|
+
the pessimistic [`Ractor::LockVar`](lockvar.md) costs 365 ns for the same thing
|
|
98
|
+
because its read takes the lock. If your load is read heavy and the state is
|
|
99
|
+
shared, this is the reason to be here.
|
|
100
|
+
|
|
101
|
+
A read outside a transaction is a read of one slot. It is not a snapshot across
|
|
102
|
+
several: two TVars that have to agree must be read inside one
|
|
103
|
+
`Ractor.atomically`, the same way they are written.
|
|
104
|
+
|
|
105
|
+
**Commits do not run in parallel.** Every committing transaction takes one
|
|
106
|
+
process wide lock to allocate its version number, whichever variables it touched.
|
|
107
|
+
Sixteen Ractors updating sixteen *unrelated* TVars get about 3.3× the throughput of
|
|
108
|
+
one, where sixteen LockVars get 7.0×. On an update as small as `increment` there
|
|
109
|
+
is no gain left at all: sixteen Ractors on sixteen TVars get no more throughput
|
|
110
|
+
than one does (both about 80 ns, inside the noise), because the commit is then
|
|
111
|
+
all of the work. Transaction
|
|
112
|
+
bodies do run in parallel; it is the commit that does not.
|
|
113
|
+
|
|
114
|
+
**Fought over, retrying beats waiting.** Sixteen Ractors updating the *same*
|
|
115
|
+
variable cost about 509 ns per completed update against 1102 for a LockVar,
|
|
116
|
+
because the loser of a race runs a short block again rather than parking a
|
|
117
|
+
thread and waking it. A transaction that loses twice in a row also **backs
|
|
118
|
+
off**, spinning about 100 ns per consecutive loss before running again, which
|
|
119
|
+
trims the work thrown away in a storm and costs an occasionally contended write
|
|
120
|
+
nothing measurable. That contended figure is the volatile one, landing anywhere
|
|
121
|
+
from 500 to 870 ns between sweeps. The full tables are in
|
|
122
|
+
[the README](../README.md#performance).
|
|
123
|
+
|
|
124
|
+
Part of [ractor-sharing](../README.md).
|