Skip to content

Commit 4d5226d

Browse files
authored
Merge pull request #891 from ruby-concurrency/tvar
Simplify TVar implementation
2 parents cf3a8ae + 1f93ad9 commit 4d5226d

File tree

5 files changed

+85
-206
lines changed

5 files changed

+85
-206
lines changed

docs-source/tvar.md

Lines changed: 3 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -24,16 +24,9 @@ We implement nested transactions by flattening.
2424
We only support strong isolation if you use the API correctly. In order words,
2525
we do not support strong isolation.
2626

27-
Our implementation uses a very simple two-phased locking with versioned locks
28-
algorithm and lazy writes, as per [1].
29-
30-
See:
31-
32-
1. T. Harris, J. Larus, and R. Rajwar. Transactional Memory. Morgan & Claypool, second edition, 2010.
33-
34-
Note that this implementation allows transactions to continue in a zombie state
35-
with inconsistent reads, so it's possible for the marked exception to be raised
36-
in the example below.
27+
Our implementation uses a very simple algorithm that locks each `TVar` when it
28+
is first read or written. If it cannot lock a `TVar` it aborts and retries.
29+
There is no contention manager so competing transactions may retry eternally.
3730

3831
```ruby
3932
require 'concurrent-ruby'
@@ -216,97 +209,3 @@ big global lock on them, and that if any exception is raised in the block, it
216209
will be as if the block never happened. But also keep in mind the important
217210
points we detailed right at the start of the article about side effects and
218211
repeated execution.
219-
220-
## Evaluation
221-
222-
We evaluated the performance of our `TVar` implementation using a bank account
223-
simulation with a range of synchronisation implementations. The simulation
224-
maintains a set of bank account totals, and runs transactions that either get a
225-
summary statement of multiple accounts (a read-only operation) or transfers a
226-
sum from one account to another (a read-write operation).
227-
228-
We implemented a bank that does not use any synchronisation (and so creates
229-
inconsistent totals in accounts), one that uses a single global (or 'coarse')
230-
lock (and so won't scale at all), one that uses one lock per account (and so has
231-
a complicated system for locking in the correct order) and one using our `TVar`
232-
and `atomically`.
233-
234-
We ran 1 million transactions divided equally between a varying number of
235-
threads on a system that has at least that many physical cores. The transactions
236-
are made up of a varying mixture of read-only and read-write transactions. We
237-
ran each set of transactions thirty times, discarding the first ten and then
238-
taking an algebraic mean. These graphs show only the simple mean. Our `tvars-
239-
experiments` branch includes the benchmark used, full details of the test
240-
system, and all the raw data.
241-
242-
Using JRuby using 75% read-write transactions, we can compare how the different
243-
implementations of bank accounts scales to more cores. That is, how much faster
244-
it runs if you use more cores.
245-
246-
![](https://raw.githubusercontent.com/ruby-concurrency/concurrent-ruby/master/doc/images/tvar/implementation-scalability.png)
247-
248-
We see that the coarse lock implementation does not scale at all, and in fact
249-
with more cores only wastes more time in contention for the single global lock.
250-
We see that the unsynchronised implementation doesn't seem to scale well - which
251-
is strange as there should be no overhead, but we'll explain that in a second.
252-
We see that the fine lock implementation seems to scale better, and that the
253-
`TVar` implementation scales the best.
254-
255-
So the `TVar` implementation *scales* very well, but how absolutely fast is it?
256-
257-
![](https://raw.githubusercontent.com/ruby-concurrency/concurrent-ruby/master/doc/images/tvar/implementation-absolute.png)
258-
259-
Well, that's the downside. The unsynchronised implementation doesn't scale well
260-
because it's so fast in the first place, and probably because we're bound on
261-
access to the memory - the threads don't have much work to do, so no matter how
262-
many threads we have the system is almost always reaching out to the L3 cache or
263-
main memory. However remember that the unsynchronised implementation isn't
264-
correct - the totals are wrong at the end. The coarse lock implementation has an
265-
overhead of locking and unlocking. The fine lock implementation has a greater
266-
overhead as as the locking scheme is complicated to avoid deadlock. It scales
267-
better, however, actually allowing transactions to be processed in parallel. The
268-
`TVar` implementation has a greater overhead still - and it's pretty huge. That
269-
overhead is the cost for the simple programming model of an atomic block.
270-
271-
So that's what `TVar` gives you at the moment - great scalability, but it has a
272-
high overhead. That's pretty much the state of software transactional memory in
273-
general. Perhaps hardware transactional memory will help us, or perhaps we're
274-
happy anyway with the simpler and safer programming model that the `TVar` gives
275-
us.
276-
277-
We can also use this experiment to compare different implementations of Ruby. We
278-
looked at just the `TVar` implementation and compared MRI 2.1.1, Rubinius 2.2.6,
279-
and JRuby 1.7.11, again at 75% write transactions.
280-
281-
![](https://raw.githubusercontent.com/ruby-concurrency/concurrent-ruby/master/doc/images/tvar/ruby-scalability.png)
282-
283-
We see that MRI provides no scalability, due to the global interpreter lock
284-
(GIL). JRuby seems to scale better than Rubinius for this workload (there are of
285-
course other workloads).
286-
287-
As before we should also look at the absolute performance, not just the
288-
scalability.
289-
290-
![](https://raw.githubusercontent.com/ruby-concurrency/concurrent-ruby/master/doc/images/tvar/ruby-absolute.png)
291-
292-
Again, JRuby seems to be faster than Rubinius for this experiment.
293-
Interestingly, Rubinius looks slower than MRI for 1 core, but we can get around
294-
that by using more cores.
295-
296-
We've used 75% read-write transactions throughout. We'll just take a quick look
297-
at how the scalability varies for different workloads, for scaling between 1 and
298-
2 threads. We'll admit that we used 75% read-write just because it emphasised
299-
the differences.
300-
301-
![](https://raw.githubusercontent.com/ruby-concurrency/concurrent-ruby/master/doc/images/tvar/implementation-write-proportion-scalability.png)
302-
303-
Finally, we can also run on a larger machine. We repeated the experiment using a
304-
machine with 64 physical cores and JRuby.
305-
306-
![](https://raw.githubusercontent.com/ruby-concurrency/concurrent-ruby/master/doc/images/tvar/implementation-scalability.png)
307-
308-
![](https://raw.githubusercontent.com/ruby-concurrency/concurrent-ruby/master/doc/images/tvar/implementation-absolute.png)
309-
310-
Here you can see that `TVar` does become absolutely faster than using a global
311-
lock, at the slightly ridiculously thread-count of 50. It's probably not
312-
statistically significant anyway.

lib/concurrent-ruby/concurrent/tvar.rb

Lines changed: 20 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ class TVar < Synchronization::Object
1515
# Create a new `TVar` with an initial value.
1616
def initialize(value)
1717
@value = value
18-
@version = 0
1918
@lock = Mutex.new
2019
end
2120

@@ -43,16 +42,6 @@ def unsafe_value=(value) # :nodoc:
4342
@value = value
4443
end
4544

46-
# @!visibility private
47-
def unsafe_version # :nodoc:
48-
@version
49-
end
50-
51-
# @!visibility private
52-
def unsafe_increment_version # :nodoc:
53-
@version += 1
54-
end
55-
5645
# @!visibility private
5746
def unsafe_lock # :nodoc:
5847
@lock
@@ -164,86 +153,57 @@ class Transaction
164153

165154
ABORTED = ::Object.new
166155

167-
ReadLogEntry = Struct.new(:tvar, :version)
156+
OpenEntry = Struct.new(:value, :modified)
168157

169158
AbortError = Class.new(StandardError)
170159
LeaveError = Class.new(StandardError)
171160

172161
def initialize
173-
@read_log = []
174-
@write_log = {}
162+
@open_tvars = {}
175163
end
176164

177165
def read(tvar)
178-
Concurrent::abort_transaction unless valid?
179-
180-
if @write_log.has_key? tvar
181-
@write_log[tvar]
182-
else
183-
@read_log.push(ReadLogEntry.new(tvar, tvar.unsafe_version))
184-
tvar.unsafe_value
185-
end
166+
entry = open(tvar)
167+
entry.value
186168
end
187169

188170
def write(tvar, value)
189-
# Have we already written to this TVar?
171+
entry = open(tvar)
172+
entry.modified = true
173+
entry.value = value
174+
end
190175

191-
if @write_log.has_key? tvar
192-
# Record the value written
193-
@write_log[tvar] = value
194-
else
195-
# Try to lock the TVar
176+
def open(tvar)
177+
entry = @open_tvars[tvar]
196178

179+
unless entry
197180
unless tvar.unsafe_lock.try_lock
198-
# Someone else is writing to this TVar - abort
199181
Concurrent::abort_transaction
200182
end
201183

202-
# Record the value written
203-
204-
@write_log[tvar] = value
205-
206-
# If we previously read from it, check the version hasn't changed
207-
208-
@read_log.each do |log_entry|
209-
if log_entry.tvar == tvar and tvar.unsafe_version > log_entry.version
210-
Concurrent::abort_transaction
211-
end
212-
end
184+
entry = OpenEntry.new(tvar.unsafe_value, false)
185+
@open_tvars[tvar] = entry
213186
end
187+
188+
entry
214189
end
215190

216191
def abort
217192
unlock
218193
end
219194

220195
def commit
221-
return false unless valid?
222-
223-
@write_log.each_pair do |tvar, value|
224-
tvar.unsafe_value = value
225-
tvar.unsafe_increment_version
226-
end
227-
228-
unlock
229-
230-
true
231-
end
232-
233-
def valid?
234-
@read_log.each do |log_entry|
235-
unless @write_log.has_key? log_entry.tvar
236-
if log_entry.tvar.unsafe_version > log_entry.version
237-
return false
238-
end
196+
@open_tvars.each do |tvar, entry|
197+
if entry.modified
198+
tvar.unsafe_value = entry.value
239199
end
240200
end
241201

242-
true
202+
unlock
243203
end
244204

245205
def unlock
246-
@write_log.each_key do |tvar|
206+
@open_tvars.each_key do |tvar|
247207
tvar.unsafe_lock.unlock
248208
end
249209
end

spec/concurrent/tvar_spec.rb

Lines changed: 0 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -106,48 +106,6 @@ module Concurrent
106106
expect(t2.value).to eq 0
107107
end
108108

109-
it 'provides weak isolation' do
110-
t = TVar.new(0)
111-
112-
a = CountDownLatch.new
113-
b = CountDownLatch.new
114-
115-
in_thread do
116-
Concurrent::atomically do
117-
t.value = 1
118-
a.count_down
119-
b.wait
120-
Concurrent.leave_transaction
121-
end
122-
end
123-
124-
Concurrent::atomically do
125-
a.wait
126-
expect(t.value).to eq 0
127-
b.count_down
128-
Concurrent.leave_transaction
129-
end
130-
end
131-
132-
it 'is implemented with lazy writes' do
133-
t = TVar.new(0)
134-
135-
a = CountDownLatch.new
136-
b = CountDownLatch.new
137-
138-
in_thread do
139-
Concurrent::atomically do
140-
t.value = 1
141-
a.count_down
142-
b.wait
143-
end
144-
end
145-
146-
a.wait
147-
expect(t.value).to eq 0
148-
b.count_down
149-
end
150-
151109
it 'nests' do
152110
t = TVar.new(0)
153111

test/stress/tvar/atomicity.rb

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
require 'concurrent-ruby'
2+
3+
v1 = Concurrent::TVar.new(0)
4+
v2 = Concurrent::TVar.new(0)
5+
6+
Thread.new do
7+
loop do
8+
Concurrent.atomically do
9+
v1.value += 1
10+
v2.value += 1
11+
end
12+
end
13+
end
14+
15+
loop do
16+
a, b = Concurrent.atomically {
17+
a = v1.value
18+
b = v2.value
19+
[a, b]
20+
}
21+
raise if a != b
22+
p a
23+
24+
a, b = Concurrent.atomically {
25+
b = v2.value
26+
a = v1.value
27+
[a, b]
28+
}
29+
raise if a != b
30+
p a
31+
end

test/stress/tvar/opacity.rb

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
require 'concurrent-ruby'
2+
3+
v1 = Concurrent::TVar.new(0)
4+
v2 = Concurrent::TVar.new(0)
5+
6+
Thread.new do
7+
loop do
8+
Concurrent.atomically do
9+
v1.value += 1
10+
v2.value += 1
11+
end
12+
end
13+
end
14+
15+
loop do
16+
a, b = Concurrent.atomically {
17+
a = v1.value
18+
b = v2.value
19+
raise if a != b
20+
[a, b]
21+
}
22+
p a
23+
24+
a, b = Concurrent.atomically {
25+
b = v2.value
26+
a = v1.value
27+
raise if a != b
28+
[a, b]
29+
}
30+
p a
31+
end

0 commit comments

Comments
 (0)