
搞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线程数量达到系统上限或资源暂时不足。EINVALattr传了非法参数比如栈大小小于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在检测到这种死锁时返回EDEADLKLinux上。这些返回值都值得检查。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降低并发补joinjoin一直阻塞不返回线程内部死循环或死锁gdb attach看线程栈运行越久内存越高joinable线程未join统一join或创建前设置detach线程返回字符串是乱码返回了栈变量地址返回堆指针并约定free加锁后不能并发创建循环里直接join先全部创建再统一joinpthread_create返回EINVALattr栈大小小于最小值查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关键字做线程本地存储。但这些都是建立在把“线程创建参数传递”吃透之后的事基础不牢高并发代码永远是空中楼阁。希望这份总结能让你避开我当年踩过的坑。