不懂信号量与完成量,别说你吃透 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 操作这两个核心操作上。1down 操作当一个进程调用 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); // 释放自旋锁 }首先使用 spin_lock_irqsave 函数获取自旋锁并保存当前中断状态 。这是为了防止在操作信号量的 count 值时被其他进程或中断打断确保操作的原子性 。将信号量的 count 值减 1 表示尝试获取一个资源 。检查 count 值如果 count 值小于 0 说明资源已被全部占用当前进程无法获取资源 。此时调用__down 函数将当前进程加入信号量的等待队列 wait_list并将进程状态设置为不可中断的睡眠状态 。然后调度器会选择其他可运行的进程执行当前进程进入睡眠等待直到被唤醒 。最后使用 spin_unlock_irqrestore 函数释放自旋锁并恢复之前保存的中断状态 。2up 操作当一个进程调用 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); // 释放自旋锁 }同样先使用 spin_lock_irqsave 函数获取自旋锁并保存当前中断状态 。将信号量的 count 值加 1 表示释放一个资源 。检查 count 值如果 count 值小于等于 0 说明有进程在等待该资源 。此时调用__up 函数从信号量的等待队列 wait_list 中唤醒一个等待的进程 。被唤醒的进程会重新尝试获取信号量即再次执行 down 操作 。最后使用 spin_unlock_irqrestore 函数释放自旋锁并恢复之前保存的中断状态 。2.4 信号量的使用场景信号量在 Linux 内核的各个子系统中有着广泛的应用以下是一些常见的使用场景设备驱动在设备驱动中经常需要保护共享的硬件资源防止多个进程同时访问导致冲突 。比如串口设备在同一时间只能被一个进程使用就可以使用信号量来实现互斥访问 。当一个进程想要使用串口设备时先获取信号量使用完后再释放信号量 。这样其他进程在获取信号量时如果信号量已被占用就会等待直到前一个进程释放信号量。文件系统文件系统中的一些操作如文件的读写、目录的创建和删除等可能涉及到共享的数据结构和资源 。使用信号量可以保证这些操作的原子性和互斥性 。例如在多个进程同时对一个文件进行写操作时通过信号量可以确保每次只有一个进程能够写入避免数据混乱 。内存管理在内存分配和释放过程中信号量可以用于保护共享的内存池或内存管理数据结构 。例如在多个进程竞争分配内存时使用信号量可以控制对内存分配函数的访问防止内存分配出错 。网络协议栈在网络协议栈中信号量可用于同步不同层次协议之间的操作 。比如当网络设备接收到数据时需要通知上层协议栈进行处理 。使用信号量可以保证数据的正确传递和处理顺序避免数据丢失或处理混乱 。2.5 最佳实践与注意事项在使用信号量时需要遵循一些最佳实践并注意以下事项避免长时间持有信号量长时间持有信号量会导致其他等待该信号量的进程长时间无法获取资源从而降低系统的并发性能 。因此在临界区的代码应尽量简洁高效尽快完成对共享资源的操作并释放信号量 。例如在设备驱动中如果对硬件设备的操作时间较长可以考虑将部分操作放到中断处理程序或工作队列中异步执行而不是在持有信号量的临界区内完成所有操作。正确选择 API 函数根据具体的应用场景选择合适的信号量操作 API 。如果在中断上下文或不希望阻塞的场景中应使用 down_trylock 函数如果需要响应信号中断应使用 down_interruptible 函数如果对实时性要求较高不允许被信号中断可使用 down 函数 。例如在网络设备驱动的中断处理程序中由于中断上下文不能睡眠就需要使用 down_trylock 来尝试获取信号量 。设置合理的超时时间在使用可中断的获取信号量函数如 down_interruptible时可以考虑设置超时时间 。这样可以避免进程因长时间等待信号量而陷入死锁或饥饿状态 。例如在一个进程等待网络资源时如果长时间获取不到信号量可以设置一个超时时间当超时后进程可以放弃等待并进行其他处理 。避免死锁死锁是使用信号量时需要特别注意的问题 。死锁通常发生在多个进程相互等待对方释放信号量的情况下 。为了避免死锁应确保所有进程获取和释放信号量的顺序一致 。例如在一个多线程程序中如果线程 A 先获取信号量 S1 再获取信号量 S2 那么线程 B 也应该按照同样的顺序获取信号量否则可能会发生死锁 。性能考虑信号量的操作涉及到进程的睡眠和唤醒会带来一定的性能开销 。因此在性能要求较高的场景中应尽量减少信号量的使用次数或者考虑使用其他更轻量级的同步机制如自旋锁适用于资源占用时间极短的情况 。例如在一些对实时性要求极高的嵌入式系统中如果共享资源的访问时间非常短使用自旋锁可能比信号量更合适因为自旋锁不会导致进程睡眠避免了线程切换的开销 。三、完成量原理剖析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, LETS 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 内核的各个子系统中有着广泛的应用以下是一些常见的使用场景设备驱动开发在设备驱动中完成量常用于实现设备操作的同步 。例如当应用程序向设备发送读或写请求时驱动程序会创建一个完成量 。应用程序线程调用 wait_for_completion 等待设备操作完成 。当设备完成操作后驱动程序调用 complete 函数唤醒应用程序线程通知其操作已完成 。这样可以确保应用程序在设备操作完成后再进行后续处理避免数据不一致或错误操作 。内核线程同步在多线程的内核程序中完成量可以用于协调不同内核线程之间的工作 。比如一个内核线程负责数据的准备工作另一个内核线程负责数据的处理工作 。准备线程在完成数据准备后通过完成量通知处理线程处理线程收到通知后开始处理数据 。这种方式可以提高内核线程之间的协作效率确保数据处理的正确性和及时性 。中断处理与线程同步在中断处理程序和其他线程之间完成量也能发挥重要作用 。当中断发生时中断处理程序可以设置完成量通知等待的线程进行相应处理 。例如在网络设备驱动中当网络数据到达时产生中断 。中断处理程序接收数据后通过完成量通知上层协议栈线程进行数据处理实现中断和线程之间的高效同步 。四、完成量进行同步案例分析下面通过一个具体的内核模块代码示例来更直观地展示完成量在实际中的应用。这段代码实现了两个内核线程之间通过完成量进行同步的功能。#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);当模块被加载到内核时my_module_init 函数被调用。在这个函数中首先通过 init_completion(my_completion) 初始化完成量 my_completion此时完成量的 done 成员被初始化为 0表示等待的事件尚未完成。接着使用 kthread_create 函数分别创建两个内核线程 thread1 和 thread2并指定它们的执行函数分别为 thread1_function 和 thread2_function。创建线程后通过 wake_up_process 函数唤醒这两个线程使它们开始执行各自的任务。线程 1 开始执行 thread1_function 函数首先打印 Thread1 started表示线程 1 启动。然后通过 msleep(2000) 模拟执行一些耗时的工作睡眠 2000 毫秒。完成工作后打印 Thread1 work completed表示线程 1 的工作完成。接着调用 complete(my_completion) 函数将完成量的 done 成员原子地加 1并唤醒等待在该完成量上的线程如果有的话同时打印 Thread1 signaled completion。线程 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。当模块被卸载时my_module_exit 函数被调用。在这个函数中首先检查线程 1 是否存在如果存在则通过 kthread_stop 函数停止线程 1然后检查线程 2 是否存在如果存在则通过 kthread_stop 函数停止线程 2。最后打印 Module exited successfully表示模块成功退出。在整个代码执行过程中完成量作为关键的同步原语确保了线程 2 在等待线程 1 完成工作后才继续执行避免了线程之间的竞态条件和数据不一致问题。原子操作保证了对完成量done成员的修改是原子的不会受到多线程并发访问的影响等待队列则管理了线程 2 在等待完成量时的睡眠和唤醒操作实现了线程之间的有效同步。