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.
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; any
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
- counter = Ractor::LockVar.new(0)
12
+ roster = Ractor::LockVar.new([])
12
13
 
13
- rs = 4.times.map do
14
- Ractor.new(counter) do |c|
15
- 1000.times { c.update {|n| n + 1 } }
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 counter.value #=> 4000
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 must be shareable
43
+ ### The value is made shareable
43
44
 
44
- A LockVar holds one **shareable** object, and so does everything you store into
45
- it. Anything else raises `ArgumentError`:
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({}) # => 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
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
- 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
+ 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 refusing to store a sum that is not shareable.
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).freeze }
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({}.freeze)
107
- h.update { it.merge(key: 1).freeze }
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 }.freeze }
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
- **Fought over, neither scales and `TVar` stays about 2× ahead**, because the
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 everything that block changes takes effect together or
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({ mode: :idle }.freeze)
15
- version = Ractor::TVar.new("v1".freeze)
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).freeze
19
- version.value = "v2".freeze # nobody sees v1 running, or v2 idle
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([].freeze)
27
- Ractor.atomically { seen.value = (seen.value + [:x]).freeze }
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) # any shareable object
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 must be shareable; `ArgumentError` otherwise.
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
- **Fought over, retrying beats waiting.** Sixteen Ractors updating the *same*
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])