Linux同步: completion — 一个简单的同步机制
一、什么是 completion
Linux 内核中的 completion 机制,专门用于解决一类简单且高频的同步场景:等待一项异步任务执行完成。
在驱动与内核开发中,大量场景需要等待异步事件结束:磁盘数据读取完成、硬件初始化就绪、异步线程/工作队列执行完毕等。completion 就是为这类「等待事件完成」的场景量身设计的轻量同步原语。
二、数据结构与核心用法
1. 数据结构
completion 结构极为精简,仅由「完成计数器」和「简易等待队列」两个成员组成,无冗余开销。
struct completion { unsigned int done; // 完成计数:0=未完成,>0=存在待处理完成事件(唤醒后自动递减) struct swait_queue_head wait; // 简易等待队列:维护所有阻塞等待的线程};
done 字段精准语义:
done = 0:无完成事件,调用wait_for_completion 会阻塞当前线程;
单次 complete():将 done 递增 1,然后唤醒队列中第一个等待线程。被唤醒的线程在返回前会消费一个完成事件(将 done 递减 1);
complete_all():将 done 设置为内核特殊值(UINT_MAX/2),一次性唤醒队列中所有等待线程。
complete_all 唤醒所有等待者后,done 保持为 UINT_MAX/2(实际宏为 COMPLETION_DONE_TOTALLY),任何后续的 wait_for_completion 都会立即返回(因为 done > 0),而不是阻塞。因此 complete_all 通常配合模块卸载、设备移除等"终止性"场景使用,之后不再复用该 completion。
2. 典型核心用法
completion 的使用范式高度固定,核心只需掌握「初始化、等待、通知完成」三类 API。
// 1. 初始化方式struct completion comp;init_completion(&comp); // 动态初始化// DECLARE_COMPLETION(comp); // 全局/静态初始化// 2. 等待方:阻塞等待事件完成void reader(void) { wait_for_completion(&comp); // 事件未完成则睡眠,完成后唤醒继续执行 /* 读取处理异步执行结果 */}// 3. 完成方:事件结束,发送完成通知void writer(void) { /* 执行异步任务、IO、硬件操作 */ complete(&comp); // 唤醒一个阻塞的等待线程}
当一个内核线程调用 wait_for_completion,就像顾客在服务窗口排队等待叫号,然后退到一旁等候(调度出去); complete() 是窗口工作人员叫一个号(按排队顺序唤醒一个等待者),该顾客办完业务离开,其他顾客继续等待下一次叫号。 complete_all() 则是窗口挂出"今日停业"的牌子,一次性告知所有排队顾客终止等待,所有人同时散去。
3. 栈上安全使用(核心特性)
completion 支持在函数栈上定义,是其区别于信号量的重要优势,内核提供专属栈上初始化宏:
struct block_io { struct completion *comp; int ret; void *data;};staticvoidio_complete_callback(struct bio *bio){ struct block_io *io = bio->bi_private; io->ret = blk_status_to_errno(bio->bi_status); complete(io->comp); // 中断上下文中唤醒}intblock_read_sync(struct block_device *bdev, sector_t sector, void *buf){ DECLARE_COMPLETION_ONSTACK(comp); // 栈上创建 completion 对象 struct block_io io = { .comp = &comp, .data = buf }; struct bio *bio = bio_alloc(GFP_KERNEL, 1); // 提交 bio,回调设置 io_complete_callback bio->bi_private = &io; bio->bi_end_io = io_complete_callback; submit_io(bio); // 异步提交IO wait_for_completion(&comp); // 阻塞等待任务完成 return io.ret; // 函数返回,栈上comp自动销毁}
栈上使用安全的核心设计:wait_for_completion 返回前,函数栈帧不会销毁,comp 对象内存有效;complete() 完成唤醒操作后,不会再访问 comp 对象,彻底杜绝野指针、内存释放后复用的问题,这是内核刻意设计的安全特性。
安全使用的核心前提:wait_for_completion 及其衍生函数返回后,调用者才能销毁栈帧。因此,只有能保证「事件一定完成」的场景,才适合用栈上 completion。
致命陷阱:可中断/可超时等待函数
intblock_read_sync_with_timeout(struct block_device *bdev, ...){ DECLARE_COMPLETION_ONSTACK(comp); submit_bio(...); // 异步提交,回调中会 complete(&comp) unsigned long ret = wait_for_completion_timeout(&comp, HZ); if (ret == 0) { // 超时返回!栈帧即将销毁 return -ETIMEDOUT; // BUG: 异步任务稍后 complete(&comp) 时, // comp 所在的栈内存已被释放,UAF! } return 0;}
wait_for_completion_*_timeout、wait_for_completion_interruptible、wait_for_completion_killable 等函数可能在事件完成前就因超时或信号而返回。一旦函数返回,调用者销毁栈帧,但异步任务可能还在执行,后续调用 complete(&comp) 时访问的是已释放的栈内存,导致 Use-After-Free。 正确做法:使用可中断/可超时等待函数时,必须将 completion 分配在堆上(kmalloc)或全局静态区,确保其在异步任务完成前始终有效。
三、为什么不直接用信号量实现?
1. 底层结构高度相似
completion 的底层模型与信号量几乎一致,均为「计数器 + 等待队列」,这也是很多人困惑为何需要单独设计 completion 的原因。
信号量数据结构:
/* Please don't access any members of this structure directly */struct semaphore { raw_spinlock_t lock; unsigned int count; // 资源计数(对应 completion.done) struct list_head wait_list; // 等待队列(对应 completion.wait)#ifdef CONFIG_DETECT_HUNG_TASK_BLOCKER unsigned long last_holder;#endif};
2. 信号量可模拟 completion 功能
内核完全可以通过 0 初始信号量模拟事件等待逻辑,代码可正常编译运行:
struct semaphore sem;sema_init(&sem, 0); // 初始无资源,模拟「事件未完成」// 等待方:资源不足则阻塞,等价等待事件完成down(&sem); // 完成方:释放资源唤醒线程,等价事件完成通知up(&sem);
3. 语义与心智模型的本质差距
虽然功能可替代,但信号量实现事件等待属于「强行复用通用接口」,语义错位、可读性差:
信号量:down/up 语义是「资源获取/释放」,心智模型是资源计数管理,用于事件等待需要开发者二次解读;
completion:wait_for_completion/complete 语义是「事件等待/事件完成」,心智模型是事件状态通知,代码自解释、无歧义。
同样的阻塞等待逻辑,down(&sem) 需要揣摩业务意图,而 wait_for_completion(&comp) 一眼可知是等待异步事件完成,大幅降低代码维护与交接的认知成本。
四、命名的价值:从晦涩技巧到直观意图
completion 的独立设计,核心价值不在于性能,而在于语义规范化、代码可读性,该设计理念来自 Linus 2002 年内核邮件的经典论述。
Linus 明确指出:使用信号量做事件等待,是一种「巧妙但晦涩的技巧(clever but obscure)」;而使用 completion 实现,是「显而易见、意图明确(obvious)」的标准写法。
他甚至强调:即便两者底层实现完全一致,也必须保留 completion 的独立 API 与命名,干净统一的语义本身就是不可替代的工程价值。
核心设计思想:代码首先是给人读的,其次才是给机器跑的。
信号量围绕「资源数量」设计,适用于资源竞争、限流、互斥场景;
completion 围绕「事件状态」设计,专一服务于异步等待场景。
相同的底层机制,通过不同的 API 命名约束,规范了开发者的使用心智,规避滥用、统一代码风格、降低维护风险,这就是命名与语义设计的核心意义。
五、完整 API 手册
1. 初始化 API
| | |
|---|
| | 清零 done、初始化等待队列,适用于堆/全局变量 |
| | 仅清零 done,不改动等待队列;需保证无并发等待线程 |
| | |
| DECLARE_COMPLETION_ONSTACK(comp) | | |
2. 等待 API(仅进程上下文可调用)
| | | |
|---|
| wait_for_completion(&comp) | | | |
| wait_for_completion_interruptible(&comp) | | | 0=事件正常完成,-ERESTARTSYS=被信号打断 |
| wait_for_completion_timeout(&comp, timeout) | | | 0=等待超时,>0=剩余计时jiffies(等待成功) |
| wait_for_completion_interruptible_timeout(&comp, timeout) | | | -ERESTARTSYS=信号打断,0=超时,>0=等待成功 |
注意:所有等待类 API 均会触发线程睡眠,禁止在中断上下文、原子上下文(自旋锁区间)调用。
3. 完成通知 API
| | |
|---|
| | 按 FIFO 顺序唤醒一个阻塞线程,可多次调用唤醒多个等待者 |
| | 将 done 置为特殊值,一次性唤醒所有线程,禁止多次调用 |
4. 状态查询 API(中断/原子上下文安全)
| | |
|---|
| | |
| try_wait_for_completion(&comp) | | 非阻塞尝试消费事件;成功消费返回true,无事件返回false,不睡眠 |