【免费下载链接】linux-insides-zh
Linux 内核揭秘
导读
本文是「Linux 内核揭秘」同步原语章节的第三部分(对应仓库 SyncPrim/linux-sync-3.md),聚焦内核同步原语中的信号量(Semaphore)。在前面两部分中我们已学习过自旋锁与排队自旋锁(ticket spinlock),本篇将从理论出发,讲解信号量为何存在、其struct semaphore数据结构如何组织,并逐行剖析down/up及__down_common等核心 API 的内核源码实现。读完本文,你将能够说清信号量与自旋锁的适用场景差异,理解TASK_INTERRUPTIBLE/TASK_KILLABLE等任务状态在等待队列中的流转,以及schedule_timeout、wake_up_process在锁释放时的协作机制。
为什么在自旋锁之外还需要信号量
Linux 内核已经提供了自旋锁(spinlock)这种同步机制,为什么还需要信号量?答案在于两者设计理念的差异:
自旋锁被设计为仅在极短时间持有。持有自旋锁期间禁止进入睡眠(因为其他等待者正在原地自旋),同时为避免死锁,上下文切换在持锁期间也是不允许的。- 当需要长时间持有锁时,信号量 就是更好的解决方案;反过来,对于需要短期持锁的应用,信号量又并非最优。
像一般的同步原语一样,信号量基于一个变量构建:这个变量的值可以增大或减小,其状态就代表了获取锁的能力。关键点在于,这个变量的取值并不限于0和1,由此衍生出两种信号量类型:
二值信号量:值只能为1或0;普通信号量:值可以为任意非负数。
当信号量的值大于1时,它被称为计数信号量,允许多于一个进程同时获取它。这种机制天然适合记录"现有资源数量"(如空闲缓冲区个数),而自旋锁一次只能为一个任务上锁。此外还有一个重要特性:信号量允许进入睡眠状态。当某进程等待一个已被其他进程获取的锁时,调度器 可以切入其他进程,把 CPU 让给真正有事可做的任务,这正是长临界区场景下的关键优势。
信号量的数据结构:struct semaphore
所有信号量相关的 API 的组织脉络理解)。内核用如下结构体表示信号量:
struct semaphore { raw_spinlock_t lock; unsigned int count; struct list_head wait_list; };结构体由三部分组成:
lock:保护信号量自身的自旋锁(raw_spinlock_t类型,与第一部分讲述的 raw spinlock 一脉相承,参考 SyncPrim/linux-sync-1.md);count:当前可用资源的数量;wait_list:等待获取该锁的进程序列(内核侵入式双向链表,见 DataStructures/linux-datastructures-1.md)。
可以看到,信号量的"资源计数 + 等待者队列"结构,使其既能表达资源池,又能公平地按队列顺序唤醒等待者,这与自旋锁单纯基于 ticket/原子变量的设计截然不同。
信号量的初始化:静态与动态两种方式
在考察信号量 API 之前,必须先知道如何初始化信号量。Linux 内核提供了两种初始化途径:静态初始化与动态初始化。
静态初始化:DEFINE_SEMAPHORE
使用DEFINE_SEMAPHORE宏可以在编译期静态初始化一个信号量:
#define DEFINE_SEMAPHORE(name) \ struct semaphore name = __SEMAPHORE_INITIALIZER(name, 1)注意DEFINE_SEMAPHORE只提供二值信号量(计数固定为1)的初始化。它展开为一个信号量结构体定义,并通过__SEMAPHORE_INITIALIZER宏完成各字段初始化:
#define __SEMAPHORE_INITIALIZER(name, n) \ { \ .lock = __RAW_SPIN_LOCK_UNLOCKED((name).lock), \ .count = n, \ .wait_list = LIST_HEAD_INIT((name).wait_list), \ }该宏传入信号量结构体的名字,并逐域初始化:
.lock使用__RAW_SPIN_LOCK_UNLOCKED宏初始化为未锁定状态。正如第一部分所讲,该宏定义于include/linux/spinlock_types.h,最终展开为__ARCH_SPIN_LOCK_UNLOCKED——即零值/无锁状态:
#define __ARCH_SPIN_LOCK_UNLOCKED { { 0 } }.count初始化为传入的资源数n;.wait_list通过LIST_HEAD_INIT初始化为空链表,等待者队列从空开始。
动态初始化:sema_init
第二种方式是把信号量和期望的资源数目传给sema_init函数,该函数同样定义在include/linux/semaphore.h:
static inline void sema_init(struct semaphore *sem, int val) { static struct lock_class_key __key; *sem = (struct semaphore) __SEMAPHORE_INITIALIZER(*sem, val); lockdep_init_map(&sem->lock.dep_map, "semaphore->lock", &__key, 0); }实现非常直白:复用__SEMAPHORE_INITIALIZER宏整体初始化传入的信号量,然后调用lockdep_init_map为锁注册 lockdep 锁验证 所需的类键(dep_map)。与前几部分一致,本文不深入 lockdep 验证器的细节,只需知道CONFIG_DEBUG_LOCK_ALLOC等配置项开启时它会介入记录锁依赖。
信号量 API 总览
Linux 内核为信号量提供了如下操作接口:
void down(struct semaphore *sem); void up(struct semaphore *sem); int down_interruptible(struct semaphore *sem); int down_killable(struct semaphore *sem); int down_trylock(struct semaphore *sem); int down_timeout(struct semaphore *sem, long jiffies);各接口语义如下:
| 接口 | 行为 |
|---|---|
down | 获取信号量;若count > 0则计数减一并成功返回,否则让当前任务进入不可中断睡眠等待 |
up | 释放信号量;若等待队列非空则唤醒队首等待者,否则count++ |
down_interruptible | 尝试获取;获取失败时以TASK_INTERRUPTIBLE状态睡眠,可被信号唤醒返回-EINTR |
down_killable | 与down_interruptible类似,但置TASK_KILLABLE标志,仅可被杀死类信号中断 |
down_trylock | 类似spin_trylock:尝试获取,失败立即返回而不等待 |
down_timeout | 尝试获取;以传入的jiffies为上限等待,超时后以TASK_UNINTERRUPTIBLE中断进入等待并返回-ETIME |
关于任务状态标志:
down_interruptible若成功获取,计数减少且锁被获取,同时当前任务被置为TASK_INTERRUPTIBLE受阻状态——该标志表示进程可以通过信号被唤醒(例如用户按下 Ctrl+C 发送 SIGINT),从而退出等待。down_killable将TASK_KILLABLE标志置位,表示等待进程只能被杀死信号(如SIGKILL)中断,不会被普通信号打扰,常用于那些需要"可被杀但不必响应普通信号"的内核路径。down_timeout的等待时间以 jiffies(内核时钟滴答)为单位计量。
down 与 up:获取与释放的实现
down 函数
down定义在kernel/locking/semaphore.c源文件中:
void down(struct semaphore *sem) { unsigned long flags; raw_spin_lock_irqsave(&sem->lock, flags); if (likely(sem->count > 0)) sem->count--; else __down(sem); raw_spin_unlock_irqrestore(&sem->lock, flags); } EXPORT_SYMBOL(down);函数开头的flags变量会传给raw_spin_lock_irqsave与raw_spin_unlock_irqrestore宏(定义于include/linux/spinlock.h),用来保护当前信号量的计数器。这两个宏与spin_lock/spin_unlock作用相似,但会保存/恢复当前中断标志并额外禁止中断——保证 count 的读改写是原子的,且持锁期间不会被本地中断打断。
down的核心逻辑很简单:将count与零比较——
- 若
count > 0:说明有可用资源,执行count--,即成功获取锁; - 若
count == 0:所有资源都已被占用,需要等待,于是调用__down。
__down与down定义在同一个源文件kernel/locking/semaphore.c中:
static noinline void __sched __down(struct semaphore *sem) { __down_common(sem, TASK_UNINTERRUPTIBLE, MAX_SCHEDULE_TIMEOUT); }__down只是__down_common的薄封装,传入三个参数:
semaphore:目标信号量;flag:当前任务的等待状态标志;timeout:最长等待信号量的时间。
值得注意的是,所有基于睡眠等待的下沉操作最终都汇聚到__down_common:
static noinline int __sched __down_interruptible(struct semaphore *sem) { return __down_common(sem, TASK_INTERRUPTIBLE, MAX_SCHEDULE_TIMEOUT); }static noinline int __sched __down_killable(struct semaphore *sem) { return __down_common(sem, TASK_KILLABLE, MAX_SCHEDULE_TIMEOUT); }static noinline int __sched __down_timeout(struct semaphore *sem, long timeout) { return __down_common(sem, TASK_UNINTERRUPTIBLE, timeout); }也就是说,down_interruptible、down_killable、down_timeout只是通过传入不同的任务状态标志与超时值来改变等待行为,调度与唤醒的骨架完全共用。
__down_common:核心等待循环
__down_common同样定义在kernel/locking/semaphore.c,以两个本地变量开场:
struct task_struct *task = current; struct semaphore_waiter waiter;task表示当前想获取锁的任务。current宏定义于arch/x86/include/asm/current.h:
#define current get_current()get_current返回current_task这个 per-cpu 变量的值:
DECLARE_PER_CPU(struct task_struct *, current_task); static __always_inline struct task_struct *get_current(void) { return this_cpu_read_stable(current_task); }(per-cpu 变量机制详见 Concepts/linux-cpu-1.md。)
waiter表示semaphore.wait_list列表中的一个入口节点:
struct semaphore_waiter { struct list_head list; struct task_struct *task; bool up; };该结构包含三部分:链表节点list(用于挂入 wait_list)、等待的任务指针task,以及up标志——当持有者释放锁唤醒等待者时置位,作为"锁已到手"的唤醒信号。
接下来,把当前进程加入wait_list并填充waiter各域:
list_add_tail(&waiter.list, &sem->wait_list); waiter.task = task; waiter.up = false;注意使用list_add_tail从尾部入队,等待者按到达顺序排队,保证 FIFO 公平性。
随后进入无限循环:
for (;;) { if (signal_pending_state(state, task)) goto interrupted; if (unlikely(timeout <= 0)) goto timed_out; __set_task_state(task, state); raw_spin_unlock_irq(&sem->lock); timeout = schedule_timeout(timeout); raw_spin_lock_irq(&sem->lock); if (waiter.up) return 0; }由于此前waiter.up = false,只要up没有被持有者置为true,任务就会一直在循环中打转。循环的每一步依次检查:
- 信号检查:调用
signal_pending_state判断当前任务是否处于 pending 状态(即带TASK_INTERRUPTIBLE或TASK_WAKEKILL标志),有信号则跳转interrupted标签。signal_pending_state定义于include/linux/sched.h:
static inline int signal_pending_state(long state, struct task_struct *p) { if (!(state & (TASK_INTERRUPTIBLE | TASK_WAKEKILL))) return 0; if (!signal_pending(p)) return 0; return (state & TASK_INTERRUPTIBLE) || __fatal_signal_pending(p); }逻辑分三步:先检查state位掩码 是否含TASK_INTERRUPTIBLE或TASK_WAKEKILL位,不含则返回 0(不可中断等待不会被打断);再检查任务是否有挂起信号,没有则返回 0;最后综合TASK_INTERRUPTIBLE位与致命信号状态给出结论。若判定有信号可中断,跳到:
interrupted: list_del(&waiter.list); return -EINTR;即把等待者从链表中删除,返回-EINTR错误码,调用方(如系统调用)据此得知"等待被信号打断"。
- 超时检查:
if (unlikely(timeout <= 0)) goto timed_out;跳转目标:
timed_out: list_del(&waiter.list); return -ETIME;与interrupted流程一致——从链表摘除自身,但返回-ETIME错误码表示"等待超时"。
- 睡眠与调度:若既无信号、超时也未过期,就把任务设置为传入的
state:
__set_task_state(task, state);然后暂时释放信号量的自旋锁并调用schedule_timeout:
raw_spin_unlock_irq(&sem->lock); timeout = schedule_timeout(timeout); raw_spin_lock_irq(&sem->lock);schedule_timeout定义于kernel/time/timer.c,它的作用是把当前任务休眠到设定的超时为止;睡眠期间调度器可切入其他任务,这正是信号量允许上下文切换的体现。被唤醒后重新获取sem->lock,回到循环起点继续判断。
- 成功路径:若
waiter.up已被持有者置位(说明我们被唤醒且锁已交接),返回0,任务成功获得信号量。
归纳__down_common的等待语义:一个想获取已被占用锁的函数,会在无限循环中挂起,直至以下三件事之一发生——被信号中断(仅对可中断变体)、超时到期(仅对down_timeout)、或持有者释放锁并唤醒它。
up 函数
up与down定义在同一个源文件kernel/locking/semaphore.c,负责释放锁:
void up(struct semaphore *sem) { unsigned long flags; raw_spin_lock_irqsave(&sem->lock, flags); if (likely(list_empty(&sem->wait_list))) sem->count++; else __up(sem); raw_spin_unlock_irqrestore(&sem->lock, flags); } EXPORT_SYMBOL(up);其骨架与down对称,仅有两处不同:
- 增加计数:若等待列表为空(
list_empty成立),说明没有人在等锁,直接count++归还资源; - 唤醒等待者:若等待列表非空,则调用
__up,把锁让渡给队列中第一个等待者:
static noinline void __sched __up(struct semaphore *sem) { struct semaphore_waiter *waiter = list_first_entry(&sem->wait_list, struct semaphore_waiter, list); list_del(&waiter->list); waiter->up = true; wake_up_process(waiter->task); }__up完成四件事:
list_first_entry取出等待队列的第一个semaphore_waiter(队列按 FIFO 排序,队首等待最久);list_del将其从链表中删除;- 置
waiter->up = true——这一操作让__down_common无限循环中的if (waiter.up) return 0;条件成立,等待者得以退出循环拿到锁; wake_up_process(waiter->task)唤醒该任务。
wake_up_process来自kernel/sched/core.c,是唤醒休眠任务的调度器入口。回看等待侧:__down_common中的schedule_timeout让任务进入睡眠;现在持有者释放锁,必须调用wake_up_process把它从睡眠状态唤醒,任务被重新放回运行队列,随后在循环中看到waiter.up == true,完成获取流程。
总结与延伸
至此,Linux 内核中信号量同步原语的核心实现已全部走通。回顾本部分(SyncPrim/linux-sync-3.md)与同步原语章节的整体脉络:
- 第一部分介绍了自旋锁 API 与 ticket spinlock(SyncPrim/linux-sync-1.md);
- 第二部分介绍了队列自旋锁(SyncPrim/linux-sync-2.md);
- 本部分介绍了信号量:它适合长时间持有的锁,因为等待者会睡眠让出 CPU,代价则是引入上下文切换开销。
信号量的核心设计可概括为:count记录资源余量,wait_list以 FIFO 组织睡眠等待者,down在资源耗尽时挂起任务,up在释放时唤醒队首——整个过程由__down_common统一调度,通过任务状态标志与超时参数衍生出可中断、可杀死、带超时等变体。
后续章节将介绍更严格的同步原语——互斥锁(mutex))。互斥锁与信号量的核心差异在于:互斥锁同一时刻只能由一个进程持有,且只有持有者能解锁;其 API 实现还允许通过乐观自旋避免昂贵的上下文切换,这将是下一篇的主题。
相关阅读
- 同步原语章节总览:SyncPrim/README.md
- 自旋锁与 ticket spinlock:SyncPrim/linux-sync-1.md
- 队列自旋锁(MCS 锁实现):SyncPrim/linux-sync-2.md
- 互斥锁(下一个主题):SyncPrim/linux-sync-4.md
- 读者/写者信号量:SyncPrim/linux-sync-5.md
- 顺序锁:SyncPrim/linux-sync-6.md
- 信号量依赖的内核双向链表:DataStructures/linux-datastructures-1.md
- jiffies 与内核时间管理:Timers/linux-timers-1.md
- per-cpu 变量(
current/current_task的基础):Concepts/linux-cpu-1.md
【免费下载链接】linux-insides-zh
Linux 内核揭秘
相关推荐
Linux内核同步原语:信号量机制详解
Linux内核同步原语:信号量机制详解 前言 在Linux内核开发中,处理并发访问是一个核心问题。作为内核同步原语系列文章的第三部分,本文将深入探讨Linux内
文档教程操作系统Linux内核同步原语:信号量机制详解
Linux内核同步原语:信号量机制详解 Linux内核揭秘项目中的同步原语是保证多任务并发安全的核心机制,而信号量作为其中重要的一员,在资源管理和进程同步中发挥
文档教程操作系统Deepseek Coder 1.3b Instruct模型故障恢复机制:高可用推理服务
Deepseek Coder 1.3b Instruct模型故障恢复机制:高可用推理服务 Deepseek Coder 1.3b Instruct作为开源代码生
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考