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