Concurrency Guide

This is a guide to thinking about concurrency in the cruby source code, whether that’s contributing to Ruby by writing C or by contributing to one of the JITs. This does not touch on native extensions, only the core language. It will go over:

What needs synchronizing?

Before ractors, only one ruby thread could run at once. That didn’t mean you could forget about concurrency issues, though. The timer thread is a native thread that interacts with other ruby threads and changes some VM internals, so if these changes can be done in parallel by both the timer thread and a ruby thread, they need to be synchronized.

When you add ractors to the mix, it gets more complicated. However, ractors allow you to forget about synchronization for non-shareable objects because they aren’t used across ractors. Only one ruby thread can touch the object at once. For shareable objects, they are deeply frozen so there isn’t any mutation on the objects themselves. However, something like reading/writing constants across ractors does need to be synchronized. In this case, ruby threads need to see a consistent view of the VM. If publishing the update takes 2 steps or even two separate instructions, like in this case, synchronization is required.

Most synchronization is to protect VM internals. These internals include structures for the thread scheduler on each ractor, the global ractor scheduler, the coordination between ruby threads and ractors, global tables (for fstrings, encodings, symbols and global vars), etc. Anything that can be mutated by a ractor that can also be read or mutated by another ractor at the same time requires proper synchronization.

The VM Lock

There’s only one VM lock and it is for critical sections that can only be entered by one ractor at a time. Without ractors, the VM lock is useless. It does not stop all ractors from running, as ractors can run without trying to acquire this lock. If you’re updating global (shared) data between ractors and aren’t using atomics, you need to use a lock and this is a convenient one to use. Unlike other locks, you can allocate ruby-managed memory with it held. When you take the VM lock, there are things you can and can’t do during your critical section:

You can (as long as no other locks are also held before the VM lock):

You can’t:

Internally, the VM lock is the vm->ractor.sync.lock.

You need to be on a ruby thread to take the VM lock. You also can’t take it inside any functions that could be called during sweeping, as MMTK sweeps on another thread and you need a valid ec to grab the lock. For this same reason (among others), you can’t take it from the timer thread either.

Other Locks

All native locks that aren’t the VM lock share a more strict set of rules for what’s allowed during the critical section. By native locks, we mean anything that uses rb_native_mutex_lock. Some important locks include the interrupt_lock, the ractor scheduling lock (protects global scheduling data structures), the thread scheduling lock (local to each ractor, protects per-ractor scheduling data structures) and the ractor lock (local to each ractor, protects ractor data structures).

When you acquire one of these locks,

You can:

You can’t:

Difference Between VM Lock and GVL

The VM Lock is a particular lock in the source code. There is only one VM Lock. The GVL, on the other hand, is more of a combination of locks. It is “acquired” when a ruby thread is about to run or is running. Since many ruby threads can run at the same time if they’re in different ractors, there are many GVLs (1 per SNT + 1 for the main ractor). It can no longer be thought of as a “Global VM Lock” like it once was before ractors.

VM Barriers

Sometimes, taking the VM Lock isn’t enough and you need a guarantee that all ractors have stopped. This happens when running GC, for instance. To get a barrier, you take the VM Lock and call rb_vm_barrier(). For the duration that the VM lock is held, no other ractors will be running. It’s not used often as taking a barrier slows ractor performance down considerably, but it’s useful to know about and is sometimes the only solution.

Lock Orderings

It’s a good idea to not hold more than 2 locks at once on the same thread. Locking multiple locks can introduce deadlocks, so do it with care. When locking multiple locks at once, follow an ordering that is consistent across the program, otherwise you can introduce deadlocks. Here are the orderings of some important locks:

These orderings are subject to change, so check the source if you’re not sure. On top of this:

Ruby Interrupt Handling

When the VM runs ruby code, ruby’s threads intermittently check ruby-level interrupts. These software interrupts are for various things in ruby and they can be set by other ruby threads or the timer thread.

This isn’t a complete list.

When sending an interrupt to a ruby thread, the ruby thread can be blocked. For example, it could be in the middle of a TCPSocket#read call. If so, the receiving thread’s ubf (unblock function) gets called from the thread (ruby thread or timer thread) that sent the interrupt. Each ruby thread has a ubf that is set when it enters a blocking operation and is unset after returning from it. By default, this ubf function sends a SIGVTALRM to the receiving thread to try to unblock it from the kernel so it can check its interrupts. There are other ubfs that aren’t associated with a syscall, such as when calling Ractor#join or sleep. All ubfs are called with the interrupt_lock held, so take that into account when using locks inside ubfs.

Remember, ubfs can be called from the timer thread so you cannot assume an ec inside them. The ec (execution context) is only set on ruby threads.

The Timer Thread

The timer thread has a few functions. They are: