Hello! Welcome to your next lesson on RTOS fundamentals.
In our last session, we dissected the low-level mechanics of a context switch. We saw how the RTOS, with hardware assistance, saves one task's state and restores another's, creating the illusion of parallel execution. This preemptive multitasking is powerful, but it introduces a significant challenge: what happens when multiple tasks need to access the same shared resource, like a global variable, a communication buffer, or a hardware peripheral?
If a task is preempted in the middle of a multi-step operation (like a simple counter++), another task might access the same resource, leading to data corruption. This is known as a race condition. Today's lesson is about the fundamental tools used to prevent these race conditions and ensure tasks can cooperate safely.
Your learning outcome is to: Differentiate between a mutex, semaphore (binary/counting), and spinlock, and select the appropriate primitive for a given synchronization problem.
Mastering these primitives is non-negotiable for any senior embedded role. Interviewers at companies like NVIDIA, Qualcomm, and AMD will expect you to not only know what they are but, more importantly, to justify why and when you would choose one over the others.
1. The Core Problem: Critical Sections and Race Conditions
A critical section is a piece of code that accesses a shared resource and must not be simultaneously executed by more than one task. To enforce this, we use synchronization primitives to "lock" the critical section.
Imagine a simple global counter updated by two tasks:
volatile uint32_t g_shared_counter = 0;
void task_A(void) {
g_shared_counter++;
}
void task_B(void) {
g_shared_counter++;
}
The g_shared_counter++ operation seems atomic, but it compiles to multiple assembly instructions:
- LOAD the value of
g_shared_counterfrom memory into a CPU register. - INCREMENT the value in the CPU register.
- STORE the new value from the CPU register back to memory.
Now, consider this sequence:
task_Aexecutes step 1 (loadsg_shared_counter, which is 0, into its register).- A context switch occurs!
task_Ais preempted. task_Bruns, executes all three steps, and incrementsg_shared_counterto 1.- Another context switch occurs.
task_Aresumes. task_Aexecutes step 2 (increments its register, which still holds 0, to 1).task_Aexecutes step 3 (stores 1 back intog_shared_counter).
Result: The counter is 1, even though it was incremented twice. We lost an update. This is a classic race condition.
To solve this, we need to make the three-step sequence an indivisible, atomic operation. This is where our three primitives come in.
2. The Spinlock: Busy-Waiting for Access
A spinlock is the simplest type of lock. When a task tries to acquire a spinlock that is already held, it doesn't go to sleep. Instead, it enters a tight loop, "spinning" and repeatedly checking the lock until it becomes free.
This requires a special hardware instruction that can check the lock's state and acquire it in a single, uninterruptible operation.
To understand how a spinlock works at a low level, let's watch this excellent explanation from the xv6 kernel series. It covers the fundamental concept, the problem with a naive implementation, and the need for an atomic hardware instruction.
Watch from the beginning to 08:58. Focus on: The representation of a lock (a memory word that is 0 or 1). Why a simple if (lock == 0) { lock = 1; } sequence fails in a concurrent environment. The role of an atomic amo swap (atomic memory operation swap) or test-and-set instruction in correctly implementing the acquire function. The use of memory fences (sync_synchronize) to prevent compiler and CPU reordering around the critical section.
Spinlocks and Interrupts: A Classic Deadlock
Spinlocks are fast but have a critical weakness on single-core systems. If a task holds a spinlock and is preempted by an interrupt whose handler also tries to acquire the same spinlock, the system will deadlock. The ISR will spin forever, waiting for the task to release the lock, but the task can't run to release the lock because the ISR has preempted it.
The same video explains this classic deadlock scenario and the common solution: disabling interrupts while the spinlock is held.
Watch from 10:37 to 14:56. This segment clearly illustrates the deadlock that can occur between a task and an interrupt handler over the same spinlock.
The key takeaway is that when a spinlock is acquired on a single-core system, interrupts must be disabled to prevent this deadlock scenario.

When to use a Spinlock:
- Multi-core systems: Where the task holding the lock is on another core and is expected to release it very quickly. The waiting core can spin for a few cycles instead of paying the high cost of a context switch.
- Kernel/Driver code: For protecting extremely short critical sections (e.g., modifying a few linked list pointers) where the overhead of a context switch is unacceptable.
- When you absolutely cannot sleep: Such as in certain parts of an interrupt handler (though this is very advanced and requires careful design).
3. The Mutex: Sleeping with Ownership
A Mutex (short for Mutual Exclusion) is a more sophisticated lock. If a task tries to acquire a mutex that is already held, the RTOS puts the task to sleep (blocks it). The RTOS then schedules another ready task to run. When the mutex is released, the RTOS will wake up the blocked task and move it to the ready state.
This avoids the wasted CPU cycles of busy-waiting, making it far more efficient for general-purpose use.
The defining characteristic that separates a mutex from a binary semaphore is ownership.
Mutex vs. Semaphores – Part 2: The Mutex
The principle of ownership is what gives a mutex its unique power and safety features. This article from the 'Sticky Bits' blog explains it perfectly.
Read the first several sections, starting from the introduction down to the end of the 'Death Detection' section. Focus on understanding the core concept of ownership and how it solves these common problems: Accidental Release: Another task cannot accidentally unlock a mutex it doesn't own. Recursive Deadlock: The owning task can re-lock the same mutex without deadlocking. Priority Inversion: Ownership allows the OS to implement solutions like Priority Inheritance (which we'll cover in the next lesson).
When to use a Mutex:
- The default choice for protecting a shared resource in an RTOS application.
- When the critical section might be long, or the task might have to wait for an I/O operation while holding the lock (though this is often a sign of poor design).
- When you need features like recursive locking or priority inheritance.
When NOT to use a Mutex:
- In an ISR: Interrupt handlers cannot sleep, so they cannot block on a mutex.
- For simple signaling: A mutex is designed for protecting resources, not for synchronizing execution between tasks (e.g., signaling that data is ready). That's a job for a semaphore.
4. The Semaphore: The Universal Signaling Tool
A semaphore is not primarily a locking mechanism; it's a signaling mechanism. It behaves like a counter with two atomic operations:
wait(orpend,take): Decrements the semaphore's count. If the count is zero, the task blocks until the count becomes greater than zero.post(orsignal,give): Increments the semaphore's count. If any tasks are blocked waiting for the semaphore, one of them is unblocked.
Unlike a mutex, a semaphore has no concept of ownership. Any task can post to a semaphore, even if it never waited on it. This is what makes it so flexible for synchronization.
What is a semaphore? How do they work? (Example in C)
This video from Jacob Sorber clearly explains what a semaphore is and, crucially, how its lack of ownership distinguishes it from a mutex.
Watch from 01:22 to 05:11. Pay close attention to the distinction made between a mutex having ownership and a semaphore being more flexible because it lacks ownership.
There are two main types of semaphores:
- Counting Semaphore: The count can range from 0 to some maximum value. This is used to manage a pool of identical resources.
- Example: You have a pool of 4 network buffers. Initialize a semaphore with a count of 4. Tasks wanting a buffer must first
waiton the semaphore. When they are done, theypostto the semaphore, making the buffer available to another task.
- Example: You have a pool of 4 network buffers. Initialize a semaphore with a count of 4. Tasks wanting a buffer must first
- Binary Semaphore: The count is restricted to 0 or 1. It can be used for mutual exclusion (like a mutex), but this is generally discouraged because it lacks ownership and the associated safety features. Its primary use is for pure signaling.
- Example: An ISR for a UART receives a complete data packet. It then
postsa binary semaphore. A high-priority worker task, which was blockedwaiting on that semaphore, now wakes up and processes the packet.
- Example: An ISR for a UART receives a complete data packet. It then
The producer-consumer problem is the classic use case for semaphores.
What is a semaphore? How do they work? (Example in C)
Let's continue with the same video to see a practical C example of using two semaphores to coordinate a producer (writer) and a consumer (reader). This is a very common interview pattern.
Watch from 05:38 to 12:53. Observe how two semaphores are used: one for the producer to signal 'data is ready', and one for the consumer to signal 'buffer is empty and ready for new data'.
5. Summary and Selection Guide
Choosing the right tool is critical. A wrong choice can lead to subtle bugs, deadlocks, or poor performance.

Here is a summary table to guide your decision-making process, a crucial skill for system design interviews.
| Feature | Spinlock | Mutex | Semaphore |
|---|---|---|---|
| Primary Use Case | Mutual Exclusion | Mutual Exclusion | Signaling / Synchronization |
| Waiting Mechanism | Busy-Wait (Spins) | Sleeps (Blocks) | Sleeps (Blocks) |
| Ownership | No | Yes (Only owner can unlock) | No |
| Use in ISR | Yes (with extreme care, interrupts disabled) | No (ISRs cannot sleep) | Yes (post only) (The primary way to signal from an ISR) |
| Recursive Locking | No | Yes (Often a configurable feature) | No |
| Typical Context | Kernel, drivers, multi-core, very short critical sections | General RTOS application resource protection | ISR-to-task signaling, producer-consumer patterns |
| Analogy | A bathroom door with a transparent window you stare at until it's free. | A key to a bathroom. You get the key, use it, and return it. If it's gone, you wait in a queue. | A stack of tokens for entry. You take a token to enter. If none are left, you wait. An ISR can add tokens. |
Interview-Style Scenarios
Let's test your understanding. For each scenario, which primitive would you choose and why?
- Scenario A: You have multiple tasks that need to write log messages to a single UART peripheral. You need to ensure that messages from different tasks are not interleaved.
- Scenario B: An I2C driver in a Linux kernel needs to protect its internal data structure during read/write operations. The critical sections are very short, just a few instructions, and these operations are frequent. The system is multi-core.
- Scenario C: An ADC completes a conversion and triggers an interrupt. The ISR needs to notify a background task to fetch the ADC value and perform a filtering calculation.
- Scenario D: A web server application has a pool of 10 worker threads and a pool of 5 available database connections. You need to manage access to these connections.
Click to reveal solutions
- Solution A: A Mutex. This is the classic case for mutual exclusion. A mutex ensures only one task can access the UART registers at a time. Its ownership property also prevents one task from accidentally interfering with another's lock.
- Solution B: A Spinlock. In a multi-core kernel environment where critical sections are extremely short and frequently accessed, the overhead of a context switch (what a mutex would cause) is much higher than the cost of spinning for a few cycles waiting for the other core to release the lock.
- Solution C: A Binary Semaphore. This is the perfect use case for signaling from an ISR to a task. The ISR is non-blocking; it simply
poststhe semaphore and returns. The background task sleeps efficiently bywaiting on the semaphore, consuming no CPU until it is woken up by the ISR. - Solution D: A Counting Semaphore, initialized to 5. Each worker thread must
waiton the semaphore to acquire a database connection. This decrements the count. When the thread is finished, itpoststo the semaphore, incrementing the count and allowing another waiting thread to acquire a connection.
Conclusion
You now have a solid framework for differentiating between these three fundamental synchronization primitives.
Key Takeaways:
- Spinlock: For performance-critical, short-duration locks in low-level code (like kernels), especially on multi-core systems. It busy-waits.
- Mutex: The standard choice for general-purpose resource protection in an RTOS. It causes tasks to block (sleep) and enforces ownership.
- Semaphore: The tool for signaling and synchronization. It has no ownership, making it perfect for ISR-to-task communication and managing pools of resources. It also causes tasks to block (sleep).
Being able to clearly articulate these trade-offs is a hallmark of a senior embedded engineer.
Preview of the Next Lesson:
We've mentioned that a key benefit of mutex ownership is the ability to solve priority inversion. This dangerous condition occurs when a high-priority task is forced to wait for a lower-priority task, effectively "inverting" their priorities and jeopardizing real-time deadlines. In our next lesson, we will explore this problem in detail and examine the common solutions: the Priority Inheritance Protocol (PIP) and the Priority Ceiling Protocol (PCP).