What are Preemption, Blocking I/O & Interrupts: Hidden Data Races in Linux Device Drivers-Free Linux Device Drivers Training

Preemption, Blocking I/O & Interrupts: Hidden Data Races in Linux Device Drivers
Part 2 of the Free Linux Device Drivers Course by EmbeddedPathashala
Lecture 2 of 2
Beginner Friendly
Updated for Kernel 6.12+

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.

What You Will Learn
How preemption causes races on one CPU Why blocking calls are dangerous mid-critical-section How hardware interrupts steal the CPU Choosing spinlocks vs mutexes correctly Why tasklets are being replaced by BH workqueues PREEMPT_RT in mainline kernel 6.12

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.

Preemption Mid-Critical-Section (Single CPU)
Process A starts updating shared struct
⚠ Struct half-updated — scheduler preempts Process A here
Process B runs, reads the SAME half-updated struct
Process A resumes later — struct is now inconsistent

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.

Blocking Call Opens the Door to Another Task
Task P1
Enters critical section
Calls a blocking function → goes to sleep
(later) wakes up, resumes
Task P2 (scheduled while P1 sleeps)
Runs the same driver code path
Modifies the same shared data
Rule of thumb: Never hold a spinlock across a call that can sleep. If you need to sleep while protecting data, use a mutex instead — spinlocks are for short, non-sleeping critical sections only.

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.

Interrupt Firing Inside a Critical Section
Driver read() is inside its critical section
⚡ Hardware IRQ fires on the same CPU — preempts everything
IRQ handler touches the same shared struct
read() resumes — data may now be inconsistent

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:

PrimitiveCan Sleep Inside It?Use When
Spinlock (spinlock_t)No — busy-waits insteadVery short critical sections, including ones shared with interrupt handlers
spin_lock_irqsave()NoData shared between process context and a hardware interrupt handler
Mutex (struct mutex)YesLonger critical sections in process context that may need to sleep
Atomic types (atomic_t, refcount_t)N/ASimple counters where full locking would be overkill
RCU (Read-Copy-Update)Readers: yes, lock-freeRead-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.

Modern kernel note: Linux 6.12 also mainlined PREEMPT_RT — real-time preemption support that used to require an out-of-tree patch set for over twenty years. If you are writing drivers that may run under a real-time configuration, your critical sections need to be even shorter and more disciplined, since spinlocks can behave differently (becoming sleepable) under PREEMPT_RT.

Real-World Use Cases

Network Interface Interrupt Handlers

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 Drivers Under PREEMPT_RT

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.

USB and MMC Host Controllers

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

MistakeSymptomFix
Sleeping while holding a spinlock“scheduling while atomic” kernel warning/crashSwitch to a mutex, or move the sleeping work outside the locked region
Using a plain spinlock for data shared with an interrupt handlerDeadlock when the interrupt fires on the same CPU that holds the lockUse spin_lock_irqsave() / spin_unlock_irqrestore()
Still using tasklet_schedule() in new codeDeprecation warnings, future removal riskMove to BH workqueues or threaded IRQ handlers
No lock ordering discipline across multiple locksRare, hard-to-reproduce deadlocksEnable 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 / _irqrestore lock 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.

Continue the Free Linux Device Drivers Course

Go deeper into locking primitives, RCU, and real driver debugging — 100% free at EmbeddedPathashala.

Explore Free Courses Back to Part 1 ←

2 Comments

Leave a Reply

Your email address will not be published. Required fields are marked *