Skip to main content

TECH VEDA

Linux kernel & Device drivers starts on 31st Oct 2026 enrollingCorporate on-site training - Submit proposal Pick your modulesSharpen your kernel skills: deep dives, drivers, Yocto, CVEs, careers — updated daily. Read the blog →
Deep Dives

What PREEMPT_RT Changes: local_lock and migrate_disable (Part 3)

On PREEMPT_RT, local_lock and migrate_disable split one job in two: the macro expansions, the kernel's case against migration disabling, and the traps.

What PREEMPT_RT Changes: local_lock and migrate_disable (Part 3)

On PREEMPT_RT, local_lock and migrate_disable are the pair that keeps per-CPU code correct once preemption can no longer be switched off. local_lock is two objects under one name: on a non-PREEMPT_RT build an empty struct whose operations are preempt_disable() plus a lockdep annotation, and on PREEMPT_RT a per-CPU spinlock_t that disables neither interrupts nor preemption. migrate_disable() pins the CPU instead, and the kernel’s own comment above it is titled “Migrate-Disable and why it is undesired”. Two consequences matter: the _irqsave suffix does nothing on RT, and data also touched from a non-threaded hard IRQ handler or an NMI needs local_trylock.

Part 2 of this series, Priority Inheritance, showed that the rtmutex boost is an assignment rather than an increment. It left a thread hanging from Part 1, which noted that rt_spin_lock() calls migrate_disable() after acquiring the lock without explaining why pinning the CPU is a separate job. This part takes local_lock and migrate_disable together, because neither makes sense alone: one is the named scope that replaces an unnamed critical section, the other the weaker guarantee it stands on once PREEMPT_RT removes the stronger one. Everything quoted below was read from Linux 7.3, released on 18 October 2026, in include/linux/local_lock.h, include/linux/local_lock_internal.h, include/linux/preempt.h and include/linux/spinlock_rt.h.

The per-CPU pattern that has no name

Start with the code this mechanism replaced, still the common shape in driver trees. A driver keeps a ring of samples per CPU and appends to it from several contexts:

static DEFINE_PER_CPU(struct sample_ring, sample_rings);

void sensor_record(u32 value)
{
        struct sample_ring *ring;
        unsigned long flags;

        local_irq_save(flags);
        ring = this_cpu_ptr(&sample_rings);
        ring->buf[ring->head] = value;
        ring->head = (ring->head + 1) % SAMPLE_RING_SIZE;
        local_irq_restore(flags);
}

This is correct on a mainline kernel and almost impossible to maintain. The problem is not the three lines in the middle. It is that local_irq_save() does not say what it protects, so the protected set is whatever the code between the two calls happens to touch.

That is how it degrades. Six months later someone updates a second per-CPU counter inside the same region, because interrupts are already off and it is free. A year later a third function does the same. Now one unnamed region protects three unrelated things, and removing the interrupt disabling from any one caller is unsafe because nobody can establish what else relied on it. The failure mode is not a crash but a latency figure nobody can reduce, because the honest answer to “what breaks if we shorten it” is that nobody knows.

The other loss is tooling. local_irq_save() has no lockdep presence, so the strongest assertion available to a callee is lockdep_assert_irqs_disabled(), which proves that something disabled interrupts rather than that the right scope was entered.

What local_lock and migrate_disable expand to

The conversion gives the region a name. The whole non-PREEMPT_RT implementation of the plain operation is three lines:

#define __local_lock(lock)                                      \
        do {                                                    \
                preempt_disable();                              \
                __local_lock_acquire(lock);                     \
                __acquire(lock);                                \
        } while (0)

There is no new exclusion mechanism here. preempt_disable() does the work it always did, __local_lock_acquire() is the lockdep bookkeeping, and __acquire() is a static-analysis annotation. The type is worth reading next to it:

context_lock_struct(local_lock) {
#ifdef CONFIG_DEBUG_LOCK_ALLOC
        struct lockdep_map      dep_map;
        struct task_struct      *owner;
#endif
};

typedef struct local_lock local_lock_t;

Without CONFIG_DEBUG_LOCK_ALLOC the structure has no members. A local_lock_t in a production build is an empty struct, and every operation on it compiles to what the open-coded version compiled to. That answers the first objection an embedded engineer raises: on the kernel you ship, the named scope is free.

On PREEMPT_RT the same name is a different object:

/*
 * On PREEMPT_RT local_lock maps to a per CPU spinlock, which protects the
 * critical section while staying preemptible.
 */
typedef spinlock_t local_lock_t;
typedef spinlock_t local_trylock_t;

#define INIT_LOCAL_LOCK(lockname) __LOCAL_SPIN_LOCK_UNLOCKED((lockname))

#define __local_lock(__lock)                                    \
        do {                                                    \
                migrate_disable();                              \
                spin_lock((__lock));                            \
        } while (0)

#define __local_unlock(__lock)                                  \
        do {                                                    \
                spin_unlock((__lock));                          \
                migrate_enable();                               \
        } while (0)

Read the two expansions side by side and the division of labour is visible. On a non-RT kernel one call does both jobs: preempt_disable() excludes other tasks on this CPU and pins the task to it. On RT the jobs are split. spin_lock() here is the rtmutex from Part 2, so it provides exclusion and nothing else, because an RT spinlock leaves the holder preemptible. The pinning must be asked for separately, which is why migrate_disable() appears.

The ordering is not arbitrary. Migration is disabled before the lock is taken and re-enabled after it is dropped, so the task cannot move between choosing its per-CPU lock instance and acquiring it. The other order would let a task take one CPU’s lock and then run on another.

Why migration disabling is weaker than preemption disabling, on purpose

The most useful paragraphs in this subsystem are not in the locking code. They are a comment block in include/linux/preempt.h, and its title states the kernel’s own position:

/*
 * Migrate-Disable and why it is undesired.
 *
 * When a preempted task becomes eligible to run under the ideal model (IOW it
 * becomes one of the M highest priority tasks), it might still have to wait
 * for the preemptee's migrate_disable() section to complete. Thereby suffering
 * a reduction in bandwidth in the exact duration of the migrate_disable()
 * section.
 *
 * Per this argument, the change from preempt_disable() to migrate_disable()
 * gets us:
 *
 * - a higher priority tasks gains reduced wake-up latency; with preempt_disable()
 *   it would have had to wait for the lower priority task.
 *
 * - a lower priority tasks; which under preempt_disable() could've instantly
 *   migrated away when another CPU becomes available, is now constrained
 *   by the ability to push the higher priority task away, which might itself be
 *   in a migrate_disable() section, reducing its available bandwidth.
 *
 * IOW it trades latency / moves the interference term, but it stays in the
 * system, and as long as it remains unbounded, the system is not fully
 * deterministic.
 */

This is a rare thing in kernel source: an author arguing against the mechanism being introduced. Replacing preemption disabling with migration disabling does not remove the interference, it relocates it. The high-priority task that used to wait now runs immediately; the low-priority task that was free to migrate is now stuck on a CPU it cannot leave. The total is unchanged, and while it is unbounded the system is not fully deterministic.

The same comment then says why the kernel does it anyway:

 * PREEMPT_RT breaks a number of assumptions traditionally held. By forcing a
 * number of primitives into becoming preemptible, they would also allow
 * migration. This turns out to break a bunch of per-cpu usage. To this end,
 * all these primitives employ migrate_disable() to restore this implicit
 * assumption.
 *
 * This is a 'temporary' work-around at best. The correct solution is getting
 * rid of the above assumptions and reworking the code to employ explicit
 * per-cpu locking or short preempt-disable regions.
 *
 * The end goal must be to get rid of migrate_disable(), alternatively we need
 * a schedulability theory that does not depend on arbitrary migration.

Note what the stated correct solution is: explicit per-CPU locking, which is what local_lock is. So local_lock and migrate_disable are not peers. Migration disabling is the compatibility shim for code that assumed non-preemptibility implied CPU pinning, and local_lock is the replacement the comment asks for. Every region converted to a named lock is one region closer to that end goal, which is a better argument for the work than tidiness.

The split is stated numerically elsewhere in the same header: PREEMPT_LOCK_OFFSET is PREEMPT_DISABLE_OFFSET on a non-RT build and 0 on RT, under the comment “Locks on RT do not disable preemption”. Code that inferred atomicity from a non-zero preempt count inside a spinlock reads a value that is now zero.

The mechanism behind migration disabling is a per-task counter rather than a lock. In Linux 7.3 the public functions in include/linux/sched.h wrap __migrate_disable() and __migrate_enable() in the same header. The first call increments a per-runqueue count of pinned tasks and sets current->migration_disabled; nested calls only increment the counter, and only the outermost release clears the flag. So it nests safely, and a nested call costs one task-field update.

The conversion, traced on both kernels

Here is the sample ring with a named scope:

#include <linux/local_lock.h>

static DEFINE_PER_CPU(local_lock_t, sample_lock) = INIT_LOCAL_LOCK(sample_lock);
static DEFINE_PER_CPU(struct sample_ring, sample_rings);

void sensor_record(u32 value)
{
        struct sample_ring *ring;
        unsigned long flags;

        local_lock_irqsave(&sample_lock, flags);
        ring = this_cpu_ptr(&sample_rings);
        ring->buf[ring->head] = value;
        ring->head = (ring->head + 1) % SAMPLE_RING_SIZE;
        local_unlock_irqrestore(&sample_lock, flags);
}

The body is unchanged. What changed is that the region has a name, so a function that must be called inside it can say so with lockdep_assert_held(&sample_lock) instead of asserting that interrupts are off somewhere.

On a non-PREEMPT_RT kernel without lockdep, local_lock_irqsave() expands to local_irq_save(flags) and two annotations that generate no code, so the compiled function is the original; with lockdep it also acquires a lock class named sample_lock. On PREEMPT_RT the same line becomes something else:

#define __local_lock_irqsave(lock, flags)                       \
        do {                                                    \
                typecheck(unsigned long, flags);                \
                flags = 0;                                      \
                __local_lock(lock);                             \
        } while (0)

Your flags variable is set to zero and the interrupt state is not touched. The release side is #define __local_unlock_irqrestore(lock, flags) __local_unlock(lock), which ignores the flags argument. Readers of Part 1 will recognise this: it is the treatment spin_lock_irqsave() receives, for the same reason. On RT, local_lock_irq() is also plain __local_lock(lock), so all three variants converge on one implementation.

Callnon-PREEMPT_RTPREEMPT_RT
local_lock()preempt_disable()migrate_disable() then spin_lock()
local_lock_irq()local_irq_disable()identical to local_lock()
local_lock_irqsave()local_irq_save(flags)flags = 0, then local_lock()
local_lock_t size0 bytes without lockdepa full spinlock_t
interrupts during the sectiondisabled by the _irq variantsenabled in every variant

The last row is the one that costs people a bug. If sensor_record() can also be reached from an interrupt handler, the non-RT build is safe because interrupts are off, and on RT the suffix gives no protection. Most handlers on RT are threads, so a concurrent caller blocks on the per-CPU rtmutex and the code still works; a handler registered with IRQF_NO_THREAD, or anything in NMI context, is not a thread and must not take this lock.

local_trylock, and why it always fails in hard IRQ context on RT

That case is why a second type exists. The non-RT definition shows the difference:

/* local_trylock() and local_trylock_irqsave() only work with local_trylock_t */
context_lock_struct(local_trylock) {
#ifdef CONFIG_DEBUG_LOCK_ALLOC
        struct lockdep_map      dep_map;
        struct task_struct      *owner;
#endif
        u8              acquired;
};

typedef struct local_trylock local_trylock_t;

The acquired byte sits outside the debug conditional. Where local_lock_t can be an empty struct, a trylock cannot: answering “is this already held” needs a byte of real state in every build, because preempt_disable() is not something you can test.

On PREEMPT_RT the same operation opens with a refusal:

#define __local_trylock(lock)                                   \
        __try_acquire_ctx_lock(lock, context_unsafe(({          \
                int __locked;                                   \
                                                                \
                if (in_nmi() | in_hardirq()) {                  \
                        __locked = 0;                           \
                } else {                                        \
                        migrate_disable();                      \
                        __locked = spin_trylock((lock));        \
                        if (!__locked)                          \
                                migrate_enable();               \
                }                                               \
                __locked;                                       \
        })))

In NMI or hard IRQ context the attempt returns failure without looking at the lock, and the API documentation in local_lock.h states this plainly: the function “can be used in any context such as NMI or HARDIRQ” but “will _always_ fail to acquire the lock in NMI or HARDIRQ context on PREEMPT_RT”. The reason is structural: the lock is an rtmutex, acquiring one may block, and those contexts cannot block. A caller written as though trylock failure were rare will work on your mainline build and take the fallback path every single time on RT.

Two in-tree users show the shape. The slab allocator’s per-CPU sheaves carry one:

struct slub_percpu_sheaves {
        local_trylock_t lock;
        struct slab_sheaf *main; /* never NULL when unlocked */
        struct slab_sheaf *spare; /* empty or full, may be NULL */
        struct slab_sheaf *rcu_free; /* for batching kfree_rcu() */
};

and the memory controller’s per-CPU stock in mm/memcontrol.c uses the same type. Both are reachable from contexts where blocking is not allowed, and both have a slower path to fall back to. That is the test for this type: not that contention is unlikely, but that a correct fallback exists for when the lock is simply unavailable.

One detail is worth knowing because of which build catches it. On non-PREEMPT_RT the two types are distinct structures, so calling local_trylock() on a plain local_lock_t is a pointer type mismatch the compiler complains about. On PREEMPT_RT both are typedef spinlock_t, so the same mistake compiles cleanly. As in Part 1, the non-RT build is where this error is found.

The traps

The first is scope, and the kernel documentation devotes an example to it. If two functions each disable interrupts and both call a third that relies on that, converting them to two different local locks is wrong, even though each conversion looks correct alone. On a non-RT kernel it still works, because the exclusion comes from the interrupt disabling rather than from either lock. On PREEMPT_RT the two locks are distinct objects and no longer serialise the callers of the shared function. One lock per protected scope, not one per call site. The documented fix is a shared lock plus lockdep_assert_held() in the common function.

The second is nesting. Taking a local_lock_irq() and then a raw_spin_lock() is equivalent to raw_spin_lock_irq() on a non-RT kernel and is broken on RT, where the outer lock leaves the context preemptible and the inner one requires that it is not. Pairing it with an ordinary spin_lock() is correct on both. This is the nesting order Part 1 described, with local_lock in the middle tier, and lockdep enforces it on mainline too.

The third is a macro whose meaning changes between configurations rather than merely its implementation:

/* preemption or migration must be disabled before calling __local_lock_is_locked */
#define __local_lock_is_locked(lock) READ_ONCE(this_cpu_ptr(lock)->acquired)
/* migration must be disabled before calling __local_lock_is_locked */
#define __local_lock_is_locked(__lock)                                  \
        (rt_mutex_owner(&this_cpu_ptr(__lock)->lock) == current)

The non-RT version asks whether this CPU’s instance is held by anyone. The RT version asks whether it is held by the calling task. Those are different questions, and the difference is visible only if you read both branches.

How to check your own build and board

First, establish which kernel you are reasoning about, because every claim above is conditional on it:

raghu@techveda.org:~$ uname -v
raghu@techveda.org:~$ grep -E 'CONFIG_PREEMPT_RT|CONFIG_PREEMPT_COUNT|CONFIG_DEBUG_LOCK_ALLOC|CONFIG_PROVE_LOCKING|CONFIG_DEBUG_PREEMPT' /boot/config-$(uname -r)

A build string containing PREEMPT_RT together with CONFIG_PREEMPT_RT=y means the RT column above applies. Where the configuration is not under /boot, /proc/config.gz is the usual alternative when CONFIG_IKCONFIG_PROC is set.

Second, with lockdep every named lock is visible, because __local_lock_init() registers the lock class with the stringified variable name:

raghu@techveda.org:~$ grep lock-classes /proc/lockdep_stats
raghu@techveda.org:~$ grep sample_lock /proc/lockdep

Third, find candidates in your own tree — an interrupt-disabled region whose body dereferences a per-CPU pointer:

raghu@techveda.org:~$ grep -rn -A4 'local_irq_save' drivers/ | grep -E 'this_cpu_ptr|get_cpu_var|__this_cpu'

Fourth, build the result with CONFIG_PROVE_LOCKING and CONFIG_DEBUG_PREEMPT on your ordinary non-RT kernel and run your normal tests. That is where nesting violations and counter imbalances are reported. Knowing which configuration catches which mistake is the layer we work through in our Linux systems engineering training.

Limits, and what was not verified

The quoted code is from Linux 7.3 and is quoted as it stands. This API has changed shape more than once: local_trylock_t, local_lock_is_locked() and the context-analysis annotations are recent additions, so a longterm kernel such as 6.6 or 6.12 will have the two mapping branches but not necessarily this full API. Check your own tree rather than assuming.

Several things were read only as far as needed. Both branches tell lockdep the lock is per-CPU, through a percpu boolean on RT and LD_LOCK_PERCPU on non-RT, but no claim is made about what lockdep does with it. The account of __migrate_disable() and __migrate_enable() summarises rather than quotes them, and the context_lock_struct and __try_acquire_ctx_lock annotations were not traced.

No timings appear here, because none were measured. The argument that migration disabling relocates interference is the kernel comment’s, not a benchmark, and the sample ring is illustrative code rather than a driver in the tree.

Key takeaways

  • local_lock names a per-CPU critical section. On a non-PREEMPT_RT build without lockdep the type is an empty struct and the operations compile to the preempt_disable() or local_irq_save() they replace, so the conversion costs nothing on a shipping kernel.
  • On PREEMPT_RT the type is a per-CPU spinlock_t, and the operation becomes migrate_disable() followed by spin_lock(), because the RT lock gives exclusion without pinning the CPU.
  • The kernel’s own comment is titled “Migrate-Disable and why it is undesired” and calls it a temporary work-around whose correct replacement is explicit per-CPU locking. That makes local_lock and migrate_disable a replacement and its shim, not two equal tools.
  • On RT the _irq and _irqsave suffixes do nothing, and data also reached from an IRQF_NO_THREAD handler or NMI needs local_trylock, which on RT fails immediately in those contexts by design.
  • One lock per protected scope. Two local locks replacing two interrupt-disabled regions that share a callee still work on mainline and stop serialising on RT.
  • Build non-RT with CONFIG_PROVE_LOCKING and CONFIG_DEBUG_PREEMPT: that is where nesting violations, type mismatches and counter imbalances are reported.
Was this worth your time?

Frequently asked questions

Why does local_lock_irqsave not disable interrupts on PREEMPT_RT?
Because local_lock maps to a per-CPU spinlock_t, which on RT is an rtmutex that may block, and blocking with interrupts disabled is not possible. The macro sets your flags variable to zero and the matching unlock ignores the argument.

What is the difference between local_lock and migrate_disable?
local_lock provides mutual exclusion for a named per-CPU scope. migrate_disable() only pins the task to its CPU and excludes nothing. On RT a local_lock uses both, because the RT spinlock no longer pins the CPU as preempt_disable() did.

When should I use local_trylock instead of local_lock?
When the data is also reached from a context that cannot block, such as an IRQF_NO_THREAD handler or NMI, and you have a correct fallback. On PREEMPT_RT the attempt always fails in those contexts, so the fallback must be a real path rather than an error case.

Can I find local_lock mistakes without booting a PREEMPT_RT kernel?
Many of them, yes. Lockdep enforces the nesting rules on non-RT kernels, the counter checks run under CONFIG_DEBUG_PREEMPT, and a local_trylock() on a plain local_lock_t is a compile error only on the non-RT build, because the two types are identical on RT.

Further reading

RB
Raghu Bharadwaj

Founder, TECH VEDA — 20+ years teaching the Linux kernel, device drivers and embedded systems.

Follow on LinkedIn

Get new posts by email

Kernel, embedded Linux and AI-era engineering — a few sharp reads a month. No spam.

We email occasionally and never share your address.