news 2026/5/1 2:46:28

不懂信号量与完成量,别说你吃透 Linux 内核同步(转)

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
不懂信号量与完成量,别说你吃透 Linux 内核同步(转)

在 Linux 内核同步机制中,信号量与完成量是最基础也最核心的两个组件,而它们的底层逻辑,始终绕不开“内核阻塞唤醒”这一核心机制。很多开发者看似会用信号量做并发控制、用完成量做同步通知,却始终没吃透二者与阻塞唤醒的关联,导致遇到复杂场景就无从下手,甚至写出存在并发隐患的代码。事实上,脱离阻塞唤醒去谈信号量与完成量,不过是停留在“会用”的表面,根本算不上真正理解其设计本质。

内核阻塞唤醒是信号量与完成量的共同底层支撑——信号量通过计数控制进程阻塞与唤醒,实现资源的有序分配;完成量则通过简单的通知机制,完成进程间的同步等待。分不清二者在阻塞唤醒逻辑上的差异,不懂何时该用信号量、何时该用完成量,就很难真正掌握内核同步的精髓。本文就从内核阻塞唤醒机制入手,拆解信号量与完成量的底层实现、核心区别及实战场景,帮你彻底吃透这两个内核必备知识点。

一、回顾 Linux 内核同步机制

1.1 同步的定义

在 Linux 内核的世界里,同步是一种至关重要的机制,它就像是一位严谨的指挥官,严格控制着多个执行路径对系统资源的访问顺序和规则 。这里所说的执行路径,简单来讲,就是在 CPU 上运行的各种代码流,它的范畴很广,既涵盖了用户态线程,这些线程负责处理用户层面的各种任务,比如我们日常使用的应用程序中的线程;也包括内核线程,它们在内核空间中默默运行,承担着诸如内存管理、进程调度等关键任务;甚至连中断服务程序也包含其中,当中断发生时,CPU 会暂停当前任务,转而执行中断服务程序,以处理诸如硬件设备的请求等紧急事务。

为了更形象地理解同步的概念,我们可以把 Linux 内核想象成一个繁忙的图书馆,图书馆里的书籍就是共享资源,而读者则是一个个执行路径。如果没有同步机制,就好比图书馆没有任何借阅规则,读者们可以随意进出书架区,随意借阅和归还书籍,这样必然会导致书籍摆放混乱,借阅记录也会一团糟,其他读者可能就无法顺利找到自己需要的书籍。而有了同步机制,就如同图书馆制定了严格的借阅规则,每次只允许一位读者进入书架区借阅或归还书籍,这样就能保证书籍的有序管理,确保每个读者都能高效地获取到自己需要的资源 。

在多线程的文件读写程序中,多个线程都可能尝试对同一个文件进行读写操作。如果没有同步机制,这些线程可能会同时修改文件内容,导致数据混乱。而通过同步机制,我们可以确保在同一时刻只有一个线程能够对文件进行写入操作,其他线程需要等待,从而保证文件数据的一致性 。

1.2 并发与竞态

并发,简单来说,就是两个或多个执行路径在同一时间段内同时被执行 。在如今的多核 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 不一致,这就是竞态导致的数据不一致问题。

1.3 中断与抢占

中断,是计算机系统中的一个重要概念。简单来讲,当计算机在执行当前程序时,如果出现了某些紧急事件,比如硬件设备发出的请求(如键盘输入、网络数据到达等),或者系统内部的一些定时事件,CPU 就会暂时停止当前程序的执行,转而去处理这些紧急事件。当处理完毕后,再返回原来的程序继续执行 。中断就像是一个紧急通知,它会打断 CPU 正在进行的工作,优先处理更紧急的任务。比如,当有新的数据到达网络接口卡时,会产生一个中断信号,通知 CPU 进行处理 。

抢占,则属于进程调度的范畴。从 Linux 内核 2.6 版本开始,就支持抢占调度。通俗地说,抢占就是当一个任务(可以是用户态进程,也可以是内核线程)正在 CPU 上运行时,如果此时有另一个优先级更高的任务就绪,调度器就会剥夺当前任务的 CPU 执行权,将 CPU 分配给更高优先级的任务,让其得以运行 。这就好比在一场比赛中,原本正在赛道上奔跑的选手,如果突然出现了一个更有实力、更紧急参赛的选手,裁判就会让当前选手暂停比赛,让更有实力的选手先上场。

中断和抢占之间有着密切的关系,抢占依赖中断 。如果当前 CPU 禁止了本地中断,那么也就意味着禁止了本 CPU 上的抢占。但反过来,禁掉抢占并不影响中断 。在一个实时控制系统中,可能会有一些高优先级的中断任务需要立即处理。当这些中断发生时,CPU 会暂停当前正在执行的任务,转而执行中断处理程序,这就体现了中断对任务执行的影响。如果此时系统支持抢占调度,且中断处理程序的优先级高于当前任务,那么在中断处理完成后,调度器可能会直接将 CPU 分配给更高优先级的任务,而不是让原来的任务继续执行,这就是抢占的体现 。

二、信号量原理剖析

2.1 什么是信号量?

信号量(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 。通过这种方式,我们可以有效地控制同时访问资源的进程或线程数量,确保资源的合理使用 。

2.2 信号量的数据结构

在 Linux 内核中,信号量的核心数据结构定义在<linux/semaphore.h>头文件中,如下所示:

struct semaphore { spinlock_t lock; // 自旋锁,用于保护对信号量的操作 unsigned int count; // 资源计数器,表示当前可用资源的数量 struct list_head wait_list; // 等待队列,用于存放等待该信号量的进程 };
  • spinlock_t lock:自旋锁,它的作用是保证对信号量的操作是原子性的,防止多个进程同时对信号量进行操作时出现数据不一致的情况 。当一个进程获取自旋锁后,其他进程如果也尝试获取该锁,会在原地自旋等待,直到锁被释放。例如,当有两个进程同时想要对信号量的 count 值进行修改时,自旋锁可以保证只有一个进程能成功修改,另一个进程必须等待。

  • unsigned int count:资源计数器,这个值表示当前可用资源的数量 。如果 count 大于 0 ,说明有可用资源;如果 count 等于 0 ,表示资源已被全部占用;如果 count 小于 0 ,其绝对值表示等待资源的进程数量 。在前面停车场的例子中,count 就表示当前空闲车位的数量。

  • struct list_head wait_list:等待队列,当一个进程尝试获取信号量但发现 count 值小于等于 0 ,即资源不可用时,该进程会被加入到这个等待队列中,进入睡眠状态,直到有其他进程释放信号量将其唤醒 。等待队列就像是一个排队等候的队伍,所有等待资源的进程都在这里按顺序排队。

在使用信号量之前,需要对其进行初始化,以设置信号量的初始值和相关状态 。Linux 内核提供了两种初始化信号量的方式:静态初始化和动态初始化。

(1)静态初始化:可以使用 DECLARE_SEMAPHORE 或 DEFINE_SEMAPHORE 宏来静态初始化一个信号量,例如:

// 使用 DECLARE_SEMAPHORE 宏初始化一个信号量,初始值为 1 DECLARE_SEMAPHORE(my_sem); // 使用 DEFINE_SEMAPHORE 宏初始化一个信号量,初始值为 1 DEFINE_SEMAPHORE(my_sem);

(2)动态初始化:使用 sema_init 函数来动态初始化一个信号量,可以指定初始值,例如:

struct semaphore my_sem; // 动态初始化信号量 my_sem,初始值为 5 sema_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);

2.3 信号量的工作原理

信号量的工作原理主要体现在获取信号量(down 操作)和释放信号量(up 操作)这两个核心操作上。

(1)down 操作:当一个进程调用 down 系列函数获取信号量时,首先会检查信号量的 count 值。如果 count 大于 0,说明有可用资源,进程将 count 减 1,表示占用了一个资源,然后继续执行后续代码;如果 count 为 0,说明资源已被占用,此时进程会被加入到信号量的等待队列中,并将自身状态设置为睡眠状态,放弃 CPU 使用权,进入等待状态。直到有其他进程释放信号量,将其唤醒。

在多处理器环境下,为了保证对 count 的操作是原子的,会使用自旋锁 lock 来保护对 count 的操作,防止多个进程同时修改 count 值而导致竞态条件。down 函数的实现逻辑如下:

void down(struct semaphore *sem) { unsigned long flags; spin_lock_irqsave(&sem->lock, flags); // 加自旋锁,保护对信号量的操作 sem->count--; // 尝试获取资源,信号量值减 1 if (sem->count < 0) { // 资源不可用,将当前进程加入等待队列并睡眠 __down(sem); } spin_unlock_irqrestore(&sem->lock, flags); // 释放自旋锁 }
  1. 首先,使用 spin_lock_irqsave 函数获取自旋锁,并保存当前中断状态 。这是为了防止在操作信号量的 count 值时,被其他进程或中断打断,确保操作的原子性 。

  2. 将信号量的 count 值减 1 ,表示尝试获取一个资源 。

  3. 检查 count 值,如果 count 值小于 0 ,说明资源已被全部占用,当前进程无法获取资源 。此时调用__down 函数,将当前进程加入信号量的等待队列 wait_list,并将进程状态设置为不可中断的睡眠状态 。然后调度器会选择其他可运行的进程执行,当前进程进入睡眠等待,直到被唤醒 。

  4. 最后,使用 spin_unlock_irqrestore 函数释放自旋锁,并恢复之前保存的中断状态 。

(2)up 操作:当一个进程调用 up 函数释放信号量时,会将信号量的 count 加 1,表示释放了一个资源。然后检查等待队列,如果等待队列不为空,说明有其他进程在等待获取信号量,此时会唤醒等待队列中的第一个进程。被唤醒的进程会重新检查信号量的 count 值,由于 count 已经增加,它可以成功获取信号量(将 count 减 1),然后继续执行。up 函数的实现逻辑如下:

void up(struct semaphore *sem) { unsigned long flags; spin_lock_irqsave(&sem->lock, flags); // 加自旋锁,保护对信号量的操作 sem->count++; // 释放资源,信号量值加 1 if (sem->count <= 0) { // 有进程在等待,唤醒等待队列中的一个进程 __up(sem); } spin_unlock_irqrestore(&sem->lock, flags); // 释放自旋锁 }
  1. 同样,先使用 spin_lock_irqsave 函数获取自旋锁,并保存当前中断状态 。

  2. 将信号量的 count 值加 1 ,表示释放一个资源 。

  3. 检查 count 值,如果 count 值小于等于 0 ,说明有进程在等待该资源 。此时调用__up 函数,从信号量的等待队列 wait_list 中唤醒一个等待的进程 。被唤醒的进程会重新尝试获取信号量,即再次执行 down 操作 。

  4. 最后,使用 spin_unlock_irqrestore 函数释放自旋锁,并恢复之前保存的中断状态 。

2.4 信号量的使用场景

信号量在 Linux 内核的各个子系统中有着广泛的应用,以下是一些常见的使用场景:

  1. 设备驱动:在设备驱动中,经常需要保护共享的硬件资源,防止多个进程同时访问导致冲突 。比如,串口设备在同一时间只能被一个进程使用,就可以使用信号量来实现互斥访问 。当一个进程想要使用串口设备时,先获取信号量,使用完后再释放信号量 。这样其他进程在获取信号量时,如果信号量已被占用,就会等待,直到前一个进程释放信号量。

  2. 文件系统:文件系统中的一些操作,如文件的读写、目录的创建和删除等,可能涉及到共享的数据结构和资源 。使用信号量可以保证这些操作的原子性和互斥性 。例如,在多个进程同时对一个文件进行写操作时,通过信号量可以确保每次只有一个进程能够写入,避免数据混乱 。

  3. 内存管理:在内存分配和释放过程中,信号量可以用于保护共享的内存池或内存管理数据结构 。例如,在多个进程竞争分配内存时,使用信号量可以控制对内存分配函数的访问,防止内存分配出错 。

  4. 网络协议栈:在网络协议栈中,信号量可用于同步不同层次协议之间的操作 。比如,当网络设备接收到数据时,需要通知上层协议栈进行处理 。使用信号量可以保证数据的正确传递和处理顺序,避免数据丢失或处理混乱 。

2.5 最佳实践与注意事项

在使用信号量时,需要遵循一些最佳实践并注意以下事项:

  1. 避免长时间持有信号量:长时间持有信号量会导致其他等待该信号量的进程长时间无法获取资源,从而降低系统的并发性能 。因此,在临界区的代码应尽量简洁高效,尽快完成对共享资源的操作并释放信号量 。例如,在设备驱动中,如果对硬件设备的操作时间较长,可以考虑将部分操作放到中断处理程序或工作队列中异步执行,而不是在持有信号量的临界区内完成所有操作。

  2. 正确选择 API 函数:根据具体的应用场景,选择合适的信号量操作 API 。如果在中断上下文或不希望阻塞的场景中,应使用 down_trylock 函数;如果需要响应信号中断,应使用 down_interruptible 函数;如果对实时性要求较高,不允许被信号中断,可使用 down 函数 。例如,在网络设备驱动的中断处理程序中,由于中断上下文不能睡眠,就需要使用 down_trylock 来尝试获取信号量 。

  3. 设置合理的超时时间:在使用可中断的获取信号量函数(如 down_interruptible)时,可以考虑设置超时时间 。这样可以避免进程因长时间等待信号量而陷入死锁或饥饿状态 。例如,在一个进程等待网络资源时,如果长时间获取不到信号量,可以设置一个超时时间,当超时后,进程可以放弃等待并进行其他处理 。

  4. 避免死锁:死锁是使用信号量时需要特别注意的问题 。死锁通常发生在多个进程相互等待对方释放信号量的情况下 。为了避免死锁,应确保所有进程获取和释放信号量的顺序一致 。例如,在一个多线程程序中,如果线程 A 先获取信号量 S1 ,再获取信号量 S2 ,那么线程 B 也应该按照同样的顺序获取信号量,否则可能会发生死锁 。

  5. 性能考虑:信号量的操作涉及到进程的睡眠和唤醒,会带来一定的性能开销 。因此,在性能要求较高的场景中,应尽量减少信号量的使用次数,或者考虑使用其他更轻量级的同步机制,如自旋锁(适用于资源占用时间极短的情况) 。例如,在一些对实时性要求极高的嵌入式系统中,如果共享资源的访问时间非常短,使用自旋锁可能比信号量更合适,因为自旋锁不会导致进程睡眠,避免了线程切换的开销 。

三、完成量原理剖析

3.1 什么是完成量?

完成量(Completion)是 Linux 内核中另一种重要的同步机制,主要用于多处理器系统中线程间的同步,特别是一个线程等待另一个线程完成特定任务的场景 。它的工作原理基于一个简单的思想:一个线程(或执行单元)在完成某个任务后,通过完成量通知其他等待该任务完成的线程继续执行。

为了更好地理解完成量的工作原理,我们以公交司机和售票员的线程调度为例。在公交车的运行过程中,只有当售票员把门关好后,司机才能启动车辆;而只有当司机停车后,售票员才能打开车门。这里就可以使用完成量来实现这种线程间的同步。假设我们有两个完成量,my_completion1 用于表示售票员关门的事件,my_completion2 用于表示司机停车的事件。司机线程在启动车辆前,会调用 wait_for_completion(&my_completion1)等待售票员关门的完成量。

售票员线程在关门后,调用 complete(&my_completion1)唤醒等待的司机线程。当司机到达站点停车后,调用 complete(&my_completion2)唤醒等待的售票员线程,售票员线程收到通知后调用 wait_for_completion(&my_completion2)等待,然后打开车门 。通过这种方式,完成量实现了线程间的有序调度和同步。

3.2 完成量的数据结构

完成量的数据结构定义在<linux/completion.h>头文件中,如下所示:

struct completion { unsigned int done; // 计数器,用于表示事件是否完成 wait_queue_head_t wait; // 等待队列,用于存放等待该完成量的进程 };
  • unsigned int done:这是一个计数器,它的值至关重要 。如果 done 的值为 0 ,表示事件尚未完成,等待该事件的进程会被阻塞;当 done 的值大于 0 ,则表示事件已经完成,等待队列中的进程会被唤醒 。每次调用 complete 函数时,done 计数器都会加 1 。例如,在一个多线程数据处理程序中,主线程创建了多个子线程来处理数据块 。主线程使用完成量等待所有子线程完成数据处理 。每个子线程完成任务后,调用 complete 函数,使 done 值增加 。当 done 值等于子线程的数量时,主线程知道所有数据处理已完成,可以继续后续操作 。

  • wait_queue_head_t wait:这是一个等待队列,当一个进程调用 wait_for_completion 函数等待完成量时,如果 done 值为 0 ,该进程就会被加入到这个等待队列中,进入睡眠状态,直到有其他进程调用 complete 函数唤醒它 。等待队列就像一个等待室,所有等待事件完成的进程都在这里排队等候 。

在使用完成量之前,需要对其进行初始化,以确保其处于正确的初始状态 。Linux 内核提供了两种初始化完成量的方式:静态初始化和动态初始化 。

(1)静态初始化:使用宏 DECLARE_COMPLETION 来静态初始化完成量,它可以声明并初始化一个完成量结构体 。例如:

DECLARE_COMPLETION(my_completion);

这行代码声明并初始化了一个名为 my_completion 的完成量,done 成员初始化为 0 ,等待队列也被初始化 。这种方式适用于完成量在编译时就确定,且作用域为整个文件的情况 。

(2)动态初始化:通过函数 init_completion 进行动态初始化,该函数接受一个指向完成量结构体的指针作为参数 。例如:

struct completion my_completion; init_completion(&my_completion);

这段代码先定义了一个完成量结构体 my_completion,然后使用 init_completion 函数将其 done 成员初始化为 0 ,并初始化等待队列 。动态初始化适用于完成量在运行时才需要创建的情况,比如在函数内部根据条件动态创建完成量 。

Linux 内核提供了一系列操作完成量的函数,主要包括等待完成量和发送完成信号的函数 。

等待完成量

  • void wait_for_completion(struct completion *x):该函数会阻塞调用进程,直到所等待的完成量被唤醒 。如果 x->done 的值为 0 ,调用进程会进入不可中断的睡眠状态,并被加入到 x->wait 等待队列中 。只有当其他进程调用 complete 函数使 x->done 的值大于 0 时,该进程才会被唤醒并继续执行 。这个函数不能被信号中断,常用于对实时性要求较高,不希望被信号干扰的场景 。例如,在一个实时数据采集系统中,采集线程使用 wait_for_completion 等待数据处理线程完成数据处理,确保数据的及时处理和采集的连续性 。

  • int wait_for_completion_interruptible(struct completion *x):与 wait_for_completion 类似,但它是可中断的 。在等待过程中,如果进程收到信号,函数会返回 -EINTR ,表示被信号中断 。如果完成量被唤醒,函数返回 0 。这个函数适用于需要响应信号的场景,比如用户空间进程在等待完成量时可能需要处理用户发送的信号 。例如,在一个用户空间的文件传输程序中,主线程等待文件传输线程完成传输任务 。如果用户在传输过程中发送了终止信号,主线程可以通过 wait_for_completion_interruptible 函数捕获到信号并进行相应处理 。

  • unsigned long wait_for_completion_timeout(struct completion *x, unsigned long timeout):该函数等待完成量被唤醒,但会设置一个超时时间 timeout 。如果在超时时间内完成量被唤醒,函数返回剩余的时间;如果超时时间到达时完成量仍未被唤醒,函数返回 0 。timeout 以系统的时钟滴答次数 jiffies 来计算 。这个函数用于防止进程无限期等待,适用于对等待时间有要求的场景 。例如,在一个网络连接程序中,客户端等待服务器的响应 。如果服务器在一定时间内没有响应,客户端可以通过 wait_for_completion_timeout 函数超时返回,避免一直等待下去 。

发送完成信号

  • void complete(struct completion *x):该函数用于唤醒一个正在等待完成量 x 的执行单元 。它会将 x->done 的值加 1 ,然后检查等待队列 x->wait 。如果有进程在等待,就唤醒队列中的一个进程 。例如,在一个多线程任务处理程序中,当某个子线程完成任务后,调用 complete 函数通知主线程,主线程就可以继续执行后续操作 。

  • void complete_all(struct completion *x):与 complete 函数不同,complete_all 会唤醒所有正在等待同一个完成量 x 的执行单元 。它同样会将 x->done 的值加 1 ,然后唤醒等待队列 x->wait 中的所有进程 。当有多个进程都在等待同一个事件完成时,使用 complete_all 函数可以一次性唤醒所有等待进程 。例如,在一个并行计算任务中,多个计算线程等待主控制线程完成数据分发 。当主控制线程完成数据分发后,调用 complete_all 函数唤醒所有计算线程,让它们开始并行计算 。

3.3 完成量工作原理详解

以一个公交司机和售票员线程调度的例子来深入理解完成量的工作原理 。假设公交司机和售票员分别由两个线程来模拟,公交车的运行需要售票员先关门,司机才能开车;到达站点后,司机停车,售票员才能开门 。

首先,定义两个完成量 my_completion1 和 my_completion2,分别用于控制司机等待售票员关门和售票员等待司机停车 。

struct completion my_completion1; struct completion my_completion2;

司机线程的代码如下:

int thread_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); // 通知售票员停车 return 0; }

售票员线程的代码如下:

int thread_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"); return 0; }

在初始化部分,对两个完成量进行初始化:

static int hello_init(void) { int ret; printk(KERN_ALERT "Hello everybody~\n"); init_completion(&my_completion1); init_completion(&my_completion2); // 其他初始化代码 return 0; }

当程序运行时,司机线程首先执行 wait_for_completion(&my_completion1),由于此时 my_completion1 的 done 值为 0 ,司机线程会被阻塞,进入等待队列 。接着售票员线程执行,当售票员线程执行到 complete(&my_completion1)时,my_completion1 的 done 值加 1 ,司机线程被唤醒,继续执行后续代码 。

当司机线程到达站点停车后,执行 complete(&my_completion2),通知售票员停车 。售票员线程此时正在执行 wait_for_completion(&my_completion2),被阻塞状态,当收到司机的通知后,售票员线程被唤醒,继续执行开门操作 。通过这样的方式,完成量实现了两个线程之间的精确同步,确保公交车的运行流程符合逻辑 。

3.4 完成量的使用场景

完成量在 Linux 内核的各个子系统中有着广泛的应用,以下是一些常见的使用场景:

  1. 设备驱动开发:在设备驱动中,完成量常用于实现设备操作的同步 。例如,当应用程序向设备发送读或写请求时,驱动程序会创建一个完成量 。应用程序线程调用 wait_for_completion 等待设备操作完成 。当设备完成操作后,驱动程序调用 complete 函数唤醒应用程序线程,通知其操作已完成 。这样可以确保应用程序在设备操作完成后再进行后续处理,避免数据不一致或错误操作 。

  2. 内核线程同步:在多线程的内核程序中,完成量可以用于协调不同内核线程之间的工作 。比如,一个内核线程负责数据的准备工作,另一个内核线程负责数据的处理工作 。准备线程在完成数据准备后,通过完成量通知处理线程,处理线程收到通知后开始处理数据 。这种方式可以提高内核线程之间的协作效率,确保数据处理的正确性和及时性 。

  3. 中断处理与线程同步:在中断处理程序和其他线程之间,完成量也能发挥重要作用 。当中断发生时,中断处理程序可以设置完成量,通知等待的线程进行相应处理 。例如,在网络设备驱动中,当网络数据到达时,产生中断 。中断处理程序接收数据后,通过完成量通知上层协议栈线程进行数据处理,实现中断和线程之间的高效同步 。

四、完成量进行同步案例分析

下面通过一个具体的内核模块代码示例,来更直观地展示完成量在实际中的应用。这段代码实现了两个内核线程之间通过完成量进行同步的功能。

#include <linux/module.h> #include <linux/kernel.h> #include <linux/init.h> #include <linux/completion.h> #include <linux/kthread.h> // 定义完成量 struct completion my_completion; // 定义线程结构体指针 struct task_struct *thread1, *thread2; // 线程 1 的执行函数 static int thread1_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 的执行函数 static int thread2_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; } // 模块初始化函数 static int __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; } // 模块退出函数 static void __exit my_module_exit(void) { // 停止线程 1 if (thread1) { kthread_stop(thread1); } // 停止线程 2 if (thread2) { kthread_stop(thread2); } printk(KERN_INFO "Module exited successfully\n"); } module_init(my_module_init); module_exit(my_module_exit); MODULE_LICENSE("GPL");
  1. 当模块被加载到内核时,my_module_init 函数被调用。在这个函数中,首先通过 init_completion(&my_completion) 初始化完成量 my_completion,此时完成量的 done 成员被初始化为 0,表示等待的事件尚未完成。接着,使用 kthread_create 函数分别创建两个内核线程 thread1 和 thread2,并指定它们的执行函数分别为 thread1_function 和 thread2_function。创建线程后,通过 wake_up_process 函数唤醒这两个线程,使它们开始执行各自的任务。

  2. 线程 1 开始执行 thread1_function 函数,首先打印 Thread1 started,表示线程 1 启动。然后通过 msleep(2000) 模拟执行一些耗时的工作,睡眠 2000 毫秒。完成工作后,打印 Thread1 work completed,表示线程 1 的工作完成。接着调用 complete(&my_completion) 函数,将完成量的 done 成员原子地加 1,并唤醒等待在该完成量上的线程(如果有的话),同时打印 Thread1 signaled completion。

  3. 线程 2 开始执行 thread2_function 函数,首先打印 Thread2 started,表示线程 2 启动。然后调用 wait_for_completion(&my_completion) 函数等待完成量。由于此时完成量的 done 为 0,线程 2 会被加入到完成量的等待队列中,并进入睡眠状态,让出 CPU 资源。当线程 1 调用 complete(&my_completion) 函数后,线程 2 被唤醒,继续执行后面的代码,打印 Thread2 woken up, continuing work,表示线程 2 被唤醒并继续工作。接着通过 msleep(1000) 模拟执行一些工作,睡眠 1000 毫秒,完成工作后打印 Thread2 work completed。

  4. 当模块被卸载时,my_module_exit 函数被调用。在这个函数中,首先检查线程 1 是否存在,如果存在则通过 kthread_stop 函数停止线程 1;然后检查线程 2 是否存在,如果存在则通过 kthread_stop 函数停止线程 2。最后打印 Module exited successfully,表示模块成功退出。

在整个代码执行过程中,完成量作为关键的同步原语,确保了线程 2 在等待线程 1 完成工作后才继续执行,避免了线程之间的竞态条件和数据不一致问题。原子操作保证了对完成量done成员的修改是原子的,不会受到多线程并发访问的影响;等待队列则管理了线程 2 在等待完成量时的睡眠和唤醒操作,实现了线程之间的有效同步。

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

影刀RPA锁屏失败排查:从错误码看Windows会话机制

双11前一周&#xff0c;我负责的店铺数据同步脚本又挂了。凌晨2点&#xff0c;运营在钉钉群里我&#xff1a;"竞品价格没更新&#xff0c;我们定价全错了。"我爬起来连服务器&#xff0c;屏幕黑的。解锁&#xff0c;开影刀日志——停在"点击千牛登录"这一步…

作者头像 李华
网站建设 2026/5/1 2:36:23

COMTool:跨平台通信调试工具的模块化架构深度解析

COMTool&#xff1a;跨平台通信调试工具的模块化架构深度解析 【免费下载链接】COMTool Cross platform communicate assistant(Serial/network/terminal tool)&#xff08; 跨平台 串口调试助手 网络调试助手 终端工具 linux windows mac Raspberry Pi &#xff09;支持插件和…

作者头像 李华
网站建设 2026/5/1 2:29:27

PMSM无感FOC实战:滑模观测器(SMO)的‘坑’我都替你踩过了——增益调节与滤波器设计避坑指南

PMSM无感FOC实战&#xff1a;滑模观测器(SMO)的‘坑’我都替你踩过了——增益调节与滤波器设计避坑指南 调试无感FOC系统时&#xff0c;滑模观测器(SMO)的稳定性与精度往往成为工程师的噩梦。转速估计抖动、低速失锁、收敛速度慢——这些问题背后&#xff0c;90%与滑模增益和低…

作者头像 李华
网站建设 2026/5/1 2:26:22

从零到一:NVDLA深度学习加速器架构解析与实战指南

从零到一&#xff1a;NVDLA深度学习加速器架构解析与实战指南 在AI芯片设计领域&#xff0c;NVDLA&#xff08;NVIDIA深度学习加速器&#xff09;作为开源架构的代表&#xff0c;正成为边缘计算和嵌入式设备的重要选择。这款可定制的神经网络加速器凭借模块化设计和高能效特性&…

作者头像 李华
网站建设 2026/5/1 2:22:24

低代码/无代码革命:软件测试从业者的机遇与挑战

在数字化浪潮的席卷下&#xff0c;低代码/无代码&#xff08;Low-Code/No-Code&#xff0c;LC/NC&#xff09;平台如雨后春笋般涌现&#xff0c;正以颠覆性的力量重塑软件开发的格局。Forrester Research的数据显示&#xff0c;到2025年&#xff0c;低代码/无代码平台将占据全球…

作者头像 李华