☰
Linux 内核信号量(Semaphore)同步原语全解:从数据结构到 down/up 实现剖析
2026/9/28 2:20:02 网站建设 项目流程

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

Linux 内核揭秘

项目地址:https://gitcode.com/hust-open-atom-club/linux-insides-zh
点击查看免费下载

导读

本文是「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,任务就会一直在循环中打转。循环的每一步依次检查:

  1. 信号检查:调用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错误码,调用方(如系统调用)据此得知"等待被信号打断"。

  1. 超时检查:
if (unlikely(timeout <= 0)) goto timed_out;

跳转目标:

timed_out: list_del(&waiter.list); return -ETIME;

与interrupted流程一致——从链表摘除自身,但返回-ETIME错误码表示"等待超时"。

  1. 睡眠与调度:若既无信号、超时也未过期,就把任务设置为传入的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,回到循环起点继续判断。

  1. 成功路径:若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完成四件事:

  1. list_first_entry取出等待队列的第一个semaphore_waiter(队列按 FIFO 排序,队首等待最久);
  2. list_del将其从链表中删除;
  3. 置waiter->up = true——这一操作让__down_common无限循环中的if (waiter.up) return 0;条件成立,等待者得以退出循环拿到锁;
  4. 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 内核揭秘

项目地址:https://gitcode.com/hust-open-atom-club/linux-insides-zh
点击查看免费下载
上一篇:SpiffWorkflow部署指南:从开发环境到生产环境的完整流程
下一篇:Linkerd服务网格终极配置指南:如何快速优化API性能与可靠性

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询