Featured image of post About Mutexes, Semaphores, and Spinlocks

About Mutexes, Semaphores, and Spinlocks

About Mutexes, Semaphores, and Spinlocks

Origin

Earlier this year, while preparing for some embedded systems interviews, I ran into Mutex, Semaphore, and Spinlock again. I had actually been asked about them before when interviewing with a few big companies—one known for fries and another known for fruit—but I was not well prepared at the time, so my answers were not great. I decided to review them properly while preparing for interviews this time. Anyone who has taken an operating systems course should be familiar with these three terms, and once you start working with an RTOS, the Linux Kernel, or multithreaded programs, you will encounter them sooner or later.

Unless you have made a point of memorizing them, most people’s impressions are probably something like this:

  • “Isn’t a Mutex just a lock?”
  • “A Semaphore seems to be a lock too?”
  • “Then what on earth is a Spinlock, and why do we need one when Mutexes already exist?”

All three also seem to be doing the same thing: preventing everyone from touching the same thing at the same time. So, as usual, I want to organize these concepts in a super plain way. If I ever forget the differences between a Mutex, a Semaphore, and a Spinlock again, I can come back here for a quick review.

So Why Do We Need These Things?

Before worrying about what Mutexes, Semaphores, and Spinlocks each do, let’s imagine a scenario. Suppose we are writing some super simple bare-metal firmware:

1
2
3
4
5
6
7
main()
{
    Read Sensor
    Control Motor
    Update LED
    ...
}

The CPU simply handles one task after another in order, right? In many cases, we do not need a Mutex or Semaphore at all. This is the most common approach on an MCU, and it usually does not cause much trouble because, at any given moment, only the current piece of code is running and can freely use the system’s resources.

But things change when the system needs multiple threads to run different logic and we begin using an RTOS such as Zephyr, FreeRTOS, or ThreadX.

Suppose the system has three threads:

1
2
3
Sensor Thread
Motor Thread
UI Thread

An MCU usually has only one CPU core, so the RTOS scheduler rapidly switches between different threads. For example, the system might have a Sensor Thread collecting sensor data, a Motor Thread controlling the motor state, and a UI Thread displaying the device status. The scheduler lets them take turns using the CPU. Although these threads are not truly running at the same time, if one thread is interrupted halfway through its work and another thread starts running, the second thread may touch a resource that the first thread was still using. On a single-core system, the first thread has stopped executing, but the resource may still be in an intermediate state. On a multicore system, two threads running on different cores may genuinely access the same resource at the same time. And that brings us to the problem: what happens if two threads want to operate on the same thing at the same time?

For example, the Motor Thread and UI Thread might both modify the motor’s control state, or the Sensor Thread and another thread might access the same region of shared memory. Without proper protection, things can go wrong.

Let’s start with the simplest possible case. Suppose we have this innocent-looking statement:

1
counter++;

It looks like one line, but the CPU normally has to read counter, increment it, and then write the result back to memory.

If Thread A and Thread B both read counter = 10 at roughly the same time, they may each write back 11. Even though counter++ ran twice, the value only increased once. Anyone who has taken an operating systems course has probably seen this before: it is a classic Race Condition. Mutexes, Semaphores, and Spinlocks fundamentally exist to solve this kind of Concurrency / Synchronization problem.

Strictly speaking, this does not mean that bare-metal systems can never have Race Conditions. As soon as Interrupts, Multicore, or another asynchronous Execution Context is involved, bare-metal firmware can face Synchronization problems too. It is just that, in embedded firmware development, this kind of preemption becomes more common once we start using an RTOS, Linux, or another system with Threads and a Scheduler. Even with only one CPU core, if Preemption exists, another Thread can take the CPU while the first one is in a Critical Section and still produce a Race Condition. The Linux Kernel documentation specifically points out that Preemption can cause this kind of Race Condition even on a single-CPU system (Linux Kernel Documentation — Locking).

So, coming back to Mutexes, Semaphores, and Spinlocks, we can think of them as synchronization mechanisms provided by an operating system to manage this kind of preemption. In other words, if we are working on bare metal, there normally is no ready-made Lock provided by an RTOS. If we need one, we have to build it into the system ourselves.

OK, now that we know where the problem comes from, let’s look at what each of these three mechanisms actually does and what problem it is intended to solve.


Mutex: This Is Mine Right Now, So Don’t Touch It Yet

Let’s start with the easiest one to understand: the Mutex. Mutex stands for Mutual Exclusion, and its purpose is almost obvious from the name:

Only one Thread may enter at a time.

Suppose the Motor Thread and UI Thread can both modify the state of the same Motor. We do not want one side to be setting the speed to 1000 RPM while the other suddenly changes it to 200 RPM. If two Threads modify the Driver State or Hardware Registers together, the final control result can become inconsistent and fall into some unpredictable random state, which is pretty terrifying. This is where a Mutex comes in.

Before operating the Motor, a Thread must acquire the Mutex. Once it has the Mutex, other Threads cannot enter the same piece of code. This area, which only the Lock holder may execute, is the Critical Section we often hear about in operating systems courses.

1
2
3
4
5
6
mutex_lock(&motor_mutex);

motor_set_speed(1000);
motor_set_direction(FORWARD);

mutex_unlock(&motor_mutex);

Only after Thread A has finished operating the Motor and unlocked the Mutex can Thread B acquire it and continue. This keeps the two Threads from touching the same hardware resource at the same time. Zephyr likewise recommends Mutexes for mutually exclusive access to a shared resource, such as protecting a Physical Device (Zephyr Project Documentation — Mutexes).

Of course, a Mutex does not have to protect hardware. It can protect Shared Memory, a Linked List, a File, an I2C Bus, an SPI Bus, UART, or Driver State. In short, whenever something may only be operated by one Thread at a time, a Mutex is usually the first thing to consider.

At this point, someone will probably wonder: if another Thread already holds the Mutex, how does the system schedule Thread B?

The approach is actually quite direct. The RTOS moves Thread B from the Running State to the Blocked State, then the Scheduler chooses another runnable Thread from the Ready Queue—for example, the Sensor Thread. During this period, Thread B does not keep checking the Mutex and does not continue consuming CPU time.

When Thread A calls mutex_unlock(), the RTOS moves Thread B back to the Ready State. If Thread B has a higher Priority than the currently running Thread, it may immediately preempt the CPU. Otherwise, it stays in the Ready Queue and waits for the next scheduling opportunity. This is also one of the most important differences between a Mutex and the Spinlock we will discuss later: when a Thread cannot acquire a Mutex, it can sleep.

A real-world example is the Mutex in the Linux Kernel, which is a Sleeping Lock. When a Task cannot acquire a Mutex, it may Suspend and let the CPU do other work (Linux Kernel Documentation — Locking).


Semaphore: I’m Not Locking Something—I’m Counting How Many Are Left

Next, let’s talk about Semaphores. The name itself does not make the concept particularly intuitive when you first see it. You can think of a Semaphore as a box containing Tokens. Suppose the box holds three Tokens; the Semaphore Count is then 3. Every time a Thread Take()s a Token, the Count decreases by one. When someone Give()s one back, the Count increases by one as long as it has not reached the maximum. So the core of a Semaphore is really just a Counter (Zephyr Project Documentation — Semaphores).

If the Count begins at 3, after Threads A, B, and C each take a Token, it becomes 0. If Thread D then calls Take(), it has to wait until someone Give()s a Token back. A Semaphore that can track multiple Tokens like this is called a Counting Semaphore. If the Count can only be 0 or 1, it is called a Binary Semaphore.

At this point, a question may come to mind: “Wait, if the maximum value of a Binary Semaphore is 1, isn’t it the same as a Mutex?”

Yes, they look very similar on the surface, but they express different ideas:

  • A Mutex is about Ownership: who owns this Resource right now?
  • A Semaphore is about Count / Signal: how many Resources are available, or how many Events have occurred?

A Mutex normally has a clear Owner: the Thread that locks it should also be the Thread that unlocks it. A Semaphore does not necessarily have this Ownership relationship. In FreeRTOS, for example, a Mutex additionally provides Priority Inheritance, while a Binary Semaphore does not. The official documentation therefore recommends using different Primitives for Resource Mutual Exclusion and Synchronization (FreeRTOS Documentation — Mutexes).

That distinction may still feel a little abstract, so let’s look at a very common embedded-system example.

Suppose a Sensor continuously takes samples and temporarily stores the data in its own FIFO. When the amount of data in the FIFO reaches a certain Threshold, the Sensor raises an Interrupt to tell the MCU, “I have accumulated some data over here—remember to come and read it.”

After the MCU enters the ISR, it could read the Sensor Registers, transfer the FIFO data, run a Filter, and perform the subsequent processing right there. But that is usually not what we want, because an ISR should be as short as possible. A more common design is for the ISR to do nothing more than Give a Semaphore, while the Sensor Thread performs the actual time-consuming work:

1
Sensor Interrupt -> ISR -> Give Semaphore -> Sensor Thread -> Read FIFO

Conceptually, the ISR only does a tiny amount of work. The following is generic code intended to make the idea easy to understand; in a real system, it must be replaced with the API that the specific RTOS allows an ISR to call:

1
2
3
4
void sensor_isr(void)
{
    semaphore_give(&sensor_sem);
}

The Sensor Thread normally waits for a notification at semaphore_take():

1
2
3
4
5
6
7
while (1)
{
    semaphore_take(&sensor_sem);

    read_sensor_fifo();
    process_sensor_data();
}

When there is no Interrupt, the Semaphore Count is 0, so the Sensor Thread remains in the Blocked State and does not need to keep polling the Sensor for data. When an Interrupt occurs, the ISR increases the Count from 0 to 1, waking the waiting Sensor Thread. The Thread Takes the Token and then reads the FIFO.

The division of responsibilities is now very clear: the ISR sends the notification, and the Thread does the actual work.

The Zephyr documentation also presents this as a typical way to offload ISR work: an ISR can use a Semaphore or another Kernel Object to Signal a Helper Thread, leaving the more time-consuming work to execute in Thread Context (Zephyr Project Documentation — Interrupts).

This example makes the semantic difference between a Mutex and a Semaphore much easier to see:

  • A Mutex is like: “The bathroom is occupied, so wait before going in.”
  • A Semaphore is like: “There is now one thing that needs to be handled.”
  • A Counting Semaphore can express: “There are now three things waiting to be handled.”

There is another detail worth mentioning. A Binary Semaphore can only have a Count of 0 or 1. Suppose the Sensor triggers three Interrupts in a row before the Sensor Thread begins processing. The final Count may still be only 1. In other words, a Binary Semaphore is closer to saying “something happened” than precisely recording that “something happened three times.”

If the system cannot afford to lose a single Event, consider using a Counting Semaphore with a sufficiently high maximum Count, or even switch to a Queue. A Semaphore can represent either the number of available Resources or the number of Events waiting to be processed, which makes it more versatile than a Mutex.


Spinlock: It Should Be Done Any Moment, So I’ll Just Wait Here

Finally, let’s look at the Spinlock. As mentioned earlier, when a Thread cannot acquire a Mutex, it can enter the Blocked State and let the RTOS scheduling policy give the CPU to another Thread. That sounds perfectly reasonable, but a Context Switch is not free.

The system must save the current Thread’s Registers, Program Counter, Stack Pointer, and other Execution Context, then load the Context of another Thread. All of that has a cost. Suppose Thread B on CPU 1 is holding a Lock but has only an extremely short operation such as shared_data++ left to perform. It may release the Lock in just a few CPU Cycles. If Thread A on CPU 0 immediately Blocks, saves its Context, and triggers another scheduling pass, Thread B may have released the Lock by the time all that work is finished.

That leads to another idea:

If I expect it to be done almost immediately, I might as well stand here and wait.

That is a Spinlock. Conceptually, it repeatedly checks whether the Lock has been released, directly occupying the CPU in a Busy Wait:

1
2
3
4
while (lock_is_taken)
{
    // Busy waiting
}

While waiting, the Thread does not enter the Sleep State. The CPU Core it is running on does not go and execute another Thread either; it keeps trying to acquire the Lock. It keeps “spinning” in place, which is where the name Spin Lock comes from.

The Linux Kernel describes a Spinlock in the same way: if the Spinlock cannot be acquired, the caller keeps trying. By contrast, a caller that cannot acquire a Mutex can Suspend and let the CPU do other work (Linux Kernel Documentation — Locking).

Doesn’t that make a Spinlock an enormous waste of CPU time? Yes, which is why a Spinlock comes with one extremely important requirement: the Critical Section must be very short. Otherwise, the CPU Core waiting for the Lock does nothing but waste cycles.

A fast operation like the following is the sort of thing that may be suitable for a Spinlock:

1
2
3
4
5
6
spin_lock();

shared_state++;
update_pointer();

spin_unlock();

If the code Sleeps, performs slow I/O, or waits for Hardware while holding a Spinlock, every other CPU Core waiting for the same Lock can do nothing but spin. Besides wasting CPU time, Sleeping while holding a Spinlock is itself forbidden in many Kernel Contexts.

So, as a very rough rule of thumb:

  • If the wait may be longer and Sleeping is allowed, consider a Mutex.
  • If the Critical Section is extremely short and the caller cannot or should not Sleep, consider a Spinlock.

This is the fundamental trade-off of a Spinlock: spend CPU Time to avoid the Overhead of Context Switching and Sleeping / Waking.

At this point, it is tempting to conclude that Spinlocks only appear on multicore systems, right?

Spinlocks are certainly easiest to understand on a multicore system. Suppose CPU 1 is modifying Shared Data while CPU 0 also wants to modify it. Even if CPU 0 Spins in place, it does not prevent CPU 1 from completing its Critical Section. As long as CPU 1 Unlocks quickly, CPU 0 can then acquire the Lock.

However, there is an easy misconception here: Spinlocks are not meaningful only on Multicore systems.

The Linux Kernel has different Execution Contexts, including Thread Context, SoftIRQ, and Hard IRQ, and some of them cannot Sleep at all. If an Interrupt Handler cannot acquire a Lock, we cannot simply tell it to take a nap. A Sleeping Lock such as a Mutex is therefore unsuitable; instead, we need a Spinlock or another Synchronization Primitive that does not Sleep. This is why the Linux Kernel provides variants such as spin_lock_irq() and spin_lock_irqsave() (Linux Kernel Documentation — Locking).

More completely stated, the usual conditions for a Spinlock are that the Critical Section is short and the current Execution Context should not or cannot Sleep. Multicore is simply the most intuitive scenario for this requirement. In addition, under some single-core Linux Kernel Configurations, a Spinlock may not need to literally Spin. For example, if the system has neither SMP nor Preemption, there is no other concurrently executing Task that can take over this Critical Section. The Linux Kernel handles Locks differently according to the Configuration (Linux Kernel Documentation — Locking).

One additional note: Linux’s PREEMPT_RT changes the implementation semantics of spinlock_t. The type that retains true Spinning Lock semantics across all Kernel Configurations is raw_spinlock_t. For this article, it is enough to understand the general concept of a Spinlock (Linux Kernel Documentation — Lock Types).


So What Is the Difference Between a Mutex, a Semaphore, and a Spinlock?

After all that explanation, the three mechanisms can actually be remembered in three short lines:

  • Mutex: “This is mine right now, so don’t touch it yet.” It is mainly used to protect Shared Resources such as a Motor, I2C Bus, Shared Data, Driver State, or File. If it cannot be acquired, the caller can usually Block / Sleep.
  • Semaphore: “How many Events are waiting to be handled?” It is mainly used for Synchronization, Signaling, or Resource Counting, and it does not necessarily have an Owner.
  • Spinlock: “It should be done any moment, so I’ll just wait here.” It is mainly used to protect an extremely short Critical Section. If it cannot be acquired, the caller does not Sleep; it keeps occupying its CPU Core and waits.

Of course, the real world is not this absolute. Different operating systems may implement preemption differently, and their scheduling policies may also vary. But for a quick first decision, start with these three questions:

  • Am I protecting a Resource? Think of a Mutex first.
  • Am I notifying another Thread that “something happened”? Think of a Semaphore first.
  • Am I protecting an extremely short Critical Section in a place that cannot Sleep? Consider a Spinlock.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
                   What problem am I solving?
                              |
              +---------------+---------------+
              |                               |
     Protect a Shared Resource        Event / Resource Count
              |                               |
              v                               v
            Mutex                         Semaphore
              |
              |
   What if I cannot acquire the Lock?
              |
        +-----+------+
        |            |
    Can Sleep    Cannot/Shouldn't Sleep
        |            |
        v            v
      Mutex       Spinlock

Conclusion

And that is roughly it. When you first encounter Mutexes, Semaphores, and Spinlocks, it is easy to feel that all three look more or less the same: they all prevent different Execution Contexts from interfering with one another. But once you examine the specific problem each one is meant to solve and think about the core idea behind why it was invented, their designs are actually quite elegant. Each one corresponds to a different kind of interaction between Threads.

A Mutex focuses on Ownership / Mutual Exclusion and emphasizes exclusive access to a resource. A Semaphore focuses on Count / Signaling, making it a better notification when an event occurs. A Spinlock addresses Mutual Exclusion through Busy Waiting, spending CPU Time to avoid the potential extra cost of a Context Switch.

These classic mechanisms are widely used across different operating systems, and each one addresses a different underlying problem. Understanding both how they work and what they were designed to solve also lets us use the facilities provided by an operating system more flexibly when designing a system.

I hope this article helps!


Reference

Hugo Shih World
Built with Hugo
Theme Stack designed by Jimmy