
在 Linux 内核同步机制中,信号量与完成量是最基础也最核心的两个组件,而它们的底层逻辑,始终绕不开“内核阻塞唤醒”这一核心机制。很多开发者看似会用信号量做并发控制、用完成量做同步通知,却始终没吃透二者与阻塞唤醒的关联,导致遇到复杂场景就无从下手,甚至写出存在并发隐患的代码。事实上,脱离阻塞唤醒去谈信号量与完成量,不过是停留在“会用”的表面,根本算不上真正理解其设计本质。
内核阻塞唤醒是信号量与完成量的共同底层支撑——信号量通过计数控制进程阻塞与唤醒,实现资源的有序分配;完成量则通过简单的通知机制,完成进程间的同步等待。分不清二者在阻塞唤醒逻辑上的差异,不懂何时该用信号量、何时该用完成量,就很难真正掌握内核同步的精髓。本文就从内核阻塞唤醒机制入手,拆解信号量与完成量的底层实现、核心区别及实战场景,帮你彻底吃透这两个内核必备知识点。
一、回顾 Linux 内核同步机制
面试题写作模版在 Linux 内核的世界里,同步是一种至关重要的机制,它就像是一位严谨的指挥官,严格控制着多个执行路径对系统资源的访问顺序和规则 。这里所说的执行路径,简单来讲,就是在 CPU 上运行的各种代码流,它的范畴很广,既涵盖了用户态线程,这些线程负责处理用户层面的各种任务,比如我们日常使用的应用程序中的线程;也包括内核线程,它们在内核空间中默默运行,承担着诸如内存管理、进程调度等关键任务;甚至连中断服务程序也包含其中,当中断发生时,CPU 会暂停当前任务,转而执行中断服务程序,以处理诸如硬件设备的请求等紧急事务。
为了更形象地理解同步的概念,我们可以把 Linux 内核想象成一个繁忙的图书馆,图书馆里的书籍就是共享资源,而读者则是一个个执行路径。如果没有同步机制,就好比图书馆没有任何借阅规则,读者们可以随意进出书架区,随意借阅和归还书籍,这样必然会导致书籍摆放混乱,借阅记录也会一团糟,其他读者可能就无法顺利找到自己需要的书籍。而有了同步机制,就如同图书馆制定了严格的借阅规则,每次只允许一位读者进入书架区借阅或归还书籍,这样就能保证书籍的有序管理,确保每个读者都能高效地获取到自己需要的资源 。
在多线程的文件读写程序中,多个线程都可能尝试对同一个文件进行读写操作。如果没有同步机制,这些线程可能会同时修改文件内容,导致数据混乱。而通过同步机制,我们可以确保在同一时刻只有一个线程能够对文件进行写入操作,其他线程需要等待,从而保证文件数据的一致性 。
并发,简单来说,就是两个或多个执行路径在同一时间段内同时被执行 。在如今的多核 CPU 时代,这种现象极为常见。每个 CPU 核心都可以独立地执行任务,就像多个勤劳的小工人各自忙碌着。在一台配备四核 CPU 的电脑上,当我们同时打开浏览器浏览网页、播放音乐、进行文件解压以及运行杀毒软件时,这些任务会被分配到不同的 CPU 核心上并发执行,让我们感觉仿佛它们是在同时进行一样。
然而,并发执行路径在访问共享资源时,却容易引发一个严重的问题 —— 竞态。共享资源可以是硬件资源,比如内存、硬盘、网卡等,也可以是软件层面的全局变量、静态变量等。当多个执行路径同时对共享资源进行读写操作时,如果没有合理的同步机制来协调,就会出现竞态。一旦竞态发生,程序的运行结果就会变得不可预测,可能出现数据不一致、程序崩溃等严重问题 。
以多核 CPU 访问共享内存中的一个全局变量 count 为例,假设 count 的初始值为 0 。现在有两个 CPU 核心,CPU1 和 CPU2,它们都要对 count 进行加 1 操作。在理想情况下,经过两次加 1 操作后,count 的值应该为 2 。但由于竞态的存在,可能会出现以下情况:CPU1 读取 count 的值为 0,然后 CPU2 也读取 count 的值为 0 。
接着 CPU1 将 count 加 1,此时 count 的值变为 1,但还没来得及将结果写回内存。这时 CPU2 也进行加 1 操作,它将自己读取的 0 加 1,得到 1,然后将 1 写回内存。最后 CPU1 再将自己计算得到的 1 写回内存,覆盖了 CPU2 的结果。这样一来,虽然进行了两次加 1 操作,但 count 的值最终却为 1,与我们预期的 2 不一致,这就是竞态导致的数据不一致问题。
中断,是计算机系统中的一个重要概念。简单来讲,当计算机在执行当前程序时,如果出现了某些紧急事件,比如硬件设备发出的请求(如键盘输入、网络数据到达等),或者系统内部的一些定时事件,CPU 就会暂时停止当前程序的执行,转而去处理这些紧急事件。当处理完毕后,再返回原来的程序继续执行 。中断就像是一个紧急通知,它会打断 CPU 正在进行的工作,优先处理更紧急的任务。比如,当有新的数据到达网络接口卡时,会产生一个中断信号,通知 CPU 进行处理 。
抢占,则属于进程调度的范畴。从 Linux 内核 2.6 版本开始,就支持抢占调度。通俗地说,抢占就是当一个任务(可以是用户态进程,也可以是内核线程)正在 CPU 上运行时,如果此时有另一个优先级更高的任务就绪,调度器就会剥夺当前任务的 CPU 执行权,将 CPU 分配给更高优先级的任务,让其得以运行 。这就好比在一场比赛中,原本正在赛道上奔跑的选手,如果突然出现了一个更有实力、更紧急参赛的选手,裁判就会让当前选手暂停比赛,让更有实力的选手先上场。
中断和抢占之间有着密切的关系,抢占依赖中断 。如果当前 CPU 禁止了本地中断,那么也就意味着禁止了本 CPU 上的抢占。但反过来,禁掉抢占并不影响中断 。在一个实时控制系统中,可能会有一些高优先级的中断任务需要立即处理。当这些中断发生时,CPU 会暂停当前正在执行的任务,转而执行中断处理程序,这就体现了中断对任务执行的影响。如果此时系统支持抢占调度,且中断处理程序的优先级高于当前任务,那么在中断处理完成后,调度器可能会直接将 CPU 分配给更高优先级的任务,而不是让原来的任务继续执行,这就是抢占的体现 。
二、信号量原理剖析
面试题写作模版信号量(Semaphore)是一种用于控制对共享资源访问的同步机制,由荷兰计算机科学家 Dijkstra 在 1965 年提出,其本质是一个计数器 。它的核心思想非常简单,通过对计数器的操作来控制对共享资源的访问。当一个进程或线程想要访问共享资源时,它需要先获取信号量,如果信号量的计数器大于 0,说明有可用资源,该进程或线程可以获取信号量并将计数器减 1,然后访问资源;如果计数器为 0,说明资源已被占用,该进程或线程就需要等待,直到有其他进程或线程释放信号量,使计数器增加。
为了更好地理解信号量的工作机制,我们以停车场车位管理为例。假设一个停车场有 100 个车位,这 100 个车位就是共享资源,而信号量就像是停车场的车位计数器。当一辆车进入停车场时,就相当于一个进程想要获取信号量,如果此时计数器大于 0,说明有空闲车位,车辆可以进入停车场,同时车位计数器减 1;如果计数器为 0,说明车位已满,车辆就需要在停车场入口等待,直到有车离开停车场,车位计数器增加,才有机会进入。当车辆离开停车场时,就相当于进程释放信号量,车位计数器加 1 。
信号量的工作原理基于两种经典的原子操作,即 P 操作(也被称为等待操作,在 Linux 内核中通常对应 down 系列函数 )和 V 操作(也被称为发送操作,在 Linux 内核中通常对应 up 函数 )。当一个进程或线程想要访问共享资源时,它需要先执行 P 操作。在 P 操作中,会将信号量的值减 1 。如果此时信号量的值大于等于 0,那就意味着资源是可用的,该进程或线程就可以顺利地访问共享资源;
但如果信号量的值小于 0,那就表明资源已经被其他进程或线程占用,当前进程或线程就需要进入睡眠状态,被放入等待队列中,等待资源的释放 。当一个进程或线程访问完共享资源后,它需要执行 V 操作,将信号量的值加 1 。如果此时信号量的值小于等于 0,那就说明有其他进程或线程正在等待资源,于是就会从等待队列中唤醒一个等待的进程或线程,让其有机会获取资源并继续执行 。
在实际应用中,信号量可以分为两种类型:二值信号量和计数信号量 。二值信号量,简单来说,它的初始值被设定为 1,并且取值范围仅仅只有 0 和 1 这两个值 。这种信号量通常被用于实现互斥访问,它就像是一把独一无二的钥匙,在同一时刻,仅仅允许一个进程或线程持有这把钥匙,访问共享资源,从而确保共享资源在同一时刻只能被一个进程或线程访问 。
而计数信号量的初始值则大于 1,它的取值可以是任意的非负整数 。计数信号量主要用于管理多个相同类型的资源,比如有一个资源池,里面有多个相同的资源,我们就可以使用计数信号量来管理这些资源的分配和释放。当一个进程或线程获取资源时,信号量的值会减 1 ;当一个进程或线程释放资源时,信号量的值会加 1 。通过这种方式,我们可以有效地控制同时访问资源的进程或线程数量,确保资源的合理使用 。
在 Linux 内核中,信号量的核心数据结构定义在<linux/semaphore.h>头文件中,如下所示:
structsemaphore {spinlock_t lock; // 自旋锁,用于保护对信号量的操作unsignedint count; // 资源计数器,表示当前可用资源的数量structlist_headwait_list;// 等待队列,用于存放等待该信号量的进程};在使用信号量之前,需要对其进行初始化,以设置信号量的初始值和相关状态 。Linux 内核提供了两种初始化信号量的方式:静态初始化和动态初始化。
(1)静态初始化:可以使用 DECLARE_SEMAPHORE 或 DEFINE_SEMAPHORE 宏来静态初始化一个信号量,例如:
// 使用 DECLARE_SEMAPHORE 宏初始化一个信号量,初始值为 1DECLARE_SEMAPHORE(my_sem); // 使用 DEFINE_SEMAPHORE 宏初始化一个信号量,初始值为 1DEFINE_SEMAPHORE(my_sem);(2)动态初始化:使用 sema_init 函数来动态初始化一个信号量,可以指定初始值,例如:
structsemaphoremy_sem;// 动态初始化信号量 my_sem,初始值为 5sema_init(&my_sem, 5);在 Linux 内核中,提供了一系列函数来操作信号量,主要包括获取信号量和释放信号量的函数:
(1)获取信号量:void down(struct semaphore *sem):获取信号量 sem 。
它会将信号量的 count 值减 1 ,如果 count 值非负,函数直接返回,调用者可以继续执行;如果 count 值为负,调用者会被阻塞,进入不可中断的睡眠状态,直到有其他进程释放信号量 。这个函数不能在中断上下文(如中断处理程序、软中断处理程序)中使用,因为它会导致进程睡眠,而中断上下文是不允许睡眠的 。例如:
down(&my_semaphore);// 临界区代码,访问共享资源up(&my_semaphore);int down_interruptible(struct semaphore *sem):功能与 down 类似,但它是可中断的 。在获取信号量时,如果 count 值为负,调用者会被阻塞进入可中断的睡眠状态 。如果在睡眠过程中收到信号,函数会被中断并返回 -EINTR 。如果获取信号量成功,返回 0 。这个函数常用于需要响应信号的场景,比如用户空间进程在获取信号量时可能需要处理用户发送的信号 。例如:
if (down_interruptible(&my_semaphore) == 0) {// 临界区代码,访问共享资源 up(&my_semaphore);} else {// 处理被信号中断的情况}int down_trylock(struct semaphore *sem):尝试获取信号量 sem 。如果能够立即获取到信号量(即 count 值大于 0 ),它会将 count 值减 1 并返回 0 ;否则,直接返回非 0 值,表示获取信号量失败 。该函数不会导致调用者睡眠,因此可以在中断上下文或不希望阻塞的场景中使用 。例如:
if (down_trylock(&my_semaphore) == 0) {// 临界区代码,访问共享资源 up(&my_semaphore);} else {// 获取信号量失败,执行其他操作}(2)释放信号量:void up(struct semaphore *sem):释放信号量 sem ,将信号量的 count 值加 1 。
如果 count 值在加 1 后仍为非正数,说明有进程在等待该信号量,此时会唤醒等待队列中的一个进程,使其有机会获取信号量 。例如:
down(&my_semaphore);// 临界区代码,访问共享资源up(&my_semaphore);信号量的工作原理主要体现在获取信号量(down 操作)和释放信号量(up 操作)这两个核心操作上。
(1)down 操作:当一个进程调用 down 系列函数获取信号量时,首先会检查信号量的 count 值。如果 count 大于 0,说明有可用资源,进程将 count 减 1,表示占用了一个资源,然后继续执行后续代码;如果 count 为 0,说明资源已被占用,此时进程会被加入到信号量的等待队列中,并将自身状态设置为睡眠状态,放弃 CPU 使用权,进入等待状态。直到有其他进程释放信号量,将其唤醒。
在多处理器环境下,为了保证对 count 的操作是原子的,会使用自旋锁 lock 来保护对 count 的操作,防止多个进程同时修改 count 值而导致竞态条件。down 函数的实现逻辑如下:
voiddown(struct semaphore *sem){unsignedlong flags; spin_lock_irqsave(&sem->lock, flags); // 加自旋锁,保护对信号量的操作 sem->count--; // 尝试获取资源,信号量值减 1if (sem->count < 0) {// 资源不可用,将当前进程加入等待队列并睡眠 __down(sem); } spin_unlock_irqrestore(&sem->lock, flags); // 释放自旋锁}(2)up 操作:当一个进程调用 up 函数释放信号量时,会将信号量的 count 加 1,表示释放了一个资源。然后检查等待队列,如果等待队列不为空,说明有其他进程在等待获取信号量,此时会唤醒等待队列中的第一个进程。被唤醒的进程会重新检查信号量的 count 值,由于 count 已经增加,它可以成功获取信号量(将 count 减 1),然后继续执行。up 函数的实现逻辑如下:
voidup(struct semaphore *sem){unsignedlong flags; spin_lock_irqsave(&sem->lock, flags); // 加自旋锁,保护对信号量的操作 sem->count++; // 释放资源,信号量值加 1if (sem->count <= 0) {// 有进程在等待,唤醒等待队列中的一个进程 __up(sem); } spin_unlock_irqrestore(&sem->lock, flags); // 释放自旋锁}信号量在 Linux 内核的各个子系统中有着广泛的应用,以下是一些常见的使用场景:
在使用信号量时,需要遵循一些最佳实践并注意以下事项:
三、完成量原理剖析
面试题写作模版完成量(Completion)是 Linux 内核中另一种重要的同步机制,主要用于多处理器系统中线程间的同步,特别是一个线程等待另一个线程完成特定任务的场景 。它的工作原理基于一个简单的思想:一个线程(或执行单元)在完成某个任务后,通过完成量通知其他等待该任务完成的线程继续执行。
为了更好地理解完成量的工作原理,我们以公交司机和售票员的线程调度为例。在公交车的运行过程中,只有当售票员把门关好后,司机才能启动车辆;而只有当司机停车后,售票员才能打开车门。这里就可以使用完成量来实现这种线程间的同步。假设我们有两个完成量,my_completion1 用于表示售票员关门的事件,my_completion2 用于表示司机停车的事件。司机线程在启动车辆前,会调用 wait_for_completion(&my_completion1)等待售票员关门的完成量。
售票员线程在关门后,调用 complete(&my_completion1)唤醒等待的司机线程。当司机到达站点停车后,调用 complete(&my_completion2)唤醒等待的售票员线程,售票员线程收到通知后调用 wait_for_completion(&my_completion2)等待,然后打开车门 。通过这种方式,完成量实现了线程间的有序调度和同步。
完成量的数据结构定义在<linux/completion.h>头文件中,如下所示:
structcompletion {unsignedint done; // 计数器,用于表示事件是否完成wait_queue_head_t wait; // 等待队列,用于存放等待该完成量的进程};在使用完成量之前,需要对其进行初始化,以确保其处于正确的初始状态 。Linux 内核提供了两种初始化完成量的方式:静态初始化和动态初始化 。
(1)静态初始化:使用宏 DECLARE_COMPLETION 来静态初始化完成量,它可以声明并初始化一个完成量结构体 。例如:
DECLARE_COMPLETION(my_completion);这行代码声明并初始化了一个名为 my_completion 的完成量,done 成员初始化为 0 ,等待队列也被初始化 。这种方式适用于完成量在编译时就确定,且作用域为整个文件的情况 。
(2)动态初始化:通过函数 init_completion 进行动态初始化,该函数接受一个指向完成量结构体的指针作为参数 。例如:
structcompletionmy_completion;init_completion(&my_completion);这段代码先定义了一个完成量结构体 my_completion,然后使用 init_completion 函数将其 done 成员初始化为 0 ,并初始化等待队列 。动态初始化适用于完成量在运行时才需要创建的情况,比如在函数内部根据条件动态创建完成量 。
Linux 内核提供了一系列操作完成量的函数,主要包括等待完成量和发送完成信号的函数 。
等待完成量:
发送完成信号:
以一个公交司机和售票员线程调度的例子来深入理解完成量的工作原理 。假设公交司机和售票员分别由两个线程来模拟,公交车的运行需要售票员先关门,司机才能开车;到达站点后,司机停车,售票员才能开门 。
首先,定义两个完成量 my_completion1 和 my_completion2,分别用于控制司机等待售票员关门和售票员等待司机停车 。
structcompletionmy_completion1;structcompletionmy_completion2;司机线程的代码如下:
intthread_driver(void *p){ printk(KERN_ALERT "DRIVER:I AM WAITING FOR SALEMAN CLOSED THE DOOR\n"); wait_for_completion(&my_completion1); // 等待售票员关门 printk(KERN_ALERT "DRIVER:OK, LET'S GO!NOW~\n"); printk(KERN_ALERT "DRIVER:ARRIVE THE STATION.STOPED CAR!\n"); complete(&my_completion2); // 通知售票员停车return0;}售票员线程的代码如下:
intthread_saleman(void *p){ printk(KERN_ALERT "SALEMAN:THE DOOR IS CLOSED!\n"); complete(&my_completion1); // 通知司机门已关闭 printk(KERN_ALERT "SALEMAN:YOU CAN GO NOW!\n"); wait_for_completion(&my_completion2); // 等待司机停车 printk(KERN_ALERT "SALEMAN:OK,THE DOOR BE OPENED!\n");return0;}在初始化部分,对两个完成量进行初始化:
staticinthello_init(void){int ret; printk(KERN_ALERT "Hello everybody~\n"); init_completion(&my_completion1); init_completion(&my_completion2);// 其他初始化代码return0;}当程序运行时,司机线程首先执行 wait_for_completion(&my_completion1),由于此时 my_completion1 的 done 值为 0 ,司机线程会被阻塞,进入等待队列 。接着售票员线程执行,当售票员线程执行到 complete(&my_completion1)时,my_completion1 的 done 值加 1 ,司机线程被唤醒,继续执行后续代码 。
当司机线程到达站点停车后,执行 complete(&my_completion2),通知售票员停车 。售票员线程此时正在执行 wait_for_completion(&my_completion2),被阻塞状态,当收到司机的通知后,售票员线程被唤醒,继续执行开门操作 。通过这样的方式,完成量实现了两个线程之间的精确同步,确保公交车的运行流程符合逻辑 。
完成量在 Linux 内核的各个子系统中有着广泛的应用,以下是一些常见的使用场景:
四、完成量进行同步案例分析
面试题写作模版下面通过一个具体的内核模块代码示例,来更直观地展示完成量在实际中的应用。这段代码实现了两个内核线程之间通过完成量进行同步的功能。
#include<linux/module.h>#include<linux/kernel.h>#include<linux/init.h>#include<linux/completion.h>#include<linux/kthread.h>// 定义完成量structcompletionmy_completion;// 定义线程结构体指针structtask_struct *thread1, *thread2;// 线程 1 的执行函数staticintthread1_function(void *data){ printk(KERN_INFO "Thread1 started\n");// 模拟一些工作 msleep(2000); printk(KERN_INFO "Thread1 work completed\n");// 标记完成量,唤醒等待的线程 complete(&my_completion); printk(KERN_INFO "Thread1 signaled completion\n");return0;}// 线程 2 的执行函数staticintthread2_function(void *data){ printk(KERN_INFO "Thread2 started\n");// 等待完成量,直到被唤醒 wait_for_completion(&my_completion); printk(KERN_INFO "Thread2 woken up, continuing work\n");// 模拟一些工作 msleep(1000); printk(KERN_INFO "Thread2 work completed\n");return0;}// 模块初始化函数staticint __init my_module_init(void){// 初始化完成量 init_completion(&my_completion);// 创建线程 1 thread1 = kthread_create(thread1_function, NULL, "thread1");if (IS_ERR(thread1)) { printk(KERN_ERR "Failed to create thread1\n");return PTR_ERR(thread1); }// 唤醒线程 1 wake_up_process(thread1);// 创建线程 2 thread2 = kthread_create(thread2_function, NULL, "thread2");if (IS_ERR(thread2)) { printk(KERN_ERR "Failed to create thread2\n");// 如果创建线程 2 失败,先停止线程 1 kthread_stop(thread1);return PTR_ERR(thread2); }// 唤醒线程 2 wake_up_process(thread2); printk(KERN_INFO "Module initialized successfully\n");return0;}// 模块退出函数staticvoid __exit my_module_exit(void){// 停止线程 1if (thread1) { kthread_stop(thread1); }// 停止线程 2if (thread2) { kthread_stop(thread2); } printk(KERN_INFO "Module exited successfully\n");}module_init(my_module_init);module_exit(my_module_exit);MODULE_LICENSE("GPL");在整个代码执行过程中,完成量作为关键的同步原语,确保了线程 2 在等待线程 1 完成工作后才继续执行,避免了线程之间的竞态条件和数据不一致问题。原子操作保证了对完成量 done 成员的修改是原子的,不会受到多线程并发访问的影响;等待队列则管理了线程 2 在等待完成量时的睡眠和唤醒操作,实现了线程之间的有效同步。
end
如果这篇文章对你有所启发,欢迎点赞、在看,转发三连。星标⭐账号,还可以第一时间收到推送,感谢你的收看,我们下期再见~
往期干货推荐