In Part 1 of this free Linux device drivers course we defined what a critical section is and saw how multiple CPU cores can race on shared data. But here’s the twist many beginners miss: a data race in Linux device driver code can happen even on a single-core system. The culprits are kernel preemption, blocking calls, and hardware interrupts. This lecture walks through each of them with original, updated examples for the modern kernel — including the 6.12 mainline merge of PREEMPT_RT and the retirement of tasklets in favour of BH workqueues.
Prerequisites
This lecture builds directly on the concept of a critical section and data race covered in Part 1 of this series. Make sure that idea is solid before continuing — everything below assumes you already know why unprotected shared data is dangerous.
Preemptible Kernels and Data Races on a Single CPU
Most distribution kernels today ship with CONFIG_PREEMPT enabled, meaning the kernel itself can be interrupted mid-task and switched to a different, higher-priority piece of work — even while running kernel code, not just user-space code. Imagine your driver’s read handler is halfway through updating a shared structure when the scheduler decides to preempt it and run a different process that happens to call the very same read handler. That second invocation now sees a half-updated structure. This is a genuine data race, and it can occur on a machine with only one CPU core.
Blocking Calls Inside a Critical Section
A blocking call puts the calling task to sleep until some event occurs — waiting for disk I/O, a semaphore, or a network packet are common examples. If your code calls a blocking function while still holding shared data mid-update, the kernel scheduler is free to run a completely different task on that CPU in the meantime, and that task may execute the very same code path. By the time your original task wakes up and resumes, the shared data may have already changed underneath it.
Hardware Interrupts and Data Races
Hardware interrupts have the highest priority on Linux — they can preempt any code, including kernel code, on the CPU where they fire. If your process-context code is mid-way through updating shared data and an interrupt fires on that same CPU, the interrupt handler runs immediately. If that handler touches the same shared data your process context was updating, you have a race between “normal” driver code and interrupt code — and this is one of the most common sources of subtle driver bugs.
Whether interrupt handling and process context actually share data is a question only you, the driver author, can answer by inspecting your own code. If they do, that shared data must be protected against races between process context and interrupt context — not just between two processes.
Choosing the Right Locking Primitive
The Linux kernel offers several tools for protecting critical sections. Picking the wrong one either fails to fix the race or destroys performance. Here is an updated selection guide for current kernels:
| Primitive | Can Sleep Inside It? | Use When |
|---|---|---|
Spinlock (spinlock_t) | No — busy-waits instead | Very short critical sections, including ones shared with interrupt handlers |
spin_lock_irqsave() | No | Data shared between process context and a hardware interrupt handler |
Mutex (struct mutex) | Yes | Longer critical sections in process context that may need to sleep |
Atomic types (atomic_t, refcount_t) | N/A | Simple counters where full locking would be overkill |
| RCU (Read-Copy-Update) | Readers: yes, lock-free | Read-heavy shared data structures, e.g. routing tables |
Below is an original example showing the correct fix for the broken counter shown in Part 1 — protecting it with spin_lock_irqsave() because the counter may also be touched from an interrupt handler:
#include <linux/module.h>
#include <linux/spinlock.h>
static int open_count;
static DEFINE_SPINLOCK(open_count_lock);
static int demo_open(struct inode *inode, struct file *file)
{
unsigned long flags;
spin_lock_irqsave(&open_count_lock, flags);
open_count = open_count + 1;
spin_unlock_irqrestore(&open_count_lock, flags);
pr_info("demo: device opened %d times\n", open_count);
return 0;
}
spin_lock_irqsave() disables local interrupts before taking the lock and restores the previous interrupt state afterwards, which is exactly what you need when the same data can be touched from an interrupt handler on the same CPU.
What’s New: Tasklets Are Being Retired
If you have read older Linux kernel books, you’ll have come across tasklets as the classic mechanism for deferring work out of a hardware interrupt handler. That guidance is now outdated. Starting with kernel 6.9, the tasklet API was formally marked deprecated in favour of a new BH (bottom-half) workqueue mechanism, because tasklets had long-standing design flaws around how they track pending work. New driver code should schedule deferred work using queue_work() on a BH workqueue (or a regular workqueue / threaded IRQ handler) instead of tasklet_schedule(). If you maintain older code, plan a migration — tasklets still work today, but they are on their way out of the kernel.
PREEMPT_RT.Real-World Use Cases
NIC drivers routinely defer packet processing out of the hardware interrupt using workqueues, precisely to avoid holding spinlocks for too long in atomic context.
Storage and industrial control drivers being certified for real-time workloads must audit every critical section for sleep-safety now that PREEMPT_RT is a mainline configuration option.
Several in-tree drivers (USB HCD, MMC/SD host controllers) have already been converted from tasklets to BH workqueues as part of the kernel’s ongoing tasklet retirement effort.
Common Mistakes and Troubleshooting
| Mistake | Symptom | Fix |
|---|---|---|
| Sleeping while holding a spinlock | “scheduling while atomic” kernel warning/crash | Switch to a mutex, or move the sleeping work outside the locked region |
| Using a plain spinlock for data shared with an interrupt handler | Deadlock when the interrupt fires on the same CPU that holds the lock | Use spin_lock_irqsave() / spin_unlock_irqrestore() |
Still using tasklet_schedule() in new code | Deprecation warnings, future removal risk | Move to BH workqueues or threaded IRQ handlers |
| No lock ordering discipline across multiple locks | Rare, hard-to-reproduce deadlocks | Enable lockdep (CONFIG_PROVE_LOCKING) in a debug kernel build |
Best Practices
- Keep every critical section as short as possible — lock, do the minimum work, unlock.
- Never call a blocking/sleeping function while holding a spinlock.
- If data is touched from an interrupt handler, use the
_irqsave/_irqrestorelock variants. - Prefer atomic types for simple counters instead of a full lock.
- Enable lockdep and KCSAN in your development kernel — don’t wait for production bug reports.
- Migrate any tasklet-based deferred work to BH workqueues in new drivers.
Performance Considerations
Spinlocks are cheap when contention is low but waste CPU cycles busy-waiting under heavy contention. Mutexes avoid busy-waiting but cost more to acquire/release due to scheduler involvement. RCU offers the best read-side performance for read-heavy data but adds complexity on the write side. Always measure under realistic concurrent load before assuming a locking choice is “good enough.”
Security Considerations
A missed _irqsave variant, or a race between interrupt and process context, is not just a correctness bug — it is precisely the kind of narrow timing window that has historically been exploited for local privilege escalation in the Linux kernel. Treat interrupt-context locking as a security-critical detail, not an optional refinement.
Summary / Key Takeaways
- Preemption can cause data races even on a single CPU.
- Blocking calls inside a critical section let other tasks run and touch the same data.
- Hardware interrupts can preempt any process-context code, including kernel code.
- Use spinlocks for short, non-sleeping sections; mutexes when you may need to sleep.
- Tasklets are deprecated since kernel 6.9 — prefer BH workqueues in new code.
- PREEMPT_RT has been mainline since kernel 6.12, raising the bar for locking discipline.
Conclusion
Data races in Linux device driver code rarely come from obviously wrong logic — they come from an unprotected assumption about who else might be touching the same data, whether that’s another CPU, a preempting task, or a hardware interrupt. Combine the mental model from this free Linux device drivers course with the right locking primitive for each situation, and you’ll avoid the vast majority of concurrency bugs that plague first-time kernel and driver developers.
FAQ: Preemption, Interrupts and Locking in Linux Drivers
Q1. Can a data race happen on a single-core embedded system?
Yes. Kernel preemption, blocking calls, and hardware interrupts can all interleave execution on one CPU, producing the same kind of race you’d see on SMP hardware.
Q2. When should I use a spinlock instead of a mutex?
Use a spinlock for short critical sections that must not sleep, especially ones shared with an interrupt handler. Use a mutex when the critical section may need to sleep.
Q3. Why is my kernel module crashing with a “scheduling while atomic” message?
You called a function that can sleep (like a blocking allocation) while holding a spinlock. Move the sleeping call outside the locked section, or switch to a mutex.
Q4. Are tasklets still usable in the Linux kernel?
They still work but were marked deprecated starting with kernel 6.9. New drivers should use BH workqueues or threaded interrupt handlers instead.
Q5. What changed with PREEMPT_RT in kernel 6.12?
PREEMPT_RT, previously an out-of-tree patch set maintained for around two decades, was merged into the mainline kernel in version 6.12, making real-time preemption a standard configuration option for ARM64, RISC-V, and x86/x86_64.
Q6. What is lockdep and why should beginners care?
Lockdep is a kernel debugging feature (CONFIG_PROVE_LOCKING) that detects unsafe lock ordering and potential deadlocks automatically during testing, well before they occur in production.
Q7. Is spin_lock_irqsave() always necessary instead of a plain spinlock?
Only when the same data can be accessed from an interrupt handler on the same CPU. If the data is only ever touched from process context on multiple CPUs, a plain spinlock is sufficient.
Go deeper into locking primitives, RCU, and real driver debugging — 100% free at EmbeddedPathashala.
Explore Free Courses Back to Part 1 ←
2 Comments