news 2026/10/4 14:20:43

Linux 内核揭秘 · 同步原语(四):互斥锁 mutex 的语义、初始化与 fast/mid/slow 三条获取路径

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Linux 内核揭秘 · 同步原语(四):互斥锁 mutex 的语义、初始化与 fast/mid/slow 三条获取路径
  • 文档
  • 教程
  • 操作系统

【免费下载链接】linux-insides-zh

Linux 内核揭秘

项目地址:https://gitcode.com/gh_mirrors/li/linux-insides-zh
点击查看免费下载

本篇是《Linux 内核揭秘》同步原语章节的第四部分,聚焦内核中最常用的同步原语之一——互斥锁(mutex,全称MUTual EXclusion)。在读完前几篇的自旋锁与信号量之后,本文将带你从理论语义出发,完整剖析struct mutex的数据结构、静态/动态两种初始化方式,以及mutex_lock/mutex_unlock背后的 fastpath、midpath(乐观自旋)与 slowpath 三条获取路径,最终掌握mutex与semaphore的本质差异及其在内核中的工程实现。

从信号量到互斥锁:为什么需要更严格的语义

在上一篇中我们已经认识了信号量,它在内核中由如下结构表示:

struct semaphore { raw_spinlock_t lock; unsigned int count; struct list_head wait_list; };

该结构包含一把自旋锁(lock)、一个记录现有资源数量的计数器(count)以及一个等待者列表(wait_list)。根据count的取值,semaphore可以同时向多个进程授予资源访问权——它是一个计数型原语。

mutex的概念与信号量相似,却有着更严格的语义:

  • 互斥性:与信号量不同,任意时刻一个mutex只能被一个进程持有,且只有持锁者本人才能对其执行释放(解锁)操作;
  • 避免强制重调度:信号量原语会在等待列表上强制触发重新调度,而mutexAPI 的实现允许在锁未被占用时通过自旋等待避免这一昂贵的上下文切换开销。

从语义上讲,mutex可以视作一个二进制信号量(值只能为 0 或 1),但它的实现与内核中信号量的实现并不相同。理解这一差异,是把握本篇全部内容的前提。

struct mutex:内核中互斥锁的表示

互斥锁同步原语在内核中由如下结构表示(定义于内核头文件include/linux/mutex.h):

struct mutex { atomic_t count; spinlock_t wait_lock; struct list_head wait_list; #if defined(CONFIG_DEBUG_MUTEXES) || defined(CONFIG_MUTEX_SPIN_ON_OWNER) struct task_struct *owner; #endif #ifdef CONFIG_MUTEX_SPIN_ON_OWNER struct optimistic_spin_queue osq; #endif #ifdef CONFIG_DEBUG_MUTEXES void *magic; #endif #ifdef CONFIG_DEBUG_LOCK_ALLOC struct lockdep_map dep_map; #endif };

与struct semaphore类似,mutex也包含count、wait_lock、wait_list三要素,但两者的相似之处到此为止。逐字段来看:

  • count——记录互斥锁的状态,也是 fastpath 操作的核心:
    • 值为1:互斥锁处于**无锁(unlocked)**状态;
    • 值为0:互斥锁处于**上锁(locked)**状态;
    • 值为负数:互斥锁处于上锁状态,且存在其他等待者。
  • wait_lock——一把保护等待队列的自旋锁;
  • wait_list——由该锁的等待者构成的等待队列(双向链表);
  • owner——持有该锁的进程指针,其存在与否取决于CONFIG_DEBUG_MUTEXES或CONFIG_MUTEX_SPIN_ON_OWNER两个配置选项,主要服务于下文将要介绍的乐观自旋(optimistic spinning);
  • osq——optimistic_spin_queue,即乐观自旋所用的 MCS 锁 队列,仅在CONFIG_MUTEX_SPIN_ON_OWNER下存在;
  • magic——仅在CONFIG_DEBUG_MUTEXES下存在,用于存储与互斥锁调试相关的信息;
  • dep_map——仅在CONFIG_DEBUG_LOCK_ALLOC下存在,供内核的锁验证器(lock validator)使用。

各配置选项与字段的对应关系可汇总如下:

内核配置选项影响的mutex字段作用
CONFIG_MUTEX_SPIN_ON_OWNERowner、osq启用乐观自旋(midpath),避免上下文切换
CONFIG_DEBUG_MUTEXESowner、magic互斥锁调试信息存储
CONFIG_DEBUG_LOCK_ALLOCdep_map支持 lockdep 锁验证器
CONFIG_DEBUG_ATOMIC_SLEEP—(影响might_sleep)在原子上下文中睡眠时打印栈追踪

一个进程想获取锁时,直观想法是直接对mutex->count做递减;释放锁时则递增。方向没错,但内核中的真实实现远没有这么简单——它要根据当前 mutex 的状态在三条路径之间做选择。

mutex_waiter:等待队列的节点

当进程无法立即获得锁而必须排队时,它会被以struct mutex_waiter的形式加入等待队列(定义于include/linux/mutex.h):

struct mutex_waiter { struct list_head list; struct task_struct *task; #ifdef CONFIG_DEBUG_MUTEXES void *magic; #endif };

如果你读过本章节的前一部分,会注意到它与kernel/locking/semaphore.c中的semaphore_waiter结构十分相似:

struct semaphore_waiter { struct list_head list; struct task_struct *task; bool up; };

两者的list与task字段都用于表示等待队列:list将等待者挂入双向链表,task指向等待中的进程。唯一差异在于mutex_waiter没有up字段(信号量的up字段用于标记“锁已被释放,你可以醒来了”),取而代之的是一个受CONFIG_DEBUG_MUTEXES控制的magic字段,用于在调试互斥锁问题时存储有用信息。

获取互斥锁的三条路径:fastpath / midpath / slowpath

当某进程试图获取互斥锁时,内核会根据锁的当前状态在三条路径中做选择:

  1. fastpath(快路径)——最快的一条路径。当互斥锁尚未被任何人持有时,直接对mutex->count做原子递减即可;释放时则对称地做原子递增。所有操作都必须是原子的;
  2. midpath(中路径)——即乐观自旋。当锁已被其他进程持有、但持有者仍在运行时,等待进程在已经熟悉的 MCS 锁 上循环自旋。这条路径只有在没有其他更高优先级进程准备运行时才会执行;它被称作“乐观”是因为等待进程既不会睡眠也不会被调度走,从而避免了昂贵的上下文切换;
  3. slowpath(慢路径)——当 fastpath 与 midpath 都无法执行时的兜底路径,行为与信号量的锁操作相似:如果锁无法获取,进程被以struct mutex_waiter加入等待队列,然后进入睡眠。

下面我们按“初始化 → 上锁(三条路径)→ 解锁”的顺序,逐层拆解内核的mutexAPI 实现。

初始化互斥锁:静态与动态两种方式

与信号量一样,mutex支持两种初始化方式。

静态初始化:DEFINE_MUTEX 与 __MUTEX_INITIALIZER

第一种是静态初始化,内核提供了DEFINE_MUTEX宏:

#define DEFINE_MUTEX(mutexname) \ struct mutex mutexname = __MUTEX_INITIALIZER(mutexname)

它接受新定义的互斥锁名称,展开成一个新的struct mutex定义,并由__MUTEX_INITIALIZER宏完成初始化:

#define __MUTEX_INITIALIZER(lockname) \ { \ .count = ATOMIC_INIT(1), \ .wait_lock = __SPIN_LOCK_UNLOCKED(lockname.wait_lock), \ .wait_list = LIST_HEAD_INIT(lockname.wait_list) \ }

可以看到它初始化了结构体中的三个基础字段:

  • count被初始化为1,代表互斥锁处于无锁状态(ATOMIC_INIT(1)保证计数器初始值是原子的);
  • wait_lock自旋锁被初始化为解锁状态;
  • wait_list被初始化为空的双向链表(LIST_HEAD_INIT)。

动态初始化:mutex_init 与 __mutex_init

第二种方式是动态初始化,调用定义于kernel/locking/mutex.c的__mutex_init函数。实践中很少直接调用它,而是使用封装好的mutex_init宏:

# define mutex_init(mutex) \ do { \ static struct lock_class_key __key; \ \ __mutex_init((mutex), #mutex, &__key); \ } while (0)

mutex_init宏先声明一个静态的lock_class_key(供锁验证器使用),再以互斥锁本身、其名称字符串与__key为参数调用__mutex_init。来看__mutex_init的实现:

void __mutex_init(struct mutex *lock, const char *name, struct lock_class_key *key) { atomic_set(&lock->count, 1); spin_lock_init(&lock->wait_lock); INIT_LIST_HEAD(&lock->wait_list); mutex_clear_owner(lock); #ifdef CONFIG_MUTEX_SPIN_ON_OWNER osq_lock_init(&lock->osq); #endif debug_mutex_init(lock, name, key); }

__mutex_init接收三个参数:

  • lock——互斥锁本身;
  • name——调试用的互斥锁名称;
  • key——锁验证器(lockdep)使用的 key。

函数体与静态初始化宏做的事情几乎一一对应:

  1. atomic_set(&lock->count, 1):以原子方式将count设为1,使互斥锁处于无锁状态;
  2. spin_lock_init(&lock->wait_lock):初始化保护等待队列的自旋锁;
  3. INIT_LIST_HEAD(&lock->wait_list):初始化空的等待队列;
  4. mutex_clear_owner(lock):清除锁的持有者(置空owner);
  5. 在CONFIG_MUTEX_SPIN_ON_OWNER开启时调用osq_lock_init(&lock->osq)初始化乐观队列——它只是将乐观队列的tail设为无锁状态,正如include/linux/osq_lock.h中osq_is_locked所体现的判定逻辑:
static inline bool osq_is_locked(struct optimistic_spin_queue *lock) { return atomic_read(&lock->tail) != OSQ_UNLOCKED_VAL; }
  1. 末尾调用debug_mutex_init完成调试相关初始化(本系列不展开讨论调试与 lockdep 内容)。

至此,一个mutex已可投入使用。接下来看它最重要的两个 API:mutex_lock与mutex_unlock,二者均实现于kernel/locking/mutex.c。

mutex_lock 与 fastpath:一条内联汇编

mutex_lock的实现如下:

void __sched mutex_lock(struct mutex *lock) { might_sleep(); __mutex_fastpath_lock(&lock->count, __mutex_lock_slowpath); mutex_set_owner(lock); }

函数开头调用might_sleep宏(来自include/linux/kernel.h)。该宏的实现取决于CONFIG_DEBUG_ATOMIC_SLEEP内核配置选项:若启用,当它在原子上下文中被执行时会打印栈追踪。这纯粹是调试辅助手段,除此之外不做任何事。

随后调用__mutex_fastpath_lock。该函数是体系结构相关的:针对本书讨论的 x86_64 架构,其实现位于arch/x86/include/asm/mutex_64.h。从函数名即可看出,它尝试通过 fastpath 获取锁——也就是试图原子地递减给定互斥锁的count字段。

__mutex_fastpath_lock由两部分组成。第一部分是内联汇编:

asm_volatile_goto(LOCK_PREFIX " decl %0\n" " jns %l[exit]\n" : : "m" (v->counter) : "memory", "cc" : exit);

先看asm_volatile_goto宏,它定义于include/linux/compiler-gcc.h,展开成两个内联汇编:

#define asm_volatile_goto(x...) do { asm goto(x); asm (""); } while (0)

第一个汇编带有goto特性,第二个空的内联汇编充当内存屏障(barrier)。汇编本体以LOCK_PREFIX开头,它只是展开为lock指令前缀:

#define LOCK_PREFIX LOCK_PREFIX_HERE "\n\tlock; "

lock前缀保证被修饰的指令原子地执行。所以汇编的第一条指令decl %0就是在原子地递减mutex->count(即v->counter)。递减之后,如果结果为非负数,jns(jump if not sign)指令会跳转到exit标签——也就是__mutex_fastpath_lock函数的出口:

exit: return;

此时 fastpath 成功,锁已被获取。但如果mutex->count递减后为负,说明锁已被其他进程持有(count从0变成-1),汇编之后会调用 fail 处理函数:

fail_fn(v);

fail_fn是__mutex_fastpath_lock的第二个参数,即指向“获取锁的 midpath/slowpath 函数”的指针。在我们的例子中它是__mutex_lock_slowpath。

在进入 slowpath 之前,先看完mutex_lock的收尾:在最简单的情况下(fastpath 成功),mutex_lock末尾会调用:

mutex_set_owner(lock);

mutex_set_owner定义于内核的kernel/locking/mutex.h,作用是把锁的持有者设为当前进程:

static inline void mutex_set_owner(struct mutex *lock) { lock->owner = current; }

深入 slowpath:__mutex_lock_slowpath 与 __mutex_lock_common

当进程因锁已被他人持有而无法通过 fastpath 获取时,__mutex_lock_slowpath被调用。该函数实现于kernel/locking/mutex.c,开头用container_of从__mutex_fastpath_lock传入的互斥锁状态变量反推出互斥锁本身:

__visible void __sched __mutex_lock_slowpath(atomic_t *lock_count) { struct mutex *lock = container_of(lock_count, struct mutex, count); __mutex_lock_common(lock, TASK_UNINTERRUPTIBLE, 0, NULL, _RET_IP_, NULL, 0); }

随后把得到的互斥锁交给__mutex_lock_common。__mutex_lock_common一上来先关闭抢占,直到下一次重新调度时才恢复:

preempt_disable();

接下来进入**乐观自旋(optimistic spinning)**阶段——这正是前面提到的 midpath。该阶段受CONFIG_MUTEX_SPIN_ON_OWNER控制;若选项被禁用,则直接跳过并落入最后的 slowpath:

if (mutex_optimistic_spin(lock, ww_ctx, use_ww_ctx)) { preempt_enable(); return 0; }

midpath:乐观自旋的循环

mutex_optimistic_spin首先检查当前进程是否需要被重新调度——换言之,检查是否没有其他更高优先级的任务可以运行。若检查通过,它把当前的自旋者(spinner)挂入MCS 锁的等待队列,保证互斥锁在某一时刻只会被一个自旋者获取:

osq_lock(&lock->osq)

随后在下面的循环中迭代并尝试获取锁:

while (true) { owner = READ_ONCE(lock->owner); if (owner && !mutex_spin_on_owner(lock, owner)) break; if (mutex_try_to_acquire(lock)) { lock_acquired(&lock->dep_map, ip); mutex_set_owner(lock); osq_unlock(&lock->osq); return true; } }

逐行解读这个循环:

  1. 首先尝试读取锁的持有者信息(READ_ONCE(lock->owner))。若持有者存在(进程释放互斥锁后该字段可能为空),就在mutex_spin_on_owner中等待,直到持有者释放锁;
  2. 若在等待持有者的过程中出现了更高优先级的任务,就跳出循环,放弃乐观自旋,转入睡眠;
  3. 若锁已被持有者释放,就通过mutex_try_to_acquire尝试获取;
  4. 若获取成功,为该互斥锁设置新持有者(mutex_set_owner),将自身从 MCS 等待队列中移除(osq_unlock),并从mutex_optimistic_spin返回。

此时锁已被成功获取,接着恢复抢占并离开__mutex_lock_common:

if (mutex_optimistic_spin(lock, ww_ctx, use_ww_ctx)) { preempt_enable(); return 0; }

乐观自旋失效时的退化行为

但并非所有情况都这么顺利。可能出现三种情形:在乐观自旋循环期间有新任务到来;进入循环前系统就存在更高优先级的任务;或者CONFIG_MUTEX_SPIN_ON_OWNER干脆被禁用——此时mutex_optimistic_spin什么也不做,直接返回false:

#ifndef CONFIG_MUTEX_SPIN_ON_OWNER static bool mutex_optimistic_spin(struct mutex *lock, struct ww_acquire_ctx *ww_ctx, const bool use_ww_ctx) { return false; } #endif

在所有这类情况下,__mutex_lock_common的行为就退化为与信号量类似。它先再次尝试获取锁——因为锁的持有者在此之前可能已经释放了它:

if (!mutex_is_locked(lock) && (atomic_xchg_acquire(&lock->count, 0) == 1)) goto skip_wait;

atomic_xchg_acquire原子地把count交换为0,并返回旧值。若旧值为1(即锁刚好被释放),说明抢锁成功,直接跳转到skip_wait标签。

若尝试失败,希望获取锁的进程将被加入等待者列表:

list_add_tail(&waiter.list, &lock->wait_list); waiter.task = task;

成功抢到锁的进程则走skip_wait路径,更新锁的持有者、允许抢占,并从__mutex_lock_common返回:

skip_wait: mutex_set_owner(lock); preempt_enable(); return 0;

如果连这一步都未能获取锁,进程将进入下面的睡眠循环:

for (;;) { if (atomic_read(&lock->count) >= 0 && (atomic_xchg_acquire(&lock->count, -1) == 1)) break; if (unlikely(signal_pending_state(state, task))) { ret = -EINTR; goto err; } __set_task_state(task, state); schedule_preempt_disabled(); }

这个循环中:

  • 再次尝试获取锁:只有当count >= 0(锁已释放)且原子交换成功(旧值为1)时才会break。循环体开头会重复一次循环前已做过的尝试,这是有意的:一方面确保一旦锁在之后被解锁,进程能够被唤醒;另一方面允许进程在睡眠之后获得锁;
  • 检查当前进程是否有 pending 的信号:如果进程在等待期间被信号中断,则返回-EINTR并跳转到err标签退出;
  • 若既没拿到锁也没被中断,将任务状态设为TASK_UNINTERRUPTIBLE,并调用schedule_preempt_disabled进入睡眠,等待下次被唤醒后再回到循环。

至此,进程获取互斥锁可能经过的三条路径——fastpath、midpath(乐观自旋)、slowpath——就全部走完了。

mutex_unlock:释放与唤醒

mutex_unlock由希望释放锁的进程调用,同样定义于kernel/locking/mutex.c,它调用来自arch/x86/include/asm/mutex_64.h的__mutex_fastpath_unlock:

void __sched mutex_unlock(struct mutex *lock) { __mutex_fastpath_unlock(&lock->count, __mutex_unlock_slowpath); }

__mutex_fastpath_unlock的实现与__mutex_fastpath_lock非常相似,唯一的区别是这里递增mutex->count:

static inline void __mutex_fastpath_unlock(atomic_t *v, void (*fail_fn)(atomic_t *)) { asm_volatile_goto(LOCK_PREFIX " incl %0\n" " jg %l[exit]\n" : : "m" (v->counter) : "memory", "cc" : exit); fail_fn(v); exit: return; }

incl %0把count递增,使互斥锁回到无锁状态;若结果为正(jg,jump if greater),说明此前count已为非负、没有等待者排队,直接跳转到exit返回。但如果释放时等待队列中还有条目,count递增后仍可能 ≤ 0,此时调用fail_fn,即__mutex_unlock_slowpath。

__mutex_unlock_slowpath同样先用container_of从mutex->count反推出mutex实例,再调用__mutex_unlock_common_slowpath:

__mutex_unlock_slowpath(atomic_t *lock_count) { struct mutex *lock = container_of(lock_count, struct mutex, count); __mutex_unlock_common_slowpath(lock, 1); }

在__mutex_unlock_common_slowpath中,如果等待队列非空,就取出第一个条目并唤醒对应的进程:

if (!list_empty(&lock->wait_list)) { struct mutex_waiter *waiter = list_entry(lock->wait_list.next, struct mutex_waiter, list); wake_up_process(waiter->task); }

wake_up_process唤醒的进程会从之前__mutex_lock_common的睡眠循环中被调度回来,重新尝试获取锁。至此,前一个进程释放了互斥锁,由等待队列中排在最前面的进程接手——这也正是mutex的公平排队语义。

其他互斥锁 API 一览

除了mutex_lock与mutex_unlock,Linux 内核还提供以下互斥锁 API:

  • mutex_lock_interruptible;
  • mutex_lock_killable;
  • mutex_trylock;
  • 以及与之对应的同前缀unlock系列函数。

这些 API 的实现逻辑与信号量提供的对应 API 高度类似,核心差异在于传入的任务状态标志:mutex_lock_interruptible允许进程被信号中断(对应TASK_INTERRUPTIBLE),mutex_lock_killable只允许被致命信号杀死(对应TASK_KILLABLE),而mutex_trylock则像down_trylock一样尝试获取后立即返回、失败时不排队等待。想深入了解这些变体的读者,可以回到信号量 API 一篇对照阅读。

总结

本篇作为《Linux 内核揭秘》同步原语章节的第四部分,完整剖析了互斥锁mutex:

  • 语义层面:mutex与信号量相似,但语义更严格——任意时刻只能有一个持有者,且只有持有者能释放;概念上等价于二进制信号量,实现却完全不同;
  • 结构层面:struct mutex在count/wait_lock/wait_list三要素之上,按CONFIG_MUTEX_SPIN_ON_OWNER、CONFIG_DEBUG_MUTEXES、CONFIG_DEBUG_LOCK_ALLOC等配置选项有条件地携带owner、osq、magic、dep_map字段;
  • 初始化层面:DEFINE_MUTEX/__MUTEX_INITIALIZER静态初始化与mutex_init/__mutex_init动态初始化两条路径;
  • 获取路径层面:fastpath(原子递减count的内联汇编)→ midpath(基于 MCS 锁的乐观自旋)→ slowpath(像信号量一样排队睡眠)三条路径的完整决策与实现;
  • 释放层面:__mutex_fastpath_unlock原子递增count,有等待者时通过__mutex_unlock_common_slowpath唤醒队首进程。

在下一部分中,我们将继续深入内核同步原语,研究基于信号量实现的特殊类型——读者/写者信号量(reader/writer semaphore),届时你会看到count字段如何进一步编码活动读者数、等待写者等复杂状态。

  • 文档
  • 教程
  • 操作系统

【免费下载链接】linux-insides-zh

Linux 内核揭秘

项目地址:https://gitcode.com/gh_mirrors/li/linux-insides-zh
点击查看免费下载

相关推荐

上一篇:如何快速掌握开源免费的ModBus调试神器:QModMaster完整实战指南
下一篇:docx 表格完全指南:用 TypeScript 声明式 API 创建、合并与美化 Word 表格

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/4 14:18:06

CIC-IDS数据集详解:入侵检测特征工程与机器学习建模实战

CIC-IDS数据集是网络安全方向做入侵检测绕不开的一套公开数据集。我最早接触它是在做流量分类特征工程的时候,那时候还在用KDD99打样,后来切到CIC-IDS,发现不管从数据规模、抓包方式,还是流量多样性来看,它都更贴近真实…

作者头像 李华
网站建设 2026/10/4 14:17:13

Linux提升账户权限全攻略:从Permission denied到sudo安全配置

刚接手一台Linux服务器时,最容易遇到的就是权限报错。明明照着文档敲命令,结果弹出一行Permission denied,当时就有点懵。后来折腾的次数多了才明白:Linux提升账户权限不是简单把用户扔进root组那么简单,背后是用户、组…

作者头像 李华
网站建设 2026/10/4 14:16:48

Entitas框架实战:Unity ECS核心概念与快速上手Demo

你翻开任何一个Unity项目,大概率能看到几十个MonoBehaviour,每个都挂着自己的Update,改一个数值可能要跑遍五六个脚本。第一次听说ECS的时候,我也以为这只是个性能优化噱头,直到自己把一个战斗逻辑模块重构为ECS之后&a…

作者头像 李华
网站建设 2026/10/4 14:15:19

插件加载失败排查指南:plugin.json、TypeScript SDK 与 CLI 实战

1. 从“plugins”这个词说起:它到底在解决什么问题如果你最近在折腾 Cursor、Codex CLI、ZCode CLI 这类工具,大概率会在某个时刻撞上plugins这个词。它可能出现在配置文件里,可能出现在启动日志里,也可能出现在某个报错信息里——…

作者头像 李华
网站建设 2026/10/4 14:14:33

设计形态学与第三自然:让形态“长”出来的生成设计实践

团队工位一角常年堆着两样东西:一摞刻着连续曲面切片的草模,一本翻烂了的《On Growth and Form》。有人第一次来会误以为这是生物实验室,其实那本Thompson的经典书旁边就放着犀牛模型、KeyShot渲染图和一个写着"第三自然"的白板。这…

作者头像 李华