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
data/docs/lockvar.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# Ractor::LockVar
|
|
2
2
|
|
|
3
|
-
**One** variable that Ractors can share. It holds one shareable object
|
|
3
|
+
**One** variable that Ractors can share. It holds one shareable object (made
|
|
4
|
+
shareable on the way in); any
|
|
4
5
|
Ractor can read it, and any Ractor can replace what is in it, one at a time.
|
|
5
6
|
Several variables that have to change together are `Ractor::TVar`'s job, and a
|
|
6
7
|
whole hash of them is [`Ractor::LockHash`](lockhash.md)'s.
|
|
@@ -8,16 +9,16 @@ whole hash of them is [`Ractor::LockHash`](lockhash.md)'s.
|
|
|
8
9
|
```ruby
|
|
9
10
|
require "ractor/lockvar"
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
roster = Ractor::LockVar.new([])
|
|
12
13
|
|
|
13
|
-
rs =
|
|
14
|
-
Ractor.new(
|
|
15
|
-
|
|
14
|
+
rs = %w[ann ben cho dee].map do |name|
|
|
15
|
+
Ractor.new(roster, name) do |r, me|
|
|
16
|
+
r.update {|team| team + [me] } # read-modify-write, one at a time
|
|
16
17
|
end
|
|
17
18
|
end
|
|
18
19
|
rs.each(&:join)
|
|
19
20
|
|
|
20
|
-
p
|
|
21
|
+
p roster.value.sort #=> ["ann", "ben", "cho", "dee"]
|
|
21
22
|
```
|
|
22
23
|
|
|
23
24
|
## API
|
|
@@ -39,22 +40,24 @@ A variable, not a lock: there is no lock, unlock, or owner query. `value` and
|
|
|
39
40
|
`update` are the whole of it, and `increment` is there because a counter is what
|
|
40
41
|
a shared variable most often is.
|
|
41
42
|
|
|
42
|
-
### The value
|
|
43
|
+
### The value is made shareable
|
|
43
44
|
|
|
44
|
-
A LockVar holds one **shareable** object, and
|
|
45
|
-
|
|
45
|
+
A LockVar holds one **shareable** object, and the store sees to it: a value
|
|
46
|
+
that already is shareable passes through untouched, anything else is
|
|
47
|
+
deep-frozen **in place**. No `.freeze` and no `Ractor.make_shareable` of your
|
|
48
|
+
own, and the object you handed
|
|
49
|
+
over comes out frozen -- hand over things you are done mutating (storing
|
|
50
|
+
`STDOUT` would freeze `STDOUT`). A value that cannot be made shareable raises
|
|
51
|
+
`Ractor::IsolationError`.
|
|
46
52
|
|
|
47
53
|
```ruby
|
|
48
|
-
Ractor::LockVar.new({})
|
|
49
|
-
lv.update { [1, 2] } #
|
|
50
|
-
lv.update { [1, 2].freeze } # fine
|
|
51
|
-
lv.update { {a: 1}.freeze } # fine
|
|
54
|
+
lv = Ractor::LockVar.new({}) # fine; the hash is frozen in place
|
|
55
|
+
lv.update { [1, 2] } # fine; frozen on the way in
|
|
52
56
|
```
|
|
53
57
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
rather than modifying it: `lv.update { it.merge(k => v).freeze }`, not
|
|
58
|
+
Shareable means nothing reachable through the LockVar can be mutated behind
|
|
59
|
+
the lock's back. It also means an update replaces the value rather than
|
|
60
|
+
modifying it: `lv.update { it.merge(k => v) }`, not
|
|
58
61
|
`lv.value[k] = v`.
|
|
59
62
|
|
|
60
63
|
A rejected value leaves the variable as it was.
|
|
@@ -79,7 +82,7 @@ return it clears the variable: `lv.update {|v| puts v }` stores `nil`.
|
|
|
79
82
|
|
|
80
83
|
`increment` is there because adding to a number is the most common update of all;
|
|
81
84
|
it is the block form with the block written for you, and behaves the same way in
|
|
82
|
-
every respect, including
|
|
85
|
+
every respect, including making the sum shareable on the way in.
|
|
83
86
|
|
|
84
87
|
## Read-modify-write belongs inside the block
|
|
85
88
|
|
|
@@ -95,7 +98,7 @@ v = lv.value
|
|
|
95
98
|
lv.update { v + 1 }
|
|
96
99
|
|
|
97
100
|
h = lv.value
|
|
98
|
-
lv.update { h.merge(key => 1)
|
|
101
|
+
lv.update { h.merge(key => 1) }
|
|
99
102
|
```
|
|
100
103
|
|
|
101
104
|
```ruby
|
|
@@ -103,8 +106,8 @@ lv.update { h.merge(key => 1).freeze }
|
|
|
103
106
|
lv = Ractor::LockVar.new(0)
|
|
104
107
|
lv.update { it + 1 }
|
|
105
108
|
|
|
106
|
-
h = Ractor::LockVar.new({}
|
|
107
|
-
h.update { it.merge(key: 1)
|
|
109
|
+
h = Ractor::LockVar.new({})
|
|
110
|
+
h.update { it.merge(key: 1) }
|
|
108
111
|
```
|
|
109
112
|
|
|
110
113
|
Four Ractors incrementing 500 times each:
|
|
@@ -218,7 +221,7 @@ final store. If your load is read heavy and shared, that guarantee is expensive.
|
|
|
218
221
|
### Updating
|
|
219
222
|
|
|
220
223
|
```ruby
|
|
221
|
-
v.update {|rec| { status: rec[:status], seq: rec[:seq] + 1 }
|
|
224
|
+
v.update {|rec| { status: rec[:status], seq: rec[:seq] + 1 } }
|
|
222
225
|
```
|
|
223
226
|
|
|
224
227
|
| Ractors | shared `LockVar#update` (ns) | shared `TVar` `atomically` (ns) | own `LockVar#update` (ns) | own `TVar` `atomically` (ns) |
|
|
@@ -229,7 +232,7 @@ v.update {|rec| { status: rec[:status], seq: rec[:seq] + 1 }.freeze }
|
|
|
229
232
|
| 8 | 1017 | 462 | 56 | 99 |
|
|
230
233
|
| 16 | 1102 | 509 | 50 | 108 |
|
|
231
234
|
|
|
232
|
-
**
|
|
235
|
+
**Under contention, neither scales and `TVar` stays about 2× ahead**, because the
|
|
233
236
|
loser of a race retries a short block where `LockVar` parks the thread and wakes
|
|
234
237
|
it through a port, which costs more than the block did; and a transaction that
|
|
235
238
|
keeps losing backs off, a 100 ns spin per consecutive loss, before running
|
data/docs/tvar.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
A variable Ractors can share, and the one to reach for first.
|
|
4
4
|
[Software transactional memory](https://en.wikipedia.org/wiki/Software_transactional_memory)
|
|
5
5
|
for Ractors and Threads: read and write as many TVars as you like inside
|
|
6
|
-
`Ractor.atomically`, and
|
|
6
|
+
`Ractor.atomically`, and all changes made by that block take effect together or
|
|
7
7
|
not at all.
|
|
8
8
|
|
|
9
9
|
A TVar holds any shareable object, not only a number:
|
|
@@ -11,20 +11,20 @@ A TVar holds any shareable object, not only a number:
|
|
|
11
11
|
```ruby
|
|
12
12
|
require "ractor/tvar"
|
|
13
13
|
|
|
14
|
-
config = Ractor::TVar.new(
|
|
15
|
-
version = Ractor::TVar.new("v1"
|
|
14
|
+
config = Ractor::TVar.new(mode: :idle)
|
|
15
|
+
version = Ractor::TVar.new("v1")
|
|
16
16
|
|
|
17
17
|
Ractor.atomically do
|
|
18
|
-
config.value = config.value.merge(mode: :running)
|
|
19
|
-
version.value = "v2"
|
|
18
|
+
config.value = config.value.merge(mode: :running)
|
|
19
|
+
version.value = "v2" # nobody sees v1 running, or v2 idle
|
|
20
20
|
end
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
One variable is a transaction with one variable in it, and reads the same way:
|
|
24
24
|
|
|
25
25
|
```ruby
|
|
26
|
-
seen = Ractor::TVar.new([]
|
|
27
|
-
Ractor.atomically { seen.value =
|
|
26
|
+
seen = Ractor::TVar.new([])
|
|
27
|
+
Ractor.atomically { seen.value = seen.value + [:x] }
|
|
28
28
|
```
|
|
29
29
|
|
|
30
30
|
Where two variables have to agree, that is the whole point:
|
|
@@ -55,7 +55,7 @@ tv.value #=> 40000
|
|
|
55
55
|
## API
|
|
56
56
|
|
|
57
57
|
```ruby
|
|
58
|
-
tv = Ractor::TVar.new(initial = nil) #
|
|
58
|
+
tv = Ractor::TVar.new(initial = nil) # anything; made shareable on the way in
|
|
59
59
|
|
|
60
60
|
Ractor.atomically { ... } # everything inside is one transaction
|
|
61
61
|
|
|
@@ -64,7 +64,13 @@ tv.value = v # write; only inside a transaction
|
|
|
64
64
|
tv.increment(n = 1) # add in one step; inside or out, for values that answer to +
|
|
65
65
|
```
|
|
66
66
|
|
|
67
|
-
Values
|
|
67
|
+
Values are **made shareable on the way in**: one that already is passes
|
|
68
|
+
through untouched, anything else is deep-frozen **in place** -- storing a value
|
|
69
|
+
here means sharing it, so neither `.freeze` nor `Ractor.make_shareable` is
|
|
70
|
+
yours to write, and the object you
|
|
71
|
+
handed over comes out frozen. Hand over things you are done mutating: storing
|
|
72
|
+
`STDOUT` would freeze `STDOUT`. A value that cannot be made shareable raises
|
|
73
|
+
`Ractor::IsolationError`.
|
|
68
74
|
|
|
69
75
|
**A write needs a transaction.** `tv.value = v` on its own raises
|
|
70
76
|
`Ractor::TransactionError`, "can not set without transaction". There is no
|
|
@@ -90,6 +96,43 @@ transaction otherwise.
|
|
|
90
96
|
* State you do not want to freeze, a mutable object updated in place:
|
|
91
97
|
[`Ractor::ActorHash`](actor_hash.md) or [`Ractor::ActiveObject`](active_object.md).
|
|
92
98
|
|
|
99
|
+
## A shareable class with TVar slots
|
|
100
|
+
|
|
101
|
+
A TVar is frozen and shareable; only the value inside it moves. So a class
|
|
102
|
+
whose mutable state lives in TVars can make its own instances shareable, and a
|
|
103
|
+
shareable instance crosses to any Ractor **by reference**, methods included --
|
|
104
|
+
or simply lives in a constant that every Ractor uses:
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
class Account
|
|
108
|
+
def initialize(balance)
|
|
109
|
+
@balance = Ractor::TVar.new(balance)
|
|
110
|
+
@history = Ractor::TVar.new([])
|
|
111
|
+
Ractor.make_shareable(self) # fails loudly if an ivar could not travel
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def deposit(amount)
|
|
115
|
+
Ractor.atomically do
|
|
116
|
+
@balance.value += amount
|
|
117
|
+
@history.value += [amount]
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def balance = @balance.value
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
acc = Account.new(100)
|
|
125
|
+
Ractor.new(acc) { |a| a.deposit(25) }.join
|
|
126
|
+
acc.balance #=> 125
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
The methods run in whichever Ractor calls them -- there is no owner and no
|
|
130
|
+
message round trip -- and the transactions they declare are the whole
|
|
131
|
+
synchronization. This is the pattern to exhaust before reaching for
|
|
132
|
+
[`Ractor::ActiveObject`](active_object.md): an active object earns its Ractor
|
|
133
|
+
and its ~2.5 µs per call when the state genuinely cannot be frozen, not when a
|
|
134
|
+
few slots of it change.
|
|
135
|
+
|
|
93
136
|
## Scaling
|
|
94
137
|
|
|
95
138
|
**Reads outside a transaction cost nothing and scale.** Sixteen Ractors reading
|
|
@@ -111,7 +154,7 @@ than one does (both about 80 ns, inside the noise), because the commit is then
|
|
|
111
154
|
all of the work. Transaction
|
|
112
155
|
bodies do run in parallel; it is the commit that does not.
|
|
113
156
|
|
|
114
|
-
**
|
|
157
|
+
**Under contention, retrying beats waiting.** Sixteen Ractors updating the *same*
|
|
115
158
|
variable cost about 509 ns per completed update against 1102 for a LockVar,
|
|
116
159
|
because the loser of a race runs a short block again rather than parking a
|
|
117
160
|
thread and waking it. A transaction that loses twice in a row also **backs
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
#
|
|
3
|
+
# The classic STM example, because nothing shows "all or none" better: money
|
|
4
|
+
# moves between accounts, and no observer ever sees it in neither.
|
|
5
|
+
#
|
|
6
|
+
# ruby -Ilib examples/01_bank_transfer.rb
|
|
7
|
+
Warning[:experimental] = false
|
|
8
|
+
require "ractor/sharing"
|
|
9
|
+
|
|
10
|
+
accounts = { alice: Ractor::TVar.new(300), bob: Ractor::TVar.new(300), carol: Ractor::TVar.new(300) }
|
|
11
|
+
Ractor.make_shareable(accounts)
|
|
12
|
+
TOTAL = 900
|
|
13
|
+
|
|
14
|
+
# Three tellers shuffle money along a ring. Each transfer is one transaction:
|
|
15
|
+
# both balances change together or not at all.
|
|
16
|
+
tellers = accounts.keys.zip(accounts.keys.rotate).map do |from, to|
|
|
17
|
+
Ractor.new(accounts[from], accounts[to]) do |a, b|
|
|
18
|
+
500.times do
|
|
19
|
+
Ractor.atomically do
|
|
20
|
+
amount = a.value >= 5 ? 5 : a.value
|
|
21
|
+
a.value -= amount
|
|
22
|
+
b.value += amount
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
:ok
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The auditor reads all three inside one transaction, so it sees a consistent
|
|
30
|
+
# snapshot: the total must be exact in every single read, mid-shuffle included.
|
|
31
|
+
auditor = Ractor.new(accounts) do |acc|
|
|
32
|
+
1_000.times.count do
|
|
33
|
+
total = Ractor.atomically { acc.each_value.sum(&:value) }
|
|
34
|
+
abort "audit failed: saw #{total}" unless total == TOTAL
|
|
35
|
+
true
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
audits = auditor.value
|
|
40
|
+
tellers.each(&:join)
|
|
41
|
+
final = accounts.transform_values(&:value)
|
|
42
|
+
abort "money leaked: #{final}" unless final.values.sum == TOTAL
|
|
43
|
+
puts "ok: #{audits} audits mid-shuffle, every one saw exactly #{TOTAL}; final #{final}"
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
#
|
|
3
|
+
# Booking two adjacent seats. With one lock per seat this is the textbook
|
|
4
|
+
# deadlock: two buyers taking overlapping pairs in opposite order wait on each
|
|
5
|
+
# other forever. With TVars there is no lock order to get wrong: the loser of a
|
|
6
|
+
# race is rolled back and simply looks again.
|
|
7
|
+
#
|
|
8
|
+
# ruby -Ilib examples/02_seat_booking.rb
|
|
9
|
+
Warning[:experimental] = false
|
|
10
|
+
require "ractor/sharing"
|
|
11
|
+
|
|
12
|
+
SEATS = Ractor.make_shareable(Array.new(10) { Ractor::TVar.new(:free) })
|
|
13
|
+
|
|
14
|
+
buyers = %i[ann ben cho dee].map do |name|
|
|
15
|
+
Ractor.new(SEATS, name) do |seats, me|
|
|
16
|
+
# One transaction: find the first adjacent free pair and take both.
|
|
17
|
+
# Everybody scans in a different direction on purpose -- the opposite-order
|
|
18
|
+
# access that would deadlock locks is just contention here.
|
|
19
|
+
Ractor.atomically do
|
|
20
|
+
range = (0...seats.size - 1)
|
|
21
|
+
range = range.to_a.reverse if %i[ben dee].include?(me)
|
|
22
|
+
i = range.find { |j| seats[j].value == :free && seats[j + 1].value == :free }
|
|
23
|
+
next nil if i.nil?
|
|
24
|
+
|
|
25
|
+
seats[i].value = me
|
|
26
|
+
seats[i + 1].value = me
|
|
27
|
+
i
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
got = buyers.map(&:value)
|
|
33
|
+
taken = SEATS.map(&:value)
|
|
34
|
+
abort "somebody got no pair: #{taken}" if got.any?(&:nil?)
|
|
35
|
+
got.each do |i|
|
|
36
|
+
a, b = taken[i], taken[i + 1]
|
|
37
|
+
abort "pair at #{i} torn: #{taken}" unless a == b && a != :free
|
|
38
|
+
end
|
|
39
|
+
abort "double booking: #{taken}" unless taken.tally.values.all? { |n| n <= 2 }
|
|
40
|
+
puts "ok: #{taken.each_slice(2).map { |s| s.map { |v| v == :free ? '__' : v[0, 2] }.join }.join(' | ')}"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
#
|
|
3
|
+
# A read-mostly config: flags read on every request by every worker, flipped
|
|
4
|
+
# once in a blue moon. This is where TVar shines -- a read outside a
|
|
5
|
+
# transaction takes no lock at all, so sixteen readers of one shared TVar pay
|
|
6
|
+
# the same as sixteen readers of their own (about 9 ns on our bench machine).
|
|
7
|
+
#
|
|
8
|
+
# ruby -Ilib examples/03_feature_flags.rb
|
|
9
|
+
Warning[:experimental] = false
|
|
10
|
+
require "ractor/sharing"
|
|
11
|
+
|
|
12
|
+
FLAGS = Ractor::TVar.new({ new_ui: false, beta_search: false }.freeze)
|
|
13
|
+
|
|
14
|
+
workers = 4.times.map do
|
|
15
|
+
Ractor.new(FLAGS) do |flags|
|
|
16
|
+
reads = 0
|
|
17
|
+
reads += 1 until flags.value[:new_ui] # serve requests on the old UI
|
|
18
|
+
reads += 1 until flags.value[:beta_search] # then wait for the next rollout
|
|
19
|
+
reads
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
sleep 0.05 # let them serve a while on the old flags
|
|
24
|
+
Ractor.atomically { FLAGS.value = FLAGS.value.merge(new_ui: true).freeze }
|
|
25
|
+
sleep 0.05
|
|
26
|
+
Ractor.atomically { FLAGS.value = FLAGS.value.merge(beta_search: true).freeze }
|
|
27
|
+
|
|
28
|
+
reads = workers.sum(&:value)
|
|
29
|
+
abort "a worker never saw the rollout" if reads.zero?
|
|
30
|
+
puts "ok: 4 workers made #{reads} flag reads across two rollouts, no locks anywhere"
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
#
|
|
3
|
+
# A shared progress counter: workers add what they finished, the main Ractor
|
|
4
|
+
# reads it whenever it feels like drawing the bar. increment is atomic, value
|
|
5
|
+
# is just a peek -- nobody coordinates with anybody.
|
|
6
|
+
#
|
|
7
|
+
# ruby -Ilib examples/04_progress.rb
|
|
8
|
+
Warning[:experimental] = false
|
|
9
|
+
require "ractor/sharing"
|
|
10
|
+
|
|
11
|
+
TOTAL = 4 * 200
|
|
12
|
+
done = Ractor::LockVar.new(0)
|
|
13
|
+
|
|
14
|
+
workers = 4.times.map do
|
|
15
|
+
Ractor.new(done) do |d|
|
|
16
|
+
200.times do
|
|
17
|
+
# pretend to move some bytes
|
|
18
|
+
d.increment
|
|
19
|
+
end
|
|
20
|
+
:ok
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
bars = []
|
|
25
|
+
until (n = done.value) == TOTAL
|
|
26
|
+
bars << "[#{'#' * (n * 20 / TOTAL)}#{'.' * (20 - n * 20 / TOTAL)}]"
|
|
27
|
+
# the reader sleeps; the writers never wait for it
|
|
28
|
+
sleep 0.001
|
|
29
|
+
end
|
|
30
|
+
workers.each(&:join)
|
|
31
|
+
|
|
32
|
+
abort "lost updates: #{done.value}" unless done.value == TOTAL
|
|
33
|
+
puts "ok: #{bars.size} redraw(s) while counting to #{TOTAL}, e.g. #{bars[bars.size / 2]}"
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
#
|
|
3
|
+
# Why LockVar exists when TVar is usually faster: a TVar transaction that loses
|
|
4
|
+
# a race RUNS ITS BLOCK AGAIN, so a side effect inside it repeats. A LockVar
|
|
5
|
+
# update waits its turn and runs the block exactly once. Here both race to be
|
|
6
|
+
# "the one who announces", and we count how often each block actually ran.
|
|
7
|
+
#
|
|
8
|
+
# ruby -Ilib examples/05_exactly_once.rb
|
|
9
|
+
Warning[:experimental] = false
|
|
10
|
+
require "ractor/sharing"
|
|
11
|
+
|
|
12
|
+
N = 8
|
|
13
|
+
tv = Ractor::TVar.new(0)
|
|
14
|
+
lv = Ractor::LockVar.new(0)
|
|
15
|
+
|
|
16
|
+
# Each Ractor counts its own block executions in plain locals. (Not in another
|
|
17
|
+
# LockVar: touching one lock from inside another is refused, by design.)
|
|
18
|
+
rs = N.times.map do
|
|
19
|
+
Ractor.new(tv, lv) do |t, l|
|
|
20
|
+
tv_runs = lv_runs = 0
|
|
21
|
+
250.times do
|
|
22
|
+
Ractor.atomically { tv_runs += 1; t.value += 1 } # side effect in a transaction: may rerun
|
|
23
|
+
l.update { |v| lv_runs += 1; v + 1 } # side effect under the lock: runs once
|
|
24
|
+
end
|
|
25
|
+
[tv_runs, lv_runs]
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
counts = rs.map(&:value)
|
|
29
|
+
tv_runs = counts.sum(&:first)
|
|
30
|
+
lv_runs = counts.sum(&:last)
|
|
31
|
+
|
|
32
|
+
updates = N * 250
|
|
33
|
+
abort "TVar lost updates" unless tv.value == updates
|
|
34
|
+
abort "LockVar lost updates" unless lv.value == updates
|
|
35
|
+
abort "a LockVar block ran #{lv_runs} times for #{updates} updates" unless lv_runs == updates
|
|
36
|
+
extra = tv_runs - updates
|
|
37
|
+
puts "ok: #{updates} updates each. LockVar blocks ran exactly #{lv_runs}; " \
|
|
38
|
+
"TVar blocks ran #{tv_runs} (#{extra} rerun#{'s' if extra != 1} -- keep side effects out of transactions)"
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
#
|
|
3
|
+
# Request metrics: a per-endpoint record AND a global total that must agree.
|
|
4
|
+
# Two keys changing together is exactly what LockHash's synchronize gives you,
|
|
5
|
+
# and to_h is a snapshot taken under the same lock -- so every report balances,
|
|
6
|
+
# even taken mid-flight from another Ractor.
|
|
7
|
+
#
|
|
8
|
+
# ruby -Ilib examples/06_metrics_board.rb
|
|
9
|
+
Warning[:experimental] = false
|
|
10
|
+
require "ractor/sharing"
|
|
11
|
+
|
|
12
|
+
board = Ractor::LockHash.new(total: 0)
|
|
13
|
+
ENDPOINTS = %w[/home /search /cart].freeze
|
|
14
|
+
|
|
15
|
+
workers = 4.times.map do
|
|
16
|
+
Ractor.new(board) do |b|
|
|
17
|
+
300.times do |i|
|
|
18
|
+
ep = ENDPOINTS[i % ENDPOINTS.size]
|
|
19
|
+
ms = 5 + i % 20
|
|
20
|
+
b.synchronize do |h|
|
|
21
|
+
rec = h[ep] || { count: 0, total_ms: 0, worst_ms: 0 }.freeze
|
|
22
|
+
h[ep] = { count: rec[:count] + 1, total_ms: rec[:total_ms] + ms,
|
|
23
|
+
worst_ms: [rec[:worst_ms], ms].max }.freeze
|
|
24
|
+
h[:total] = h[:total] + 1 # the cross-key part: total moves with the record
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
:ok
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The reporter never catches the books unbalanced: per-endpoint counts must sum
|
|
32
|
+
# to :total in every snapshot, because both changed under one synchronize.
|
|
33
|
+
reporter = Ractor.new(board) do |b|
|
|
34
|
+
200.times.count do
|
|
35
|
+
snap = b.to_h
|
|
36
|
+
sum = snap.reject { |k, _| k == :total }.values.sum { |r| r[:count] }
|
|
37
|
+
abort "unbalanced snapshot: #{sum} recorded but total says #{snap[:total]}" unless sum == snap[:total]
|
|
38
|
+
true
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
snapshots = reporter.value
|
|
43
|
+
workers.each(&:join)
|
|
44
|
+
final = board.to_h
|
|
45
|
+
abort "final books off" unless final[:total] == 1200
|
|
46
|
+
puts "ok: #{snapshots} mid-flight snapshots all balanced; " +
|
|
47
|
+
final.reject { |k, _| k == :total }.map { |ep, r| "#{ep} avg #{r[:total_ms] / r[:count]}ms" }.join(", ")
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
#
|
|
3
|
+
# Map-reduce with a mutable reduce side: workers count their chunk locally,
|
|
4
|
+
# then send the merge to the ActorHash as work. The tallies live unfrozen in
|
|
5
|
+
# the owner Ractor and are bumped in place -- no freezing a Hash per update,
|
|
6
|
+
# which is what the lock family would have demanded.
|
|
7
|
+
#
|
|
8
|
+
# ruby -Ilib examples/07_word_count.rb
|
|
9
|
+
Warning[:experimental] = false
|
|
10
|
+
require "ractor/sharing"
|
|
11
|
+
|
|
12
|
+
TEXT = ("the quick brown fox jumps over the lazy dog " * 50 +
|
|
13
|
+
"sator arepo tenet opera rotas " * 30).freeze
|
|
14
|
+
CHUNKS = TEXT.scan(/(?:\S+\s*){1,40}/).map(&:freeze).freeze
|
|
15
|
+
|
|
16
|
+
tally = Ractor::ActorHash.new
|
|
17
|
+
|
|
18
|
+
workers = 4.times.map do |i|
|
|
19
|
+
Ractor.new(tally, CHUNKS, i) do |t, chunks, offset|
|
|
20
|
+
chunks.each_with_index do |chunk, j|
|
|
21
|
+
next unless j % 4 == offset
|
|
22
|
+
|
|
23
|
+
counts = chunk.split.tally
|
|
24
|
+
# Fire and forget: the block runs on the owner, against the real hash.
|
|
25
|
+
t.async_call(counts) { |h, c| c.each { |w, n| h[w] = (h[w] || 0) + n } }
|
|
26
|
+
end
|
|
27
|
+
t[:the] # one sync read = my merges have all landed (per-sender FIFO)
|
|
28
|
+
:ok
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
workers.each(&:join)
|
|
32
|
+
|
|
33
|
+
expected = TEXT.split.tally
|
|
34
|
+
got = tally.to_h
|
|
35
|
+
abort "tallies differ" unless got == expected
|
|
36
|
+
puts "ok: #{got.values.sum} words, #{got.size} distinct; 'the' => #{got['the']}, 'tenet' => #{got['tenet']}"
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
#
|
|
3
|
+
# An LRU cache is a Hash plus an eviction order, mutated on every hit -- state
|
|
4
|
+
# you have no intention of freezing. Give it a Ractor of its own: callers see
|
|
5
|
+
# ordinary methods, the owner runs them one at a time, and the object graph
|
|
6
|
+
# never leaves home.
|
|
7
|
+
#
|
|
8
|
+
# ruby -Ilib examples/08_lru_cache.rb
|
|
9
|
+
Warning[:experimental] = false
|
|
10
|
+
require "ractor/sharing"
|
|
11
|
+
|
|
12
|
+
class LRUCache < Ractor::ActiveObject
|
|
13
|
+
def initialize(capacity)
|
|
14
|
+
@capacity = capacity
|
|
15
|
+
@h = {} # key => value
|
|
16
|
+
@order = [] # least recently used first
|
|
17
|
+
@hits = @misses = 0
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
sync def get(key)
|
|
21
|
+
if @h.key?(key)
|
|
22
|
+
@hits += 1
|
|
23
|
+
@order.delete(key)
|
|
24
|
+
@order << key
|
|
25
|
+
@h[key]
|
|
26
|
+
else
|
|
27
|
+
@misses += 1
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
sync def put(key, value)
|
|
33
|
+
@order.delete(key)
|
|
34
|
+
@order << key
|
|
35
|
+
@h[key] = value
|
|
36
|
+
@h.delete(@order.shift) while @h.size > @capacity
|
|
37
|
+
value
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
sync def stats = { size: @h.size, hits: @hits, misses: @misses }.freeze
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
cache = LRUCache.new(8)
|
|
44
|
+
|
|
45
|
+
rs = 4.times.map do
|
|
46
|
+
Ractor.new(cache) do |c|
|
|
47
|
+
200.times do |i|
|
|
48
|
+
key = "item-#{(i * 7) % 20}"
|
|
49
|
+
c.get(key) or c.put(key, "value of #{key}")
|
|
50
|
+
end
|
|
51
|
+
:ok
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
rs.each(&:join)
|
|
55
|
+
|
|
56
|
+
s = cache.stats
|
|
57
|
+
abort "capacity breached: #{s}" if s[:size] > 8
|
|
58
|
+
abort "nothing happened: #{s}" unless s[:hits] + s[:misses] == 800
|
|
59
|
+
puts "ok: #{s[:size]}/8 slots, #{s[:hits]} hits, #{s[:misses]} misses across 4 Ractors"
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
#
|
|
3
|
+
# A fire-and-forget audit log: workers must not wait for the log to be written,
|
|
4
|
+
# so the method is async -- the call costs a send, not a round trip (about 5x
|
|
5
|
+
# cheaper on our bench machine). Reads are sync, because the answer is the point.
|
|
6
|
+
#
|
|
7
|
+
# ruby -Ilib examples/09_audit_log.rb
|
|
8
|
+
Warning[:experimental] = false
|
|
9
|
+
require "ractor/sharing"
|
|
10
|
+
|
|
11
|
+
class AuditLog < Ractor::ActiveObject
|
|
12
|
+
def initialize = @lines = []
|
|
13
|
+
async def record(who, what) = @lines << "#{@lines.size}: #{who} #{what}"
|
|
14
|
+
sync def size = @lines.size
|
|
15
|
+
sync def tail(n) = @lines.last(n).dup
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
log = AuditLog.new
|
|
19
|
+
|
|
20
|
+
workers = %w[ann ben cho dee].map do |name|
|
|
21
|
+
Ractor.new(log, name) do |l, me|
|
|
22
|
+
150.times { |i| l.record(me, "step #{i}") } # never waits
|
|
23
|
+
l.size # one sync call = my records landed
|
|
24
|
+
:ok
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
workers.each(&:join)
|
|
28
|
+
|
|
29
|
+
abort "records lost: #{log.size}" unless log.size == 600
|
|
30
|
+
abort "sequence torn" unless log.tail(600).each_with_index.all? { |line, i| line.start_with?("#{i}:") }
|
|
31
|
+
puts "ok: 600 records, strictly ordered by the owner; tail: #{log.tail(1).first.inspect}"
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
#
|
|
3
|
+
# Fan out, then gather: ask several slow services for quotes as futures, do
|
|
4
|
+
# other work, and only then sit down to wait. Each future is a ticket for an
|
|
5
|
+
# answer that is being computed while you are not looking.
|
|
6
|
+
#
|
|
7
|
+
# ruby -Ilib examples/10_price_quotes.rb
|
|
8
|
+
Warning[:experimental] = false
|
|
9
|
+
require "ractor/sharing"
|
|
10
|
+
|
|
11
|
+
class PriceService < Ractor::ActiveObject
|
|
12
|
+
def initialize(vendor, base)
|
|
13
|
+
@vendor = vendor
|
|
14
|
+
@base = base
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
future def quote(item)
|
|
18
|
+
sleep 0.05 # the network, allegedly
|
|
19
|
+
{ vendor: @vendor, item: item, price: @base + item.sum % 17 }.freeze
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
services = { "acme" => 100, "moma" => 90, "zenith" => 95 }.map { |v, base| PriceService.new(v, base) }
|
|
24
|
+
|
|
25
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
26
|
+
futures = services.map { |s| s.quote("garden gnome") } # all three are working now
|
|
27
|
+
elapsed_to_fire = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
|
|
28
|
+
|
|
29
|
+
quotes = futures.map(&:value) # now we wait
|
|
30
|
+
elapsed_total = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
|
|
31
|
+
|
|
32
|
+
best = quotes.min_by { |q| q[:price] }
|
|
33
|
+
abort "missing quotes: #{quotes}" unless quotes.size == 3 && quotes.all? { |q| q[:price].positive? }
|
|
34
|
+
abort "firing the futures blocked" if elapsed_to_fire > 0.04
|
|
35
|
+
puts format("ok: 3 quotes in %.0f ms (three 50 ms sleeps, overlapped); best: %s at %d",
|
|
36
|
+
elapsed_total * 1000, best[:vendor], best[:price])
|