C 语言下的 WaitGroup 设计
1. 问题背景
在并发程序中,经常会遇到一种需求:
一个线程启动或安排若干个并发任务,然后等待这些任务全部完成后再继续执行。
这类模式通常被称为 fan-out / fan-in:
text
fan-out:把工作拆分成多个并发任务;
fan-in:等待所有任务汇合完成。
例如:
text
1. 主线程启动多个工作线程处理不同数据片段;
2. 一个任务被拆分成多个子任务并行执行;
3. 多个 I/O 请求同时发出,最后等待全部返回;
4. 多个计算任务并发执行,最后合并结果;
5. 多个后台任务完成后,再进行下一阶段处理。
如果没有等待机制,主线程可能会在子任务完成前继续执行甚至退出,导致:
text
1. 部分任务尚未完成;
2. 共享结果还没有写入完毕;
3. 资源被提前释放;
4. 后续逻辑读取到不完整状态;
5. 程序行为变得依赖线程调度时机。
Go 语言中提供了 sync.WaitGroup 来表达这种需求:
go
wg.Add(1)
go func() {
defer wg.Done()
doWork()
}()
wg.Wait()
C 语言标准库本身没有 WaitGroup,但可以使用 POSIX 线程库中的 pthread_mutex_t 和 pthread_cond_t 实现一个类似机制。
2. WaitGroup 的核心思想
WaitGroup 本质上是一个:
text
带阻塞等待能力的线程安全计数器。
它维护一个计数值:
text
count = 当前尚未完成的任务数量
当一个任务被安排执行时,计数器加一:
c
WaitGroupAdd(&wg, 1);
当一个任务完成时,计数器减一:
c
WaitGroupDone(&wg);
当某个线程需要等待所有任务完成时,调用:
c
WaitGroupWait(&wg);
如果 count > 0,等待线程阻塞。
如果 count == 0,等待线程返回。
3. 基本语义
WaitGroup 通常提供三个核心操作:
text
Add(delta) 增加或减少待等待任务数量;
Done() 表示一个任务完成,等价于 Add(-1);
Wait() 阻塞等待,直到计数器归零。
典型使用方式如下:
c
WaitGroup wg;
WaitGroupInit(&wg);
WaitGroupAdd(&wg, 1);
start_async_task(...);
WaitGroupWait(&wg);
WaitGroupDestroy(&wg);
在子任务完成时:
c
WaitGroupDone(&wg);
需要注意:
text
WaitGroup 等待的是"任务完成";
pthread_join 等待的是"线程退出"。
这两个概念相关,但不能混为一谈。
4. 数据结构设计
头文件可以这样定义:
c
#ifndef WAITGROUP_H
#define WAITGROUP_H
#include <pthread.h>
typedef struct WaitGroup {
int count;
pthread_mutex_t mutex;
pthread_cond_t cond;
} WaitGroup;
int WaitGroupInit(WaitGroup *wg);
void WaitGroupAdd(WaitGroup *wg, int delta);
void WaitGroupDone(WaitGroup *wg);
void WaitGroupWait(WaitGroup *wg);
void WaitGroupDestroy(WaitGroup *wg);
#endif
其中:
c
int count;
表示当前尚未完成的任务数量。
c
pthread_mutex_t mutex;
用于保护 count。
多个线程可能同时调用:
c
WaitGroupAdd()
WaitGroupDone()
WaitGroupWait()
所以对 count 的读写必须互斥。
c
pthread_cond_t cond;
用于让等待线程休眠,并在 count == 0 时唤醒等待线程。
5. 为什么不能只用普通 int
如果多个线程同时修改:
c
wg->count;
就会产生数据竞争。
例如两个线程同时执行:
c
wg->count = wg->count - 1;
可能出现如下情况:
text
初始 count = 2
线程 A 读取 count = 2
线程 B 读取 count = 2
线程 A 写回 count = 1
线程 B 写回 count = 1
实际完成了两个任务,但 count 只减少了一次。
因此 count 必须由 mutex 保护。
6. 为什么不用 busy wait
一种粗糙写法是:
c
while (wg->count > 0) {
}
这叫忙等。
问题是:
text
1. 持续占用 CPU;
2. 等待线程无法休眠;
3. 任务时间较长时浪费严重;
4. 仍然需要同步机制保护 count;
5. 不适合一般并发程序。
更合理的方式是使用条件变量:
text
count > 0 时,等待线程休眠;
count == 0 时,完成任务的线程唤醒等待者。
所以 WaitGroup 的基本结构应该是:
text
count + mutex + cond
7. 初始化函数
c
int WaitGroupInit(WaitGroup *wg) {
if (wg == NULL)
return -1;
wg->count = 0;
pthread_mutex_init(&wg->mutex, NULL);
pthread_cond_init(&wg->cond, NULL);
return 0;
}
初始化时做三件事:
text
1. 设置 count = 0;
2. 初始化 mutex;
3. 初始化 cond。
初始状态下没有任何待等待任务,所以计数器为 0。
更严谨的工程实现应该检查:
c
pthread_mutex_init()
pthread_cond_init()
的返回值。
例如:
c
int WaitGroupInit(WaitGroup *wg) {
if (wg == NULL)
return -1;
wg->count = 0;
int err = pthread_mutex_init(&wg->mutex, NULL);
if (err != 0)
return -1;
err = pthread_cond_init(&wg->cond, NULL);
if (err != 0) {
pthread_mutex_destroy(&wg->mutex);
return -1;
}
return 0;
}
学习阶段可以先保留简化版本,但要知道真实工程代码中需要处理初始化失败。
8. Add 操作
c
void WaitGroupAdd(WaitGroup *wg, int delta) {
pthread_mutex_lock(&wg->mutex);
int new_count = wg->count + delta;
if (new_count < 0) {
pthread_mutex_unlock(&wg->mutex);
abort();
}
wg->count = new_count;
if (wg->count == 0) {
pthread_cond_broadcast(&wg->cond);
}
pthread_mutex_unlock(&wg->mutex);
}
WaitGroupAdd() 用于修改计数器。
安排一个新任务时:
c
WaitGroupAdd(&wg, 1);
任务完成时:
c
WaitGroupAdd(&wg, -1);
所有对 count 的修改都必须放在:
c
pthread_mutex_lock(&wg->mutex);
pthread_mutex_unlock(&wg->mutex);
之间。
这样可以保证:
text
1. 同一时间只有一个线程修改 count;
2. Wait 线程读取 count 时看到一致状态;
3. count 归零时可以安全唤醒等待线程。
9. 为什么 count 不能小于 0
c
int new_count = wg->count + delta;
if (new_count < 0) {
pthread_mutex_unlock(&wg->mutex);
abort();
}
如果 count < 0,说明 Done() 调用次数多于 Add() 调用次数。
例如:
text
Add 了 3 次;
Done 了 4 次。
这表示任务生命周期管理已经失衡。
常见原因包括:
text
1. 某个任务重复调用 Done;
2. 没有 Add 就调用 Done;
3. 错误路径和正常路径都调用了 Done;
4. 任务提前失败时没有统一处理计数。
这种错误继续运行没有明确语义,所以直接 abort() 是合理的调试策略。
10. count 归零时为什么要 broadcast
c
if (wg->count == 0) {
pthread_cond_broadcast(&wg->cond);
}
当 count == 0 时,说明所有被计入 WaitGroup 的任务都已经完成。
此时所有调用 WaitGroupWait() 的线程都应该被唤醒。
这里使用:
c
pthread_cond_broadcast()
而不是:
c
pthread_cond_signal()
原因是可能有多个线程同时等待同一个 WaitGroup。
signal 只唤醒一个等待线程。
broadcast 会唤醒所有等待线程。
由于 WaitGroup 表达的是"所有任务已经完成"这个全局状态,状态一旦成立,所有等待者都可以继续执行,因此使用 broadcast 更合适。
11. Done 操作
c
void WaitGroupDone(WaitGroup *wg) {
WaitGroupAdd(wg, -1);
}
Done() 只是一个语义化封装。
它等价于:
c
WaitGroupAdd(wg, -1);
但它表达的含义更清楚:
text
当前任务已经完成。
典型任务函数中可以这样写:
c
void *worker_func(void *arg) {
TaskContext *ctx = arg;
do_work(ctx);
WaitGroupDone(ctx->wg);
return NULL;
}
如果任务中存在多个返回路径,需要保证每条路径最终都会调用 Done()。
例如:
c
void *worker_func(void *arg) {
TaskContext *ctx = arg;
if (ctx == NULL) {
WaitGroupDone(ctx->wg);
return NULL;
}
if (do_work(ctx) != 0) {
WaitGroupDone(ctx->wg);
return NULL;
}
WaitGroupDone(ctx->wg);
return NULL;
}
这种写法容易遗漏。更好的方式是统一出口:
c
void *worker_func(void *arg) {
TaskContext *ctx = arg;
if (ctx == NULL)
goto done;
if (do_work(ctx) != 0)
goto done;
done:
WaitGroupDone(ctx->wg);
return NULL;
}
C 没有 Go 的 defer,所以需要格外注意 Done() 的调用路径。
12. Wait 操作
c
void WaitGroupWait(WaitGroup *wg) {
pthread_mutex_lock(&wg->mutex);
while (wg->count > 0) {
pthread_cond_wait(&wg->cond, &wg->mutex);
}
pthread_mutex_unlock(&wg->mutex);
}
Wait() 的作用是阻塞当前线程,直到:
c
wg->count == 0
这里必须使用 while,不能使用 if。
错误写法:
c
if (wg->count > 0) {
pthread_cond_wait(&wg->cond, &wg->mutex);
}
正确写法:
c
while (wg->count > 0) {
pthread_cond_wait(&wg->cond, &wg->mutex);
}
原因包括:
text
1. 条件变量可能发生虚假唤醒;
2. 被唤醒不代表条件一定满足;
3. 多个等待线程被唤醒后都需要重新检查 count;
4. 条件变量表达的是"条件可能变化了",不是"条件一定成立了"。
所以条件变量的标准模式是:
c
pthread_mutex_lock(&mutex);
while (条件不满足) {
pthread_cond_wait(&cond, &mutex);
}
pthread_mutex_unlock(&mutex);
13. pthread_cond_wait 的行为
c
pthread_cond_wait(&wg->cond, &wg->mutex);
这个函数的行为很关键。
它会:
text
1. 原子地释放 mutex;
2. 让当前线程进入等待状态;
3. 被 signal 或 broadcast 唤醒;
4. 醒来后重新获得 mutex;
5. 返回到调用点。
所谓"原子地释放 mutex 并等待",是为了避免下面这种丢失唤醒问题:
text
线程 A 准备等待;
线程 A 释放锁;
线程 B 修改条件并发出 signal;
线程 A 还没真正进入等待;
signal 丢失;
线程 A 开始等待并永久睡眠。
pthread_cond_wait() 把"释放锁"和"进入等待"组合成一个原子操作,从而避免这个窗口。
14. Destroy 操作
c
void WaitGroupDestroy(WaitGroup *wg) {
if (wg == NULL)
return;
pthread_mutex_destroy(&wg->mutex);
pthread_cond_destroy(&wg->cond);
}
销毁时释放 WaitGroup 内部的同步资源。
调用 WaitGroupDestroy() 前必须保证:
text
1. 没有线程正在 WaitGroupAdd;
2. 没有线程正在 WaitGroupDone;
3. 没有线程正在 WaitGroupWait;
4. 没有线程阻塞在 cond 上;
5. 后续不会再使用这个 WaitGroup。
否则行为未定义。
一般使用顺序应该是:
c
WaitGroupInit(&wg);
/* Add / Done / Wait */
WaitGroupWait(&wg);
WaitGroupDestroy(&wg);
但注意:
text
WaitGroupWait 返回只代表 count == 0;
它不自动保证所有相关线程已经退出。
如果任务是由独立线程执行的,线程资源仍然需要通过 pthread_join() 回收。
15. WaitGroup 和 pthread_join 的区别
WaitGroupWait() 和 pthread_join() 都带有等待含义,但等待对象不同。
text
WaitGroupWait:等待任务计数归零;
pthread_join:等待某个线程函数返回。
例如启动 4 个线程,每个线程处理 10 个任务。
此时可以有两种等待对象:
text
等待 40 个任务完成;
等待 4 个线程退出。
这两个状态不一定等价。
一种线程可能完成了当前任务,但还没有退出。
也可能线程已经退出,但其中某些逻辑没有被正确计入 WaitGroup。
因此:
text
WaitGroup 不能替代 pthread_join;
pthread_join 也不能替代 WaitGroup 的任务计数语义。
如果你需要等待线程退出,必须使用 pthread_join()。
如果你需要等待一组逻辑任务完成,可以使用 WaitGroup。
16. Add 和 Wait 的时序限制
WaitGroup 有一个重要使用约束:
text
正数 Add 应该发生在 Wait 之前。
典型安全用法:
c
WaitGroup wg;
WaitGroupInit(&wg);
for (int i = 0; i < n; i++) {
WaitGroupAdd(&wg, 1);
start_task(i, &wg);
}
WaitGroupWait(&wg);
WaitGroupDestroy(&wg);
不清晰的用法:
text
线程 A 正在 Wait;
线程 B 同时继续 Add(1)。
如果线程 A 调用 Wait() 时看到 count == 0,它会直接返回。随后线程 B 再 Add(1),这个新任务不属于刚才那次等待范围。
这不是实现错误,而是 WaitGroup 的语义限制。
因此,WaitGroup 更适合表达:
text
先明确任务集合;
再等待任务集合完成。
如果任务集合会动态增长,需要额外的生命周期控制,例如:
text
1. 明确任务提交阶段;
2. 关闭提交入口;
3. 再调用 Wait;
4. 保证 Wait 期间不会继续正数 Add。
17. 为什么不用 atomic_int 实现 WaitGroup
可能会想到用:
c
atomic_int count;
然后等待线程写:
c
while (atomic_load(&count) > 0) {
}
这个方案不推荐。
原因是:
text
1. 它是忙等,会持续占用 CPU;
2. 它只能原子地观察计数,不能让线程休眠;
3. 它缺少条件变量的唤醒机制;
4. 它不能自然表达"count 归零时唤醒所有等待者";
5. 复杂场景中仍然需要额外同步。
WaitGroup 不是单纯的 atomic counter。
它需要同时解决两个问题:
text
1. 计数修改必须线程安全;
2. 等待线程需要在计数归零时被唤醒。
因此更合适的实现是:
text
mutex + condition variable + count
18. 一个通用使用示例
下面是一个不依赖特定业务场景的例子:主线程启动多个 pthread,每个线程完成一个任务,主线程等待所有任务完成。
c
#include "WaitGroup.h"
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
typedef struct TaskArg {
int id;
WaitGroup *wg;
} TaskArg;
void *task_func(void *arg) {
TaskArg *task_arg = arg;
printf("task %d running\n", task_arg->id);
/*
* 实际任务逻辑写在这里。
*/
WaitGroupDone(task_arg->wg);
free(task_arg);
return NULL;
}
int main(void) {
const int n = 5;
pthread_t threads[n];
WaitGroup wg;
WaitGroupInit(&wg);
for (int i = 0; i < n; i++) {
TaskArg *arg = malloc(sizeof(TaskArg));
if (arg == NULL) {
perror("malloc");
exit(1);
}
arg->id = i;
arg->wg = &wg;
WaitGroupAdd(&wg, 1);
int err = pthread_create(&threads[i], NULL, task_func, arg);
if (err != 0) {
WaitGroupDone(&wg);
free(arg);
fprintf(stderr, "pthread_create failed\n");
exit(1);
}
}
WaitGroupWait(&wg);
/*
* WaitGroupWait 返回后,说明逻辑任务都已经 Done。
* 但线程资源仍然需要 join。
*/
for (int i = 0; i < n; i++) {
pthread_join(threads[i], NULL);
}
WaitGroupDestroy(&wg);
return 0;
}
这个例子中:
text
WaitGroupAdd:在启动任务前调用;
WaitGroupDone:任务函数结束前调用;
WaitGroupWait:等待所有任务逻辑完成;
pthread_join:回收线程资源。
19. 当前实现的不足
这个 WaitGroup 实现适合学习和基本使用,但还不是完整工程级实现。
19.1 没有完整错误处理
当前实现没有检查:
c
pthread_mutex_init()
pthread_cond_init()
pthread_mutex_lock()
pthread_cond_wait()
pthread_cond_broadcast()
的返回值。
学习阶段可以接受,但工程中应该处理这些错误。
19.2 没有状态字段
当前结构体没有记录是否已经初始化或销毁。
因此下面这种调用无法被内部防御:
c
WaitGroup wg;
WaitGroupAdd(&wg, 1); // 未初始化
WaitGroupDestroy(&wg); // 未初始化或重复销毁
WaitGroupAdd(&wg, 1); // 销毁后继续使用
这些都会导致未定义行为。
可以增加字段:
c
int initialized;
但这也会增加实现复杂度。
19.3 不防御 Add 与 Wait 的错误并发
这个实现不阻止下面的语义错误:
text
一个线程已经开始 Wait;
另一个线程又进行新的 Add(1)。
这种情况通常应该通过外层程序结构避免,而不是由 WaitGroup 自己解决。
19.4 没有超时等待
当前 WaitGroupWait() 会一直等待,直到 count == 0。
如果某个任务永远没有调用 Done(),等待线程会永久阻塞。
可以扩展出:
c
int WaitGroupTimedWait(WaitGroup *wg, const struct timespec *timeout);
用于支持超时等待。
20. 使用规范
使用 WaitGroup 时,可以遵守以下规则:
text
1. 使用前必须调用 WaitGroupInit。
2. 每安排一个需要等待的任务,调用 WaitGroupAdd(&wg, 1)。
3. 每个被 Add 的任务必须恰好调用一次 WaitGroupDone。
4. WaitGroupDone 不能多调,否则 count 会变成负数。
5. WaitGroupWait 只等待 count 归零,不负责回收线程资源。
6. WaitGroupDestroy 必须在没有线程继续使用 WaitGroup 后调用。
7. 不要用 busy wait 代替条件变量。
8. 不要在 Wait 已经开始后再并发进行新的正数 Add。
9. 多个等待线程可以同时调用 WaitGroupWait。
10. 如果任务有多个错误路径,要确保所有路径都能调用 Done。
21. 完整实现
WaitGroup.h
c
#ifndef WAITGROUP_H
#define WAITGROUP_H
#include <pthread.h>
typedef struct WaitGroup {
int count;
pthread_mutex_t mutex;
pthread_cond_t cond;
} WaitGroup;
int WaitGroupInit(WaitGroup *wg);
void WaitGroupAdd(WaitGroup *wg, int delta);
void WaitGroupDone(WaitGroup *wg);
void WaitGroupWait(WaitGroup *wg);
void WaitGroupDestroy(WaitGroup *wg);
#endif
WaitGroup.c
c
#include "WaitGroup.h"
#include <stdio.h>
#include <stdlib.h>
int WaitGroupInit(WaitGroup *wg) {
if (wg == NULL)
return -1;
wg->count = 0;
pthread_mutex_init(&wg->mutex, NULL);
pthread_cond_init(&wg->cond, NULL);
return 0;
}
void WaitGroupAdd(WaitGroup *wg, int delta) {
pthread_mutex_lock(&wg->mutex);
int new_count = wg->count + delta;
if (new_count < 0) {
pthread_mutex_unlock(&wg->mutex);
abort();
}
wg->count = new_count;
if (wg->count == 0) {
pthread_cond_broadcast(&wg->cond);
}
pthread_mutex_unlock(&wg->mutex);
}
void WaitGroupDone(WaitGroup *wg) {
WaitGroupAdd(wg, -1);
}
void WaitGroupWait(WaitGroup *wg) {
pthread_mutex_lock(&wg->mutex);
while (wg->count > 0) {
pthread_cond_wait(&wg->cond, &wg->mutex);
}
pthread_mutex_unlock(&wg->mutex);
}
void WaitGroupDestroy(WaitGroup *wg) {
if (wg == NULL)
return;
pthread_mutex_destroy(&wg->mutex);
pthread_cond_destroy(&wg->cond);
}
22. 总结
C 语言下的 WaitGroup 可以理解为:
text
一个受 mutex 保护,并且可以通过 cond 阻塞等待的任务计数器。
它的三个核心组件是:
text
count 表示未完成任务数量;
mutex 保证 count 的并发访问安全;
cond 在 count 归零时唤醒等待线程。
它的四个核心操作是:
text
Add 修改任务计数;
Done 标记一个任务完成;
Wait 等待所有任务完成;
Destroy 销毁同步资源。
WaitGroup 适合解决:
text
等待一组逻辑任务完成。
它不负责解决:
text
线程如何创建;
线程如何退出;
线程资源如何回收;
任务结果如何合并;
任务失败如何传播。
因此,WaitGroup 应该被看作并发程序中的一个基础同步组件,而不是完整的并发框架。
使用它时,最重要的是保证:
text
Add 和 Done 成对;
Wait 的边界清晰;
Destroy 时没有其他线程继续使用;
WaitGroupWait 和 pthread_join 的语义不能混淆。