搞C/C++多线程编程的人,迟早会和pthread_create打交道。这个函数是POSIX线程库的入口,几乎所有Linux/Unix下的并发任务都从它开始。可它的签名对新手不太友好,尤其是第三个参数void *(*start_routine)(void *),一堆星号括号混在一起,很多人第一次看到就发怵:到底要我传什么?线程入口函数长什么样?参数又要怎么给?
我在项目里带过不少刚接触多线程的同事,发现真正让人翻车的往往不是锁,也不是条件变量,而是这个最基础的“线程创建+参数传递”环节。有人往pthread_create里传了一个局部变量的地址,程序跑得好好的,换一个优化级别就段错误;有人写个循环批量创建线程,所有线程拿到的编号竟然一模一样。所以这次我把pthread_create函数指针用法从头到尾拆开讲:函数指针是什么、参数怎么传、线程怎么回收,再配合函数指针数组,把批量创建线程和组织任务调度的高级玩法一起讲了。
这篇内容适合两类人:一是刚入门Linux多线程、想系统搞懂pthread_create用法的人;二是项目里要用线程池、任务分发的C/C++工程师,需要把代码组织得易维护。我会尽量用“看得见代码、能直接跑”的方式来写,讲清楚每一步背后的道理。
1. pthread_create函数指针参数:先看懂它在等什么
1.1 从原型里拆出最关键的两个参数
pthread_create完整原型(glibc版本)是这样的:
int pthread_create(pthread_t *restrict thread, const pthread_attr_t *restrict attr, void *(*start_routine)(void *), void *restrict arg);四个参数,前两个只是“属性配置”。thread是一块调用方提供的内存,函数会在创建成功时把新线程的ID写进去,后续join、detach都靠这个ID。attr传NULL就用默认属性,绝大多数场景够用了。
真正决定这个线程干什么的,是后两个参数:start_routine是线程入口函数的指针,arg是传给这个入口函数的参数。一个线程的“灵魂”就是这两样:跑哪段代码、拿什么数据。
用生活类比,线程就是一条流水线工位:start_routine是工位墙上贴的操作步骤(代码),arg是工位上那箱待加工的物料(数据)。同一个工位可以换操作步骤,也可以换物料,线程调度器负责把“工人”派到工位上执行。
1.2 拆解void *(*start_routine)(void *)
要把这个函数指针看懂,先记住一个通用语法:返回类型 (*指针名)(参数列表)。
拿一个普通函数来说:
int add(int a, int b); int (*add_ptr)(int, int); // 声明一个指向该类型函数的指针 add_ptr = add; // 函数名本身代表函数地址函数指针的规则:函数名被当作地址使用,add和&add本质上一样。这也是为什么pthread_create第三个参数可以直接写函数名。
回到start_routine,拆三大块:
start_routine:一个指针变量,它指向某个函数。- 前面的
void *:被指函数的返回类型是void *。 - 后面的
(void *):被指函数接收一个void *参数。
所以完整含义是:一个“返回void *、接收一个void *参数”的函数的地址。
那为什么选void *?因为void *是C语言里的“万能地址”。线程库在通用层面不知道业务的具体类型,它只负责把入口地址和参数地址交给新线程,具体解释成什么类型完全由你自己完成。返回void *同理,线程结束时可以把结果地址交回,想返回什么返回什么。这种“库不关心类型,使用者自解释”的设计,保证了线程入口对任何业务都适用。
下面是一个最简入口函数:
void *thread_routine(void *arg) { // 参数需要自己强转 int value = (int)(intptr_t)arg; printf("thread got %d\n", value); return NULL; }注意:如果你有一个业务函数void worker(),直接拿它传给pthread_create,编译器会报“incompatible pointer type”。它和void *(*)(void *)不是一个类型。要么把业务函数签名改成线程入口格式,要么包一层适配函数。我习惯包一层,业务函数保持纯净,线程入口统一做类型转换和异常兜底。
1.3 attr与返回值:创建线程的“隐藏细节”
attr即使传NULL,也要知道它控制什么。最常见的有分离态(PTHREAD_CREATE_DETACHED)、栈大小、调度策略。后面第3章会展开。
而pthread_create的返回值容易被忽视:它不是设置errno,而是直接返回错误码。0表示成功,非0对应一个错误编号。常见错误:
- EAGAIN:线程数量达到系统上限,或资源暂时不足。
- EINVAL:attr传了非法参数,比如栈大小小于PTHREAD_STACK_MIN。
- EPERM:调度策略或权限不满足。
正确检查方式:
int rc = pthread_create(&tid, NULL, thread_routine, arg); if (rc != 0) { fprintf(stderr, "pthread_create failed: %s\n", strerror(rc)); }从这点可以展开:创建线程是一个真实消耗资源的操作。系统对进程内线程数有上限,用ulimit -u可以看。批量创建线程的程序,必须处理pthread_create失败的情况,而不是默认一定成功。
2. 参数传递的正确姿势:从单值到结构体
2.1 单参数:传值还是传地址
线程入口函数接收的是void *arg,理论上你可以把任何类型地址塞进去。传“地址”还是传“值”,是个原则问题。
最简单的单参数场景,给每个线程传一个编号。常见错误写法是:
for (int i = 0; i < 4; i++) { pthread_create(&tids[i], NULL, worker, &i); // 错! }这里传的是变量i的地址。所有线程拿到的是同一块栈内存。循环是主线程上的执行流,i在不停变化;而子线程什么时候真正读取这个地址,完全由调度决定。结果就是多个线程读到的编号可能都是3,甚至更糟。我在监控日志里见过一次,4个线程打印全部是7——因为i已经跑到7了,而且这个地址上层的栈帧可能已被别的函数调用覆盖。
正确做法是“传值”——把整数拷贝成指针宽度的值传过去:
pthread_create(&tids[i], NULL, worker, (void *)(intptr_t)i);线程里再转回来:
int id = (int)(intptr_t)arg;中间用intptr_t过渡,是因为int不保证和指针同宽,intptr_t才是“能完整保存指针的整数类型”。先转成intptr_t,再转成void *,可以避免64位平台上int被截断的问题。
注意:如果你传的是一个动态分配的整数对象(malloc一个int然后传地址),那线程用完必须有人free,而且这个对象的生命周期必须覆盖线程执行期。相比之下,传值方式更省心,优先用。
2.2 多参数:用结构体打包
线程需要多个参数时,不可能一次传多个,系统只给了一个void *。对多个参数,一定要“打包”,最直观的就是用struct。
typedef struct task_arg { int id; char name[32]; int payload; } task_arg; void *task_worker(void *arg) { task_arg *ta = (task_arg *)arg; printf("task %d/%s payload=%d\n", ta->id, ta->name, ta->payload); return NULL; }创建时按每个线程分配独立结构体:
task_arg *ta = malloc(sizeof(*ta)); ta->id = next_id; snprintf(ta->name, sizeof(ta->name), "task-%d", next_id); ta->payload = compute_payload(); pthread_create(&tid, NULL, task_worker, ta);这里有两个容易踩的细节。
第一,结构体不要用同一个栈变量。假如主线程有局部变量task_arg ctx,循环里创建4个线程,四个线程的arg都指向同一个ctx地址,和传&i一样的下场。
第二,内存释放要明确。可以主线程统一在join后free,也可以线程入口里free。我习惯:如果主线程在join之后不再需要结构体,就由线程入口free;因为线程入口可以自己保证“我已经用完了”。但无论哪种,代码里必须写清楚。我在实际代码里经常这样处理入口:
void *task_worker(void *arg) { task_arg *ta = (task_arg *)arg; // 使用参数 // ... free(ta); // 参数结构体由线程自己释放 return NULL; }如果结构体里还有指针指向另一块动态内存,也要约定释放顺序。先free内部字段,再free结构体本身。
另一种少用malloc的办法:维护一个全局结构体数组,按创建顺序给下标。比如:
static task_arg ctx_pool[64]; pthread_create(&tid, NULL, task_worker, &ctx_pool[i]);这种方式适合线程总数固定、参数天然按槽位分开的场景,省掉了malloc/free的频繁调用。但它本质是共享数组,如果不是只读字段,多个线程读写同一个slot要加锁。而且槽位不能复用错,否则数据会被覆盖。我自己只有在玩具demo里才这么写,工程里还是malloc结构体稳妥。
2.3 生命周期陷阱:栈变量、字符串、共享缓冲
前面提到栈变量,这里展开串成一个必看清单。
先说最坑的:把局部变量的地址直接传给新线程,父函数返回后,该地址指向的栈帧已经失效。子线程什么时候读,读到什么都不可预测。表现通常是“偶发段错误”或“加了O2优化后必现”。
字符串是另一个重灾区。字符串字面量存放于只读数据区,进程生存期内有效,可以直接传:
pthread_create(&tid, NULL, worker, "hello"); // 安全但如果字符串是函数内局部数组,比如:
char buf[64]; snprintf(buf, sizeof(buf), "req-%d", id); pthread_create(&tid, NULL, worker, buf); // 危险buf是栈内存,函数返回即失效。要么等线程用完再返回(join),要么复制到堆上:
char *dup = strdup(buf); // 线程用完后 free(dup) pthread_create(&tid, NULL, worker, dup);共享缓冲的问题也常被归错类。传一个指针给线程,等于把这块内存的访问权交给了线程。如果另一个线程/主线程也在读写同一块缓冲而没有任何同步,那这不是“传参错误”,而是“数据竞争”。但很多人早期就是栽在:明明arg传对了,为什么数据乱?本质是共享写入缺锁。所以参数传递前先想清楚:这块内存在线程运行期间,还有谁会碰?
我给自己定的通用规则很简单:子线程将要访问的所有内存,要么在创建前保证有效到pthread_join之后,要么在堆上分配并明确释放责任。没有第三条捷径。这个规则帮我躲掉了至少几十次段错误。
3. 线程创建与回收:把资源管明白
3.1 默认是joinable,别让它悄悄泄漏
pthread_create创建的线程,默认处于joinable状态。很多人不知道这句话意味着什么:线程执行完,它的资源不会自动释放,必须有人调pthread_join来回收。如果创建了线程却从不join,也没设置detach,那么每跑完一个线程,就泄漏一份资源(线程内核对象、用户态栈等)。服务型程序如果一直在创建线程不回收,内存只会涨不会跌,跑几天就会被系统杀掉了。
所以要建立清晰的回收策略:
- 需要等待线程结束的:保留joinable,等任务完成后调用pthread_join。返回值也可以从join里带出来。
- 纯后台、不关心结果的:创建前通过attr设置detach状态,让线程结束自动回收。
join的典型写法:
pthread_t tids[4]; for (int i = 0; i < 4; i++) { pthread_create(&tids[i], NULL, worker, (void *)(intptr_t)i); } // 其他主线程业务... for (int i = 0; i < 4; i++) { pthread_join(tids[i], NULL); }很多人刚学时写错成“创建+join在一个循环里”,那样线程根本没有并行,第二线程要等第一线程做完全部再开跑,丧失了并发意义。正确做法是先全部创建,存好tids,再统一join。
detach设置代码:
pthread_attr_t attr; pthread_attr_init(&attr); pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED); pthread_t tid; int rc = pthread_create(&tid, &attr, worker, arg); pthread_attr_destroy(&attr);如果某个线程已经被pthread_join过一次,再join会立刻返回ESRCH。如果两个线程互相join,可能造成死锁,pthread_join在检测到这种死锁时返回EDEADLK(Linux上)。这些返回值都值得检查。
3.2 从线程取返回值
线程入口函数返回void *,这个指针会在线程退出后由pthread_join带出来。用法:
void *worker(void *arg) { int num = (int)(intptr_t)arg; char *result = malloc(64); snprintf(result, 64, "square=%d", num * num); return result; } int main() { pthread_t tid; pthread_create(&tid, NULL, worker, (void *)(intptr_t)5); void *ret = NULL; pthread_join(tid, &ret); if (ret) { printf("result: %s\n", (char *)ret); free(ret); } return 0; }关键约束是:返回值必须指向“线程结束后依然有效”的内存。最常见的错误是返回入口函数内的栈数组:
void *bad_worker(void *arg) { char buf[64]; snprintf(buf, 64, "hello"); return buf; // 错!函数返回后栈内存失效 }这种代码在调试时往往能“侥幸”工作,但优化级别一高或者栈帧被复用,打印出来的就是垃圾字符串。正确做法是返回堆指针、全局指针或static数组。static数组又面临多线程共享问题:多个线程写同一个static缓冲区,后面的覆盖前面的。最稳妥的是堆分配,让调用方free。
也可以不通过返回值传数据,而是把结果写进arg指向的结构体字段里。比如请求结构体里放result字段,线程执行完填好,主线程join后直接读结构体。这样参数的单位就是“请求上下文”,比零散的返回值管理更清晰。
3.3 线程属性:栈大小与分离状态
attr里除了分离状态,栈大小是高频项。Linux上默认线程栈通常是8MB虚拟内存(可pthread_attr_getstacksize确认),对大多数任务完全够用。但有些程序在线程里做深度递归,或者声明一个大数组,需要显式加大:
pthread_attr_t attr; pthread_attr_init(&attr); pthread_attr_setstacksize(&attr, 16 * 1024 * 1024);设太小(小于PTHREAD_STACK_MIN,常见为16KB左右)则pthread_create返回EINVAL。如果要写可移植代码,用sysconf(_SC_THREAD_STACK_MIN)查询最小值。
需要提醒的是:栈设置是一次性分配给线程的虚拟内存,不是物理内存。不管栈设多大,物理内存是按需分配的,所以设大一点通常不会立刻消耗大量内存,但设太小会直接导致线程内递归越界、栈溢出崩溃。
调度策略和优先级这类attr,普通人不用动。默认SCHED_OTHER就够了。实时调度策略需要权限配合,业务线程贸然调整可能引发系统级问题。
3.4 线程数量不是越多越好
pthread_create本身是消耗型系统调用,一次创建大约几微秒到几十微秒(取决于系统负载和线程栈分配)。如果任务密集且短小,频繁创建销毁线程的开销会十分可观。这也是为什么工程上会引入线程池:预先创建固定数量线程,任务通过队列分发,线程循环取任务执行。
线程数量怎么定?简单估计:
- CPU密集型:通常设为CPU核心数或核心数+1,再多也没收益,反而增加切换开销。
- IO密集型:线程数可以明显多于核心数,因为大部分时间在等IO。常用公式:线程数 ≈ 核心数 × (1 + 等待时间/计算时间),如果拿不准,先按核心数的2~3倍起压测再调。
- 每创建一个线程,默认栈8MB虚拟内存、还要占用内核任务描述符。创建成千上万个线程,进程地址空间会迅速吃紧。用
pthread_create返回EAGAIN时,先检查是不是线程数量超了ulimit -u。
在一个需要高并发的服务里,我用线程池之前试过“每请求一线程”,峰值几百个线程时系统还可以,到上千个时响应时间明显恶化。改成固定线程池后,吞吐反而稳定。
4. 函数指针数组:用一张表组织多个线程任务
4.1 先解决语法问题
函数指针数组是本章的主角,也是pthread_create场景里最能体现C语言灵活性的设计。先定义一个指向线程入口格式的函数指针类型:
typedef void *(*thread_func)(void *);这个typedef等于给“线程入口函数类型”起了一个别名thread_func。然后三个任务函数:
void *task_a(void *arg) { ... return NULL; } void *task_b(void *arg) { ... return NULL; } void *task_c(void *arg) { ... return NULL; }定义一个数组:
thread_func task_table[] = { task_a, task_b, task_c };task_table就是函数指针数组:下标0存task_a的地址,下标1存task_b,以此类推。编译期就把函数地址静态初始化好,运行期可以直接按索引取用。
调用方式:
for (int i = 0; i < 3; i++) { pthread_create(&tids[i], NULL, task_table[i], args[i]); }这个模式将“任务类型”和“线程创建”彻底解耦。以后新增任务,只需在数组里加一个函数,创建线程的循环一行不用改。对代码维护价值很大,特别是一批任务本来就要并行执行时,数组就是最自然的批处理容器。
4.2 用下标映射任务ID:一张分发表
函数指针数组最常见的工程用法是“任务分发表”。比如服务端收到一个协议包,包里有个opcode字段,表示要执行什么操作。用switch-case当然能写,但每加一个操作就要改一遍switch,代码越来越臃肿。函数指针数组可以直接用opcode做下标:
typedef void *(*thread_func)(void *); typedef struct request { int opcode; char data[128]; } request; void *handle_login(void *arg); void *handle_query(void *arg); void *handle_save(void *arg); thread_func op_table[] = { [0] = handle_login, [1] = handle_query, [2] = handle_save, };C99支持指定初始化器,把opcode和处理器绑定得非常直白。线程入口拿到请求后查表:
void *dispatch(void *arg) { request *req = (request *)arg; if (req->opcode < 0 || req->opcode >= TABLE_SIZE) { return NULL; } thread_func handler = op_table[req->opcode]; if (!handler) { return NULL; } return handler((void *)req); }有人担心数组下标越界,所以在查表之前必须做范围检查。表里不一定每个槽位都有对应函数,留空设NULL,读取前判空即可。这个模式比一行行switch简洁得多,而且操作码到处理函数的映射关系集中在一个地方,配置化程度更高。
在一些大的异步框架里,甚至不止一层表:外层是“事件类型→处理函数”,内层再分“业务状态→回调函数”。本质都是同一招:函数地址放进数组,用整数索引直接触发对应逻辑。
4.3 综合示例:批量任务分发给线程
最后给一个完整可编译的示例,把函数指针数组和pthread_create串起来。
#include <pthread.h> #include <stdio.h> #include <stdint.h> #include <unistd.h> void *task_compute(void *arg) { int id = (int)(intptr_t)arg; printf("compute task %d start\n", id); sleep(1); printf("compute task %d done\n", id); return NULL; } void *task_io(void *arg) { int id = (int)(intptr_t)arg; printf("io task %d start\n", id); usleep(200 * 1000); printf("io task %d done\n", id); return NULL; } void *task_cleanup(void *arg) { int id = (int)(intptr_t)arg; printf("cleanup task %d start\n", id); sleep(2); printf("cleanup task %d done\n", id); return NULL; } typedef void *(*thread_func)(void *); int main() { thread_func tasks[] = { task_compute, task_io, task_cleanup }; int task_count = (int)(sizeof(tasks) / sizeof(tasks[0])); pthread_t tids[task_count]; for (int i = 0; i < task_count; i++) { pthread_create(&tids[i], NULL, tasks[i], (void *)(intptr_t)i); } for (int i = 0; i < task_count; i++) { pthread_join(tids[i], NULL); } printf("all tasks finished\n"); return 0; }编译并运行:
gcc task_table_demo.c -o task_table_demo -pthread ./task_table_demo输出顺序很大概率是交错的,这正是多线程的特征。如果某个任务先执行完,它的done可能先打出来。想验证并发,可以多跑几次,就明白不能依赖线程执行的先后顺序。
注意:pthread_create要求start_routine类型严格匹配。示例中task_compute这些函数都满足“返回void *、接收void *”,数组元素类型thread_func与start_routine完全一致,可以直接传。
5. 常见问题、排查实录与经验提示
5.1 三个让我印象深刻的线上问题
第一个:偶发处理错误。服务每收到一个请求就创建一个线程处理,参数是请求结构体指针。当时这个请求结构体在主线程栈上分配,线程还没处理完,主线程已经处理下一个请求,同一块栈内存被覆盖。结果是10个请求里偶尔有1个处理结果错误。排查了两天,最后发现根本不是业务逻辑问题,是传参生命周期没管好。改成堆分配后立刻消失。
第二个:多个线程打印一样的编号。一个批处理程序创建8个线程处理8块数据,日志里所有线程都打印“processing block 7”。原因就是循环里传了&i。用intptr_t把值固化进去之后,编号立刻正常。这个坑非常经典,几乎所有初学pthread_create的人都会踩一次。
第三个:程序不定期卡住几分钟。排查时发现主线程在join一个早已设置了detach的线程。语义矛盾:线程已经分离,资源会自动回收,join永远等不到。处理后统一了设计规则:所有线程要么走join路径,要么走detach路径,绝不混用。从此我再没遇到过这个奇葩卡顿。
5.2 常见问题速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 创建线程偶发段错误 | 传入了已失效的栈地址 | 改堆分配,或join后再返回 |
| 线程参数打印全是同一个值 | 循环里传了共享变量地址 | 传intptr_t值,或用独立结构体 |
| 程序报Resource temporarily unavailable | 线程数量超限或资源不足 | 查ulimit -u,降低并发,补join |
| join一直阻塞不返回 | 线程内部死循环或死锁 | gdb attach看线程栈 |
| 运行越久内存越高 | joinable线程未join | 统一join,或创建前设置detach |
| 线程返回字符串是乱码 | 返回了栈变量地址 | 返回堆指针并约定free |
| 加锁后不能并发 | 创建循环里直接join | 先全部创建,再统一join |
| pthread_create返回EINVAL | attr栈大小小于最小值 | 查PTHREAD_STACK_MIN或sysconf |
除表格外再说三个小点。编译时务必加-pthread,不是只加-lpthread,-pthread还会定义必要的编译宏,否则抽象掉的关键宏不一致,某些平台直接运行出错。pthread_create的错误码要用strerror打印,而不是perror,因为它不走errno。调试多线程用gdb的thread apply all bt可以一次性看到所有线程栈,排查卡顿非常高效。
5.3 让线程代码好维护的几条经验
入口函数保持短小。线程入口只做四件事:参数类型转换、防御检查、调用真正的业务函数、记录开始/结束日志。业务逻辑放在普通函数里,单元测试不造线程也能测。
参数结构体好好命名。我习惯统一加task_前缀,字段写清楚含义和所有权,特别是带指针的字段,在注释里写明谁负责free。一个结构体同时被多个线程引用时,只读字段可以放心共享;任何写操作都必须同步。
调试阶段给线程入口加pthread_self()打印。多线程日志混在一起时,线程ID比printf的字符串更精确,能马上定位是哪个线程出了问题。线上版本可以用原子计数或日志框架替代,但开发阶段这招最省钱。
再提一个经常会遇到的需求:线程池。前面讲的函数指针数组加上一个任务队列,就是线程池的最小雏形。工作线程循环从队列取任务编号,查表执行。这样线程数量固定、任务分发灵活,能避免“每请求一线程”带来的资源抖动。哪怕是先写一个能用但简陋的版本,也好过完全没有池化。
pthread_create的函数指针参数,我第一次看完man手册其实也是一知半解,真正搞明白还是靠一次次段错误和调试。现在回头看,它的核心就一句话:给新线程一个入口地址和一份有效数据。函数指针是入口的载体,void *是数据的载体,两者组合,C语言就拥有了通用且优雅的线程调用方式。
在实际项目里我越来越喜欢把入口任务组织成函数指针数组:新增任务时,写一个满足签名的函数,往数组里加一项,线程创建逻辑不用动。很多写着省事的switch-case,最后都被这张表替代了。参数传递方面,我给自己定的红线始终没变——传地址可以,但必须保证这块内存在线程执行窗口内是有效的。守住这条红线,pthread_create这个接口基本不会再给你惹麻烦。
如果后续想继续深入,可以往这几个方向展开:线程池的完整实现、pthread_cond条件变量驱动的多线程协作、以及用__thread关键字做线程本地存储。但这些都是建立在把“线程创建+参数传递”吃透之后的事,基础不牢,高并发代码永远是空中楼阁。希望这份总结能让你避开我当年踩过的坑。