Linux Kernel Synchronization Tutorial: Critical Sections and Atomicity Explained-Free Linux Device Drivers Training

Linux Kernel Synchronization Tutorial: Critical Sections and Atomicity Explained

Part 1 of our free Linux kernel programming course — understand critical sections, race conditions, and atomic operations the way modern kernel and device driver code actually uses them

⏱ 16 min read
🐧 Kernel 6.x Ready
🆓 100% Free

If you are serious about Linux kernel synchronization, the very first thing you need to internalize is that “correct” kernel code is not just code that compiles and runs once without crashing — it is code that behaves correctly even when a dozen CPU cores are hammering it at the same time. Whether you are writing a character device driver, a network filter, or a kernel thread, sooner or later your code will touch data that some other code path can also touch simultaneously. This free lesson from EmbeddedPathashala’s Linux kernel development course walks you through critical sections and atomicity from first principles, using current kernel APIs rather than outdated examples.

What You Will Learn

Critical sections Race conditions Atomic operations atomic_t and refcount_t Process vs interrupt context SMP concurrency Preemptible kernel behaviour Kernel module demo

Prerequisites

  • Basic C programming (pointers, structs, function pointers)
  • A working Linux kernel module build environment (kernel headers installed, make, gcc)
  • Comfort with the terminal — insmod, rmmod, dmesg
  • No prior synchronization experience required — we build it up from zero

What Is a Critical Section in Linux Kernel Programming?

A critical section is any piece of code that reads or writes data shared between more than one execution path — two threads, two CPU cores, a process and an interrupt handler, or two kernel modules touching the same global. The moment more than one path can enter that code “at the same time,” you have a critical section, and it must be protected. Code that only touches local, stack-based variables is never a critical section, because every thread has its own private stack — there is nothing to share and therefore nothing to race on.

Two properties matter here, and beginners often mix them up:

  • Exclusivity — only one thread is allowed to execute the critical section’s code at any instant; every other contender must wait.
  • Atomicity — the operation appears to happen as a single, indivisible step; no other code can observe it “half-done.”

Not every critical section needs both. Code running in a normal, sleep-capable process context (a typical driver’s open(), read(), or ioctl() callback) usually only needs exclusivity. Code running in a non-blocking atomic context — a hardware interrupt handler, a tasklet, or a softirq — needs both exclusivity and atomicity, because it cannot afford to sleep while waiting for a lock.

Critical Section Timeline — Safe vs Unsafe Zones
Time Window Data Accessed Concurrency Status
t0 → t1 Local / stack-only variables Safe — runs in parallel freely
t1 → t2 Global / static shared data Critical section — must be locked
t2 → t3 Local / stack-only variables Safe — runs in parallel freely

Why This Matters More on Modern Multicore Systems

Even small embedded boards today commonly ship with two to eight CPU cores, and the mainline kernel is fully preemptible on most distributions (CONFIG_PREEMPT or the real-time PREEMPT_RT patches merged into recent kernels). That means your driver code can be interrupted mid-execution by a higher-priority task on the very same core, on top of true parallelism from other cores. A bug that “never showed up” during testing on a single-core board can appear in production within hours on a quad-core SoC. This is precisely why every serious embedded Linux and device driver course spends real time on synchronization instead of glossing over it.

Atomicity: What the Hardware Actually Guarantees

At the CPU level, only two things are guaranteed atomic on virtually all modern architectures:

  1. The execution of a single machine instruction.
  2. An aligned read or write of a primitive type that fits within the processor’s native word size (typically 32 or 64 bits).

So a plain int increment like counter++ is not atomic — it decomposes into a load, an add, and a store, and another CPU can slip in between those steps. This is exactly why the kernel provides dedicated atomic types instead of relying on plain integers for shared counters.

Using atomic_t in Current Kernels

The kernel’s atomic_t type (declared in <linux/atomic.h>) wraps a 32-bit integer and guarantees that operations on it are indivisible across all CPUs, without you needing an explicit lock:

#include <linux/atomic.h>

static atomic_t open_count = ATOMIC_INIT(0);

static int mydev_open(struct inode *inode, struct file *filp)
{
    if (atomic_inc_return(&open_count) > 1) {
        atomic_dec(&open_count);
        return -EBUSY;   /* device already open elsewhere */
    }
    return 0;
}

static int mydev_release(struct inode *inode, struct file *filp)
{
    atomic_dec(&open_count);
    return 0;
}

For simple reference counting, current kernels actually prefer the newer refcount_t type over raw atomic_t, because it adds overflow and underflow protection and will warn loudly via the kernel’s hardening checks if your driver mismanages a reference:

#include <linux/refcount.h>

static refcount_t clients = REFCOUNT_INIT(1);

refcount_inc(&clients);          /* take a reference   */
if (refcount_dec_and_test(&clients))
    cleanup_device();            /* last reference gone */
Choosing the Right Atomic Primitive
Type Best For Notes
atomic_t Generic 32-bit shared counters/flags No overflow protection
atomic64_t Large counters, byte/packet totals 64-bit width
refcount_t Object/device reference counting Built-in overflow/underflow protection, preferred since kernel 4.11+

Seeing a Race Condition for Real: A Minimal Kernel Module Demo

Nothing builds intuition faster than watching a race happen. The module below deliberately uses a plain int instead of an atomic type, then two kernel threads hammer it with increments. On a multicore VM you will reliably see the final count fall short of the expected value.

#include <linux/kernel.h>
#include <linux/module.h>
#include <linux/kthread.h>
#include <linux/delay.h>

#define ITERATIONS 200000

static int shared_counter;   /* intentionally NOT atomic */

static int worker(void *data)
{
    int i;
    for (i = 0; i < ITERATIONS; i++)
        shared_counter++;    /* unsafe: read-modify-write */
    return 0;
}

static int __init race_demo_init(void)
{
    struct task_struct *t1, *t2;

    shared_counter = 0;
    t1 = kthread_run(worker, NULL, "race_worker_1");
    t2 = kthread_run(worker, NULL, "race_worker_2");

    msleep(500);
    pr_info("race_demo: expected=%d actual=%d\n",
            2 * ITERATIONS, shared_counter);
    return 0;
}

static void __exit race_demo_exit(void)
{
    pr_info("race_demo: module unloaded\n");
}

module_init(race_demo_init);
module_exit(race_demo_exit);
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("Demonstrates an unprotected kernel race condition");

Build and load it to see the mismatch yourself:

$ make
$ sudo insmod race_demo.ko
$ dmesg | tail -n 3
$ sudo rmmod race_demo

Now replace shared_counter with an atomic_t and swap shared_counter++ for atomic_inc(&shared_counter). Reload the module and the expected and actual counts will match every single time — that is atomicity doing its job.

Process Context vs Interrupt Context

Understanding where your critical section runs decides which protection tool you’re even allowed to use — a topic we cover in full depth in the next lecture on mutexes and spinlocks. For now, remember this split:

Context Can It Sleep? Typical Examples
Process context Yes open(), read(), ioctl(), kernel threads, workqueues
Atomic / interrupt context No Hardirq handlers, tasklets, softirqs
⚠ Common Mistake: Calling a sleeping function (like mutex_lock() or kmalloc(GFP_KERNEL, …)) from interrupt context. This can hang or crash the kernel. Always check the context your code runs in before choosing a locking primitive.

Best Practices for Critical Sections

  • Keep critical sections as short as possible — long locked regions kill scalability on multicore systems.
  • Never call blocking/sleeping functions while holding a spinlock (covered in detail in the next lecture).
  • Prefer existing atomic types (atomic_t, refcount_t) over hand-rolled locking for simple counters.
  • Use kernel lock-debugging aids — CONFIG_PROVE_LOCKING (lockdep) and CONFIG_DEBUG_ATOMIC_SLEEP — during development to catch mistakes early.
  • Document, in a comment, exactly what data a lock protects — future maintainers (including you) will thank you.

Performance Considerations

Protecting shared data always costs something — cache-line bouncing between cores, potential contention, and in some cases scheduling overhead. The goal is not to eliminate all sharing but to minimize the size and duration of critical sections, and to pick the lightest primitive that is still correct (an atomic op instead of a full lock, where possible).

Security Considerations

Unsynchronized access to shared kernel data isn’t just a correctness bug — it is a well-known class of security vulnerability. Time-of-check-to-time-of-use (TOCTOU) races and use-after-free bugs caused by missing or incorrect locking have led to real, exploitable privilege-escalation CVEs in the Linux kernel. Treat every unreviewed critical section as a potential attack surface, not just a stability risk.

Real-World Use Case

Consider a USB-to-serial driver that maintains an “open count” so that only one application can hold the device at a time, plus a shared receive buffer that the USB completion callback (running in interrupt context) fills while a user-space read() (running in process context) drains it. The open count is a perfect candidate for refcount_t; the receive buffer needs a lock that is safe to take from interrupt context — exactly the kind of decision we unpack in the next lecture.

Summary — Key Takeaways

  • A critical section is any code touching data shared across execution paths.
  • Exclusivity means “one thread at a time”; atomicity means “no partial results are ever visible.”
  • Only single-instruction execution and aligned, word-sized reads/writes are atomic by hardware guarantee alone.
  • Use atomic_t/atomic64_t for counters and refcount_t for reference counting in modern kernels.
  • Whether code runs in process context or interrupt context determines which locking primitives are safe to use.

Conclusion

Getting comfortable with critical sections and atomicity is the foundation everything else in Linux kernel synchronization is built on. Once you can confidently spot a critical section and reason about whether it needs exclusivity, atomicity, or both, you’re ready to move on to the actual locking primitives — mutexes and spinlocks — which is exactly where the next lecture in this free Linux kernel development course picks up.

Frequently Asked Questions

What is the difference between a critical section and a race condition?

A critical section is the code region that touches shared data. A race condition is the bug that occurs when that critical section is not properly protected and two or more threads execute it concurrently, producing unpredictable results.

Is a single 32-bit integer read always atomic on Linux?

Only if it is naturally aligned and within the CPU’s native word size. On 32-bit and 64-bit architectures, aligned 32-bit accesses are atomic by hardware guarantee, but you should still use atomic_t for anything shared, since compilers can otherwise reorder or optimize accesses in ways that break assumptions.

When should I use refcount_t instead of atomic_t?

Use refcount_t whenever you’re counting references to an object’s lifetime (like a device or a data structure). It behaves like atomic_t but adds overflow/underflow protection and kernel hardening warnings, which atomic_t does not provide.

Can interrupt handlers sleep while waiting for a lock?

No. Hardirq handlers, tasklets, and softirqs run in atomic context and must never call sleeping functions such as mutex_lock(). Doing so can hang or crash the system. This constraint is central to choosing between mutexes and spinlocks, covered in the next lecture.

Do I need synchronization on a single-core embedded system?

Yes, in most cases. Even on a single core, kernel preemption and interrupts can interleave execution of your code, producing the same class of races you’d see on multicore hardware. True single-core, non-preemptible, interrupt-free systems are rare in modern Linux configurations.

What kernel debugging tools help catch synchronization bugs?

Enable CONFIG_PROVE_LOCKING (lockdep) to detect potential deadlocks, and CONFIG_DEBUG_ATOMIC_SLEEP to catch sleeping-function calls made from atomic context. Both are invaluable during development and should be enabled in your test kernel builds.

Is this tutorial based on an old or outdated kernel?

No. This lesson uses current kernel APIs and conventions, including refcount_t and modern lockdep/hardening options, and is written to reflect how synchronization is actually done in recent 6.x Linux kernels rather than legacy examples.

Continue the Free Linux Kernel Development Course

This is Part 1 of EmbeddedPathashala’s free Linux kernel programming and device drivers course. Keep going to learn mutexes, spinlocks, and locking with interrupts in Part 2.

Next Lecture: Mutex vs Spinlock → Browse Full Course

2 Comments

Leave a Reply

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