The Thread API : Concurrent, colorless ruby
ππΌ This is part of series on concurrency, parallelism and asynchronous programming in Ruby. Itβs a deep dive, so itβs divided into 12 main parts:
- Your Ruby programs are always multi-threaded: Part 1
- Your Ruby programs are always multi-threaded: Part 2
- Consistent, request-local state
- Ruby methods are colorless
- The Thread API: Concurrent, colorless Ruby
- Interrupting Threads: Concurrent, colorless Ruby
- Thread and its MaNy friends: Concurrent, colorless Ruby
- Fibers: Concurrent, colorless Ruby
- Processes, Ractors and alternative runtimes: Parallel Ruby
- Scaling concurrency: Streaming Ruby
- Abstracted, concurrent Ruby
- Closing thoughts, kicking the tires and tangents
- How I dive into CRuby concurrency
Youβre reading βThe Thread API: Concurrent, colorless Rubyβ. Iβll update the links as each part is released, and include these links in each post.
The Thread API π§΅
Weβre going to break down threads into three parts:
- The Thread API - All the tools available to you in the Ruby runtime to manage threads, and how they work
- Interrupting Threads - How threads get stuck, and how to shut them down safely
- Thread and its MaNy friends - Thread architecture and the GVL
This post covers the Thread API. Weβll go over every method available to you, why they matter, how to call them, and often how popular open source projects use them.
Donβt let the cute mascot fool you
Before we start digging into threads, Iβd like to make a small disclaimer: writing safe, deterministic, bug-free threaded code is hard.
I think understanding threads is valuable knowledge. After all, whether you explicitly use threads or not, your Ruby programs are always multi-threaded. If youβre always in the context of a thread, itβs helpful to know how they work behind the scenes. Even if the most you ever do is set a thread count on a server, it still helps to know how they work - it better informs what changing those numbers can and canβt do for you.
Behind the simplicity of the thread interface lives a lot of complexity. An OS thread gives you the literal ability to perform tasks in parallel. And once things can run in parallel, our sequential thinking starts to fail us. Itβs difficult to correctly read through code step by step if at any point that step by step code can swap out with another separate piece of code. No warning, no ability to determine exactly where your program will switch to next.
Itβs the same when multiple people work on some tasks in parallel - you encounter communication breakdown. There can be contention over a shared resource. You can undo someone elseβs work, or leave them with inconsistent information that causes them to finish their task, but incorrectly.
Thereβs a cognitive bias known as the Dunning Kruger Effect. Essentially someone with limited experience in something can overestimate their abilities, or the complexity of the task. Thread.new {}
feels pretty simple - itβs easy to underestimate what goes into it. Diving into threads together helps us realize they wield a lot of power!
Thereβs a reason that most threaded code in gems like Rails, SolidQueue and GoodJob use the concurrent-ruby
gem. Abstractions are your friend. Weβll dig into abstractions later in the series - for now weβll learn about threads directly. But dont stop here! Learn the foundation, and when you need it yourself, learn the abstractions and tools that make it easier.
source: Eli and JP Camara, https://x.com/logicalcomic
This post will dig into all the options available in Ruby out of the box. Youβll learn each thread method and what theyβre for, and weβll discuss how to coordinate your threads once you create them. Letβs go!
A thread api primer (with examples!)
Weβll start off with some details on how you interact with threads. Letβs create a thread!
Run that code1 ππΌβ¦ hmmm, nothing happens. Nothing prints and the program exits silently. What happened?
Everything starts off running in the βmain threadβ, which is accessible by calling Thread.main
. When a new thread is created itβs like a branch off of the main thread. Those branches exist independently and the main thread doesnβt wait for them to finish by default.
Waiting for a thread to complete
join
To wait on a specific thread, you join
that thread and the current thread together. Here we have the main thread join with t
:
When the thread finishes, the thread object is returned from join
.
βββ
value
You can also join on a thread and ask it for the last value returned using value
:
The simplest way to run some work concurrently is to start up several threads and then iterate over each one calling join
or value
. Weβll make 4 http requests here, and the overall time will take as long as the longest request:
Make sure you let each thread start first! If you join too early, you end up running them sequentially:
uuids = []
uuids << generate_uuid_thread.value
uuids << generate_uuid_thread.value
uuids << generate_uuid_thread.value
uuids << generate_uuid_thread.value
puts uuids
# 6b76167d-9bac-45dc-8d0b-5b7af865b843
# a025a125-51a5-4773-b78d-ab93fba02eb3
# 98befb95-adf7-4c89-9fd0-d10f6b2a3d7a
# 95f486fd-5bc6-46fe-b65a-c52a87140dfb
The end result looks identical, and the output is but the execution is totally different. When we create all the threads up front, certain operations can run in parallel:
- Threads 1 through 4 get created and are βrunnableβ
- The main thread blocks on the first iteration to call
map(&:join)
- As each thread blocks on the HTTP call, they run in parallel
- The loop takes as long as the longest thread, so if it takes us 50ms for 3 requests, and the 4th is 100ms, we spend around 100ms total
Calling generate_uuid_thread.value
one at a time, weβre just running the code sequentially:
- Thread 1 gets created and run, returning its
value
- Thread 2 gets created and run, returning its
value
- Same for threads 3 and 4
- Weβre running sequentially, so the threads provided no value. It would take roughly 250ms. If anything, the threads likely added overhead.
β οΈ Never actually generate a uuid using a web service, thatβs crazy π€£
βββ
join(timeout_in_seconds)
You can also limit the amount of time you join
. If youβre calling join
, typically you just want to wait however long it takes. But using join(seconds)
could be useful to periodically pop into some other work or alert that youβve been running too long. join(seconds)
will return the thread while it is still running, and return nil
once it finishes.
You could use it to intermittently check for a thread finishing without wasting too many CPU cycles:
In the Puma web server, it uses join(seconds)
to manage shutting down its thread pool. It iterates over each thread, adjusting the join
timeout based on how much time has elapsed since starting the method:
It uses join(timeout)
to try and let each thread finish before forcing a shutdown with raise
or kill
. The reject!
removes any threads that finish during that time - nil
returned from join
means the thread is still running, otherwise the thread object is returned (removing it from the array).
π A βthread poolβ is a reusable, typically fixed size set of threads. Rather than create brand new threads every time they are needed, a thread pool saves on thread creation by reusing threads to perform operations. They are generally used to save on thread creation cost and limit the number of threads running at a time.
concurrent-ruby
comes with several types of thread pools.
Error Reporting
report_on_exception
and abort_on_exception
What happens if something fails in your thread?
Running that ππΌ, it just runs forever. Even though our thread has failed, it never impacts the program. We will see an error printed in our console, however:
You can control whether it fails silently, but the default is to report the error. Keep that default - silent errors are not your friend. But if you really needed to, you can change it globally or with a per thread setting:
What if you want to report it in the current thread? Similar to join
ing execution with a thread to wait for it to finish, you need to join with it to raise its error:
You can also force the thread to raise, even when running independently:
This can be set globally as well:
Rack timeout uses it in its Scheduler thread, applying it using Thread.current
:
In general, you donβt see this much in a production setting. But it could be useful if youβre writing a one-off script and you want any thread that fails to kill the program.
Tracking threads
Can we find out what threads are running?
Thread.list
Thread.list
will give you every thread that hasnβt already finished:
name
Okβ¦ but how can you differentiate them? Thereβs a few ways you can achieve that:
- File information
- Setting a name
- Inheritance
File information gets printed by default, and shows you the name of the file and what line the thread was started on:
The file info is useful, but often your threads are all started from the same place, so it doesnβt tell you much:
In that case, you can set a name
on each thread to differentiate them:
The concurrent-ruby gem uses this to help differentiate threads created in its ThreadPoolExecutor:
The Honeybadger gem runs a background thread when sending errors to their error reporting service. To differentiate their thread, they use inheritance. The information about each thread includes the class so it makes it easy to identify:
βββ
status
, alive?
and stop?
Can you keep track of the status of a running thread? Yep! Threads operate in one of 5 states - theyβre not the most intuitive, but theyβre what youβve got:
βrunβ
- the thread is runningβsleepβ
- the thread is βsleepingβ. There is some blocking operation going on or the thread went to sleep or was put to sleep by the thread schedulerβabortingβ
- the thread has failed but hasnβt finished running yetnil
- an error was raised and the thread is deadfalse
- the thread finished normally
Letβs demonstrate some statuses:
Youβll notice a few things about the above:
- I didnβt show βabortingβ. Thatβs because Iβm not sure regular code could ever see that status. Catching a failed thread that hasnβt finished yet seems pretty hard to do. Internally the CRuby thread needs to be in a
to_kill
state. I would love it if someone knows a way to demonstrate it, though! It would perhaps require the assistance of a core CRuby wizard π§ββοΈ. - βsleepβ is not specific to the
sleep
method. In threadd
we are making anIO.select
call that takes three seconds. So the thread blocks while waiting, hence it is βsleepβing. - Since you canβt run Ruby code in multiple threads in parallel, you pretty much would only ever see βrunβ on the current, active thread.
It would be nice if the information was a bit more readable. We can put together a little helper to make the output clearer. Weβll also add in one more internal status not directly exposed by status
:
Using our new helper, we get a bit more readability and depth into our thread statuses.
- Using
join
we are able to return more information about the failed thread - βfailedβ instead of nil, and the actual error it failed with - We return βfinishedβ instead of false for a successful finish
sleep_forever
letβs us differentiate actively blocked threads (like one doingIO.select
) from a thread that is actually stopped, and wonβt run again without intervention. Weβll talk more aboutThread.stop
in the next section
In addition to status
, we can also use alive?
and stop?
to check on a threads status:
Thread Scheduling
There are several methods for either taking direct action, or suggesting action to the thread scheduler. Before using them, keep in mind that youβre probably not smarter than the Ruby thread scheduler. It tries to do what makes the most sense for the runtime, and itβs been tuned extensively. But these tools exist, and they get used, so letβs discuss them a bit.
Thread.pass
, wakeup
, Thread.stop
, run
, and priority
pass
and wakeup
are kind of like nudges to the runtime. They request a particular action, but the scheduler does not have to honor them. Thread.pass
tells the thread scheduler it can βpassβ control to another thread:
wakeup
marks a thread as eligible for scheduling. Itβs up to the thread scheduler whether that happens:
Thread.stop
and run
are more direct commands to the thread scheduler:
Only one second has passed, but run
caused the sleep
to finish early.
priority
gives a hint to the scheduler of which thread should be given more runtime. The thread docs have a good example of this:
The threads run forever, but thread a
gets higher priority so it adds to the counter more often.
For an open source example of run
, you can check the rack-timeout
scheduler:
Thread.pass
is a recommendation from Mike Perham if you have your jobs hogging CPU:
Thread Shutdown
raise
and kill
You want to killβ¦ me? π₯Ί
β οΈ TL;DR You shouldnβt use these methods unless you really know what youβre doing. Instead, interrupt your thread safely. Incidentally, you should also avoid the timeout module. Weβll dig deep into raise
and kill
in the next post on βInterrupting Threadsβ.
Interrupt your thread safely
Instead of killing your thread, set it up to be interruptible. Most mature, threaded frameworks operate this way.
Donβt use timeout
If you see this in code, be concerned:
For some reason, the timeout
gem itself doesnβt warn about any issues. But Mike Perham summarizes it best:
Thereβs nothing that exactly matches what timeout offers: a blanket way of timing out any operation after the specified time limit. But instead of using the timeout
gem, there is a repository called The Ultimate Guide to Ruby Timeouts. It shows you how to set timeouts safely for basically every blocking operation you could care about timing out. For instance, how to properly handle timeouts using the redis
gem:
The one piece mentioned in that repository you should leave alone: Net::HTTP
open_timeout
. Behind the scenes it uses the timeout
module π
ββοΈ. Leave the 60 second default, it should almost never impact you, and youβre probably worse off lowering it.
βββ
Thread.handle_interrupt
A thread can be externally βinterruptedβ by a few things:
Thread#kill
Thread#raise
- Your program being exited
- A signal, like Ctrl+C
handle_interrupt
gives you the ability to control how your program reacts to 1-32.
Because it primarily matters in the context of raise
and kill
, weβll discuss it in the next post on βInterrupting Threadsβ.
βββ
Process._fork
Process#_fork
isnβt a thread api, but itβs good be aware of for your threaded code.
Whatβs happens to a thread when a process forks?
You canβt bring your threads with you when you fork. But you can recreate them, using _fork
!
Itβs a little strange of a setup. Youβre hooking into the inheritance chain for Process._fork
, so you need to call super
directly. No one calls _fork
directly, super
ultimately returns the result of fork
itself. If the result is 0
, weβre in the forked process, which means we can perform any kind of post-fork action. In the case of managing threads, that would involve recreating them.
The connection_pool
gem uses this to run an after_fork
method. It uses it to close out connections.
The redis-client
gem uses _fork
to track the pid
in PIDCache
, so it can determine whether it needs to close the inherited socket (threads are not inherited when forking, but file descriptors are).
Coordinating Threads
Now that we know the different methods of interacting with a thread directly, how can we coordinate threads together safely?
π If you can avoid it, donβt coordinate at all! Immutable structures, or isolated work are your friends.
Mutex
Mutex
is the core thread coordination primitive in Ruby. It stands for mutual exclusion, and it allows you to control single thread access to a particular resource. A thread or fiber acquires a lock on the mutex, and it is the only thing that can unlock that mutex.
π If you know database locks, a
Mutex
basically operates like an exclusive lock.
π Youβre better off not sharing objects, it keeps things simpler. Iβll reference my own note from βYour Ruby programs are always multi-threaded: Part 2β:
My personal metric is that the right amount of mutexes in my code is zero. If I am using a mutex, I think hard to figure out a way to avoid it because it means Iβm opening up myself and future devs to a lot of cognitive overhead: you need to think critically anytime you make a change relating to mutex code.
If youβre a library or framework author they may be unavoidable at some point to do interesting or useful things. In my own application code, I can pretty much always avoid them.
Earlier we used a class from concurrent-ruby
called AtomicBoolean
to implement an interruptible thread. What if we wanted to implement it ourselves?
To make sure our data stays consistent, we synchronize
every access. That way we know we canβt corrupt anything and we have a consistent view of β@value```:
It doesnβt make sure we do things in any expected order, but itβs a foolproof way of having proper access and visibility.
π Truthfully, CRuby doesnβt need this kind of corruption guarantee. But true parallel Ruby runtimes like JRuby and TruffleRuby do.
synchronize
is the probably the only method youβll find yourself using on a Mutex
. Look in different projects and thatβs 95% of all Mutex
usage. But there are more methods available you may see on occasion:
lock
unlock
try_lock
owned?
locked?
sleep
lock
and unlock
can be used to recreate what synchronize
does:
try_lock
lets you attempt a lock without blocking. When you call lock
or synchronize
, your code will block until you are able to acquire a lock:
But try_lock
will just return a boolean if the lock worked:
Notice the owned?
call in the ensure
? We donβt know if the lock was successfully acquired, so we only call unlock
if the lock is owned?
. Being owned?
means the current thread successfully acquired the lock, and is the current βownerβ. If you tried to call unlock
on a thread that wasnβt the owner, an error would be raised.
locked?
allows you to check if the lock is owned by some thread. Seems susceptible to some race conditions, but you could use it to determine if you need to perform an action. The aws-sdk-core
uses it to determine whether to create a thread for an βasync refreshβ. If the thread has already started and is refreshing, locked?
will be true
and no thread will be created:
Last we have sleep(timeout = nil)
, which releases the lock for timeout
seconds, or runs forever if given nil
. This is exactly what the rack-timeout
gem uses internally to create a Scheduler
class which it uses to schedule request timeouts:
It acquires a lock using synchronize
to safely operate on the @events
array. It then finds the event with the shortest wait time, and sleep
s the mutex for that period. That way other events can be added to the @events
array using the appropriate lock, even while waiting. This supports the scheduler interface:
When you call run_in
, a new event is appended to the @events
array. run
is called on the thread with the sleep
ing mutex , which causes @mx_events.sleep
to wakeup and run_loop!
to iterate again, checking for any events to fire and scheduling the shortest even duration to wait again using @mx_events.sleep
.
Similar to the principle of a database lock, the shorter you can keep the mutex lock the better for performance.
ConditionVariable
Similar to Mutex#sleep
, a ConditionVariable
s purpose is to let you release a lock and sleep - you do that using the wait
method. The difference is that it provides a direct communication mechanism to wake up: signal
and broadcast
. Letβs look at a small example - in it we wonβt see the wait
re-acquire the lock until we call signal
:
signal
will only notify a single thread, whereas broadcast
will notify all threads. Letβs create two threads and try it signal
instead:
We never see the second thread say βbye!β, because only a single signal
call has been made. A signal
call attempts to wake up a single thread. If you try to join the second thread, Ruby will detect a deadlock condition because the wait
will never finish:
There are probably cases where signal
makes sense - only one thread can acquire the lock so itβs cheaper to wake up a single thread than to wake up every thread. But broadcast
covers more scenarios, and fixes our two thread example:
A ConditionVariable
can only wait
on a locked mutex:
And only for the thread that owns the mutex:
We can use ConditionVariable#wait
with a timeout in seconds, so we can also recreate the Mutex#sleep
Scheduler
code from rack-timeout
(in a more basic form):
In Your Ruby programs are always multi-threaded: part 2, we looked at an example of coordinating threads using a βCountdownLatchβ.
π Quick reminder that
concurrent-ruby
comes with a countdown latch so thereβs no need to use this one βπΌ. Itβs just educational. It is very similar to the concurrent-ruby version though!
Weβve seen how wait
and broadcast
work. But why do we need that while @count > 0
check? Shouldnβt it only get woken up when @count == 0
and @cond.broadcast
is called? Unfortunately, Mutex#sleep
and ConditionVariable#wait
can wake up randomly due to something called Spurious wakeups3.
Thread runtimes can decide for internal reasons to wake up a condition at random, so you should be ready to handle it - in our case we continually check the expected condition @count > 0
and continue to wait
until it is false. This makes sure if we wake up due to a spurious wakeup weβll immediately wait
for the condition again.
Monitor
A Monitor
is essentially the same as a Mutex
, but it is also βre-entrantβ. What does it mean to be re-entrant? Letβs go back to an example from Your Ruby programs are multi-threaded: part 2:
In it, when we call #calculate
, internally it calls #result
and #result=
. calculate
first acquires the lock using synchronize
, then it calls result=
which also tries to acquire the lock. It is re-entering the same lock. Letβs change @fib_monitor
to a Mutex
and see what happens:
We immediately see an error raised: βdeadlock; recursive lockingβ. By changing to a Monitor
, everything works fine.
The redis-rb
gem creates clients that are thread-safe. It uses a Monitor
to do that, likely because it allows synchronize
d methods to call other synchronize
d methods without any recursive deadlocking errors:
Monitor
comes with some other conveniences around creating ConditionVariable
s related to it, which you can read more about in its documentation.
Queue
Queue
is one of two thread-safe data structures that come out of the box with Ruby. It is a First-in, First-out queue which allows for safe communication between threads. Itβs primarily used for implementing producer/consumer patterns between threads:
The producer
thread endlessly adds an item to the queue every 1 second, and two consumer
threads pop
items off the queue. If there is no item available, the thread sleeps until one becomes available.
If it seems like you could pretty easily implement this using a Mutex
and a ConditionVariable
, youβd be right! Iβll leave that for you to try as an example.
SizedQueue
SizedQueue
is the other thread-safe data structure available in Ruby, and itβs just another flavor of the base Queue
class. It allows you to create a fixed-size queue - when new items are added the queue blocks until space is available.
This is a good use-case for throttling your own code to not overwhelm your application. The following code will block if more than 5 items exist in the queue, throttling the producers so consumers can keep up with it:
Youβll see threads waiting going between 1 and 2, as producers get blocked while consumers slowly consume data from the SizedQueue
.
ThreadGroup
The Ruby docs describe ThreadGroup
as βa means of keeping track of a number of threads as a group.β A thread is automatically a part of the βdefaultβ group:
And you can add threads to a new group:
Iβm mentioning them here for completeness, but you almost never see them in real use. See their documentation to learn more.
ββ
Thatβs about it! Out of the box, Ruby comes with a pretty small set of Thread primitives. Most code you might see in real use will use either these, or options from the concurrent-ruby
gem. Weβll dig more into concurrent Ruby in βAbstracted, concurrent Rubyβ later on in the series.
Memory visibility
The last thing weβll discuss before finishing up is a concept called βmemory visibilityβ.
The simplest way to think of memory visibility is βwhat can each thread can see at any given timeβ. When threads are running on multiple CPUs, a common optimization is to localize things to the most performant memory caches located on the CPU itself. If that happens, it means two different threads can operate on a shared piece of data, and have completely different views of that data because they have localized, out of sync versions of it.
In addition, the CPU can actually reorder certain operations to optimize them.
How can you solve these problems? When you need to make sure each thread sees a consistent, accurate version of a shared piece of data, you utilize something called a memory barrier. How can you use a memory barrier from Ruby? A mutex! As long as you wrap each access to a particular shared resource, youβll be guaranteed to see a consistent view of it:
Isolated to a single thread, or for immutable objects, memory visibility doesnβt really matter - itβs another reason attempting to share objects between threads can lead to issues, and avoiding it is better than trying to safely coordinate it.
π In CRuby, memory visibility is unlikely to ever be an issue for you. Thatβs because there is always a mutex involved when moving between threads: The GVL. Weβll talk more about the GVL in βThread and its MaNy friendsβ. But to be on the cautious side, youβre better off synchronizing access consistently for reads/write to any data shared between threads.
βββ
Weβve now dug into the majority of the Thread API. The main piece weβve only touched lightly, is around interrupting threads. Itβs up next in βInterrupting Threads: Colorless, concurrent Rubyβ. More soon! ππΌ
- There are a couple other interfacing for creating a thread, but you basically never see them in use.