Threads, mutexes and queues
Mutex
Two threads that add to the same variable lose updates: the read, the add and the write are three steps, and the scheduler can switch between them. A Mutex makes the three steps one:
mutex = Mutex.new
total = 0
10.times.map do
Thread.new { mutex.synchronize { total += 1 } }
end.each(&:join)
p total
ruby -e 'm = Mutex.new; t = 0; 10.times.map { Thread.new { m.synchronize { t += 1 } } }.each(&:join); p t'
10
Ruby's GVL means only one thread runs Ruby code at a time, so the counter race is narrower than in C. It is not gone: a context switch can happen between the read and the write, and the result is a lost increment that appears once in a thousand runs.
Queue
Queue is the better default: a thread-safe hand-over with no shared variable to guard. push adds and pop takes, blocking when the queue is empty. A sentinel value ends the consumer:
queue = Queue.new
producer = Thread.new { 3.times { |i| queue << i }; queue << :done }
consumer = Thread.new do
loop do
item = queue.pop
break if item == :done
puts item
end
end
producer.join
consumer.join
A producer and a consumer
The pair from the previous section with a counter instead of a print. The mutex is only around the shared total; the queue does its own locking:
queue = Queue.new
total = 0
mutex = Mutex.new
producer = Thread.new do
3.times { |i| queue << i }
queue << :done
end
consumer = Thread.new do
loop do
item = queue.pop
break if item == :done
mutex.synchronize { total += item }
end
end
producer.join
consumer.join
p total
ruby threads.rb
3
The consumer blocks on pop until there is work, so it does not spin. The sentinel ends the loop; a queue with no sentinel and no timeout is a program that hangs on the last join, which is the usual bug in a hand-written worker pool.
ConditionVariable
When a thread must wait for a condition and not for an item, ConditionVariable is the tool. It waits on a mutex and wakes when another thread signals:
mutex = Mutex.new
ready = false
cond = ConditionVariable.new
waiter = Thread.new do
mutex.synchronize do
cond.wait(mutex) until ready
puts "woke"
end
end
sleep 0.1
mutex.synchronize do
ready = true
cond.signal
end
waiter.join
ruby condvar.rb
woke
The until ready is not decoration: a condition variable can wake spuriously, and the loop re-checks. A signal wakes one waiter, broadcast wakes all of them.
What to watch for
A mutex held across a network call serialises every thread behind the
slowest one. Hold it around the shared data and release it before the
I/O.
A thread that raises does not stop the program until it is joined, and
the exception appears at the join. Check
thread.report_on_exception or join every thread
you start, or a failure disappears.
Next: Processes, fork and signals
Article author: Arthur Isaev