说真的Linux系统编程绕不开线程而线程的创建几乎是一切多线程代码的第一块砖。很多教程喜欢把pthread_create一笔带过好像调用一下就能跑出完美的并发程序。但实际上我在实际开发和带新人时遇到的那些诡异问题一大半都出在这一行看似简单的调用上编译报错、线程不执行、传进去的参数全变成同一个值、主线程一退出整个程序直接就没了。这篇文章想把线程创建这件事的来龙去脉讲透包括它到底解决什么问题、底层由什么支撑、函数参数怎么理解、代码怎么写、编译命令怎么敲、常见坑怎么避开。刚开始上手Linux系统编程的人适合读已经用过线程但偶尔翻车的朋友应该也能从中找到几条有用的排查思路。1. 创建线程之前先搞清楚线程到底是什么1.1 进程与线程为什么有了进程还不够在很多人的第一印象里Linux系统编程就是fork()一把梭创建子进程来并发处理任务。这个思路在早期没问题但随着服务器要抗的并发越来越高进程的代价就暴露出来了。一个进程自带一份独立的地址空间、独立的环境变量、独立的文件描述符表fork()之后还要走写时复制之类的机制创建和切换的开销都不小。更麻烦的是进程之间通信要走管道、消息队列、共享内存这些外部手段稍微复杂一点的协作逻辑写起来就非常啰嗦。线程的出现就是为了补这个短板。同一个进程内的多个线程共享同一份地址空间、全局变量、文件描述符和堆内存但各自有独立的执行栈、寄存器上下文和调度状态。打个不太严谨的比方进程像是一间独立的办公室每开一间都要配齐桌椅、复印机、门禁线程则是在同一间大办公室里多摆几张工位大家共用打印机和茶水间但每个人都干各自的活。创建线程的代价比创建进程小得多上下文切换也快得多线程之间要共享数据更是直接读写同一块内存就行省去了通信协议那一整套弯弯绕。但便宜没好货也是相对的。共享是一把双刃剑一个线程写崩了内存整个进程连带所有线程一起崩溃一个线程改了全局变量其他线程全都看得见。所以刚接触线程的朋友最容易踩的坑不是创建本身而是没弄明白哪些资源是共享的、哪些是独享的结果在共享数据上打架。后面我会专门说竞态和互斥的问题这里先把基础结构讲清楚。1.2 线程的组成TCB、私有存储区与共享资源很多人会把线程理解成“一个函数跑起来了”这个说法作为入门可以但若想排查问题还是得把线程的组成拆开看。一个线程至少包含这几样东西线程IDpthread_t用来标识和操作线程独立的用户态栈用来承载函数调用和局部变量独立的内核栈用于内核态处理系统调用时的临时数据寄存器上下文包括程序计数器PC、栈指针SP等线程切换时保存和恢复就靠它线程控制块TCBThread Control Block这是操作系统内核维持线程运行状态的核心数据结构记录调度状态、优先级、CPU时间等元信息私有存储区TLSThread Local Storage用于存放线程私有的全局数据比如errno就是通过TLS实现的。热词里提到的“线程控制块和私有存储区的关系”其实可以这样理解TCB相当于这个线程的“身份档案”内核调度它、中断它、切换它都是先翻这份档案而私有存储区是这个线程自己专属的“抽屉”别人无权打开抽屉里的东西跟着线程出生、跟着线程销毁。TCB里会记录指向私有存储区、栈空间这些资源的位置信息所以两者是管理结构与其管理对象之间的关系。线程之间共享的那部分包括进程地址空间代码段、数据段、堆、全局变量、静态变量、文件描述符表、信号处理器、当前工作目录。线程独占的部分包括线程ID、栈空间、寄存器状态、errno、调度优先级、信号掩码。记住这个划分非常重要后面讲传参、返回值和线程安全底层都是这一套资源边界在起作用。2. 核心APIpthread_create 参数逐个拆解2.1 函数签名与四个参数Linux下创建线程的入口函数是pthread_create原型如下int pthread_create(pthread_t *restrict thread, const pthread_attr_t *restrict attr, void *(*start_routine)(void *), void *restrict arg);四个参数各有各的任务我们拆开看。第一个参数thread是一个输出参数指向pthread_t类型的缓冲区。函数成功返回后这个缓冲区里会被写入新线程的ID。后续你要pthread_join等待、pthread_cancel取消、pthread_kill发信号都要用到这个ID。有两点需要注意pthread_t在Linux上虽然实际是一个无符号长整型但POSIX标准明确说它是“不透明类型”你不要依赖它的具体类型更不要把它当成整数去运算把它当作一个句柄来用就行。其次传入的指针必须指向有效内存否则线程创建成功后你不知道该拿谁的ID去做后续操作。第二个参数attr是线程属性对象填NULL就是用默认属性。默认情况下线程是可join的detachstate为PTHREAD_CREATE_JOINABLE栈大小取系统默认值调度策略跟随进程。大多数入门代码都填NULL这没有错但如果你的线程需要分离运行、需要自定义栈大小、需要调整调度优先级就必须先创建pthread_attr_t对象来配置这一点后面一小节单独展开。第三个参数start_routine是线程入口函数新线程创建成功后内核会从这个函数开始执行。它的签名固定是void *(*)(void *)传入一个void *返回一个void *。之所以设计成这样是为了让入口函数能跟任意类型的参数、任意类型的返回结果打交道。你可以把任何结构体指针塞进去在线程内部再转回具体类型返回时也可以返回堆上的指针、静态变量地址甚至用pthread_exit退出并把结果通过pthread_join传给其他线程。函数返回后线程自动结束所以入口函数里一般是一个无限循环或者明确任务流程。第四个参数arg是要传给入口函数的参数。注意它的语义是“传指针”不是“传值”。这个设计显然是为了让入参不受一个整数宽度的限制能传结构体指针。但它也是无数新手翻车的地方如果你传入的是一个局部变量的地址而这个变量在线程还没结束时就出作用域被销毁了那线程里看到的可能是一堆垃圾值。后面第4章我会专门用代码演示这个坑。2.2 返回值不是errnopthread_create返回0表示成功非零表示失败。最关键的一点是它失败时不会去设置errno而是直接把错误码作为返回值返回。这一点和open、socket、fork这些传统API完全不同后者的失败信息都是通过errno发布的。如果你拿习惯性思维在调用完pthread_create后紧跟着打印strerror(errno)大概率会得到一条误导性的错误信息。正确的做法是保存返回值并用它查错误int ret pthread_create(tid, NULL, worker, arg); if (ret ! 0) { fprintf(stderr, pthread_create failed: %s\n, strerror(ret)); // 或者用 strerror_r线程安全版本 return -1; }常见错误码有这么几个错误码含义常见触发场景EAGAIN资源不足无法创建新线程线程数超过系统上限或内存不足或陷入RLIMIT_NPROC限制EINVALattr设置非法你给了无效的属性参数例如未知的调度策略EPERM没有权限操作尝试设置需要权限的调度策略比如实时调度SCHED_RR我刚开始工作的时候在服务端框架里遇到EAGAIN一脸懵后来查pthread_join不回收线程、也没有线程池导致线程只创建不销毁最终捅穿了系统限制。建议在任何长时间运行的程序里把pthread_create的返回值当作一等公民对待宁可多写几行错误处理也别当地球人都永远不会失败一样直接忽略。2.3 attr线程属性什么时候需要自定义pthread_attr_t这个对象在普通示例里经常被忽略但真实项目里它其实很常用。要配置一个线程属性流程是定义属性变量、pthread_attr_init初始化、用各种pthread_attr_set*函数设置、创建线程时传进去、用完后pthread_attr_destroy清理。最常用的几个属性配置设置分离状态PTHREAD_CREATE_DETACHED线程创建后自动回收资源不需要也不能够再join。设置栈大小stacksize默认栈在x86-64 Linux下通常是8MB受ulimit -s控制。如果你做深度递归或者协程风格的大栈需求就要显式调大。设置调度策略与优先级如SCHED_FIFO、SCHED_RR这需要权限普通用户容易触发EPERM。一个设置分离状态的完整片段pthread_attr_t attr; pthread_attr_init(attr); pthread_attr_setdetachstate(attr, PTHREAD_CREATE_DETACHED); pthread_t tid; int ret pthread_create(tid, attr, worker, NULL); pthread_attr_destroy(attr); if (ret ! 0) { // 错误处理 }这里有个容易被忽略的小坑pthread_attr_destroy是在pthread_create之后调用的不是之前。因为pthread_create内部会复制属性数据销毁属性对象不会影响已经创建出来的线程但你必须确保在创建期间属性对象的生命周期是有效的。3. 实操从零创建第一个线程程序3.1 环境准备与编译命令Linux系统编程里几乎所有线程函数都定义在pthread.h中实现在glibc里。编译时要加一个关键选项很多初学者在这里卡过半小时gcc -o thread_demo thread_demo.c -pthread注意看我用的是-pthread而不是-lpthread。这两个看着像实际不完全一样。-pthread会把编译器置于POSIX线程模式它除了让链接器去找线程库之外还会定义_REENTRANT等宏引导头文件对外暴露线程安全的函数声明。如果你只写-lpthread可能链接也能过但某些平台上头文件层面的宏定义缺失后续可能出现奇奇怪怪的问题。所以我的建议是统一用-pthread把编译和链接层面的线程支持都打开。还有一点新版glibc比如2.34以后已经把libpthread的大部分实现合并进libc了但-pthread这个选项依然是推荐的稳定写法不要因为它“没有实际链接到单独的库”就去掉。3.2 完整代码创建多个线程并传参先看一段典型的新手错误代码#include stdio.h #include pthread.h void *worker(void *arg) { int num *(int *)arg; printf(thread [%ld] got num %d\n, pthread_self(), num); return NULL; } int main(void) { pthread_t tids[5]; for (int i 0; i 5; i) { pthread_create(tids[i], NULL, worker, i); } for (int i 0; i 5; i) { pthread_join(tids[i], NULL); } return 0; }这段代码的问题在于i是每次循环里同一个栈变量i的地址创建5个线程时传入的其实是同一个地址。线程调度不一定跟创建顺序一致等某个线程真的去解引用时i可能已经变成了3、4甚至5于是输出里的确可能看到多个重复的数字。这在并发场景里就是典型的data race结果是不确定的。正确的传参方式通常是给每个线程分配独立的内存块线程函数自己负责释放#include stdio.h #include stdlib.h #include pthread.h void *worker(void *arg) { int num *(int *)arg; free(arg); printf(thread [%ld] got num %d\n, pthread_self(), num); return NULL; } int main(void) { pthread_t tids[5]; for (int i 0; i 5; i) { int *arg malloc(sizeof(int)); *arg i; pthread_create(tids[i], NULL, worker, arg); } for (int i 0; i 5; i) { pthread_join(tids[i], NULL); } return 0; }这里malloc出来的内存在堆上在每个线程的生命周期内都有效线程拿到后解引用、用完释放不会跟循环变量纠缠。还有一种取巧但也很常见的方式如果只是传一个小整数可以直接把整数转成指针传进去pthread_create(tids[i], NULL, worker, (void *)(long)i); // 函数内 long num (long)arg;这种技巧在x86-64 Linux上通常没问题因为有符号long可以完整容纳int指针也够宽。但它依赖void *至少能放得下long这个假设理论上不够可移植。我的建议是小项目图省事可以用严肃代码还是走堆指针。3.3 pthread_join等线程结束的正确方式pthread_join是跟pthread_create成对出现的。它有两个作用一是阻塞当前线程直到目标线程终止二是回收目标线程的资源。如果你创建了一个可join线程却从不join那线程结束后的资源不会自动被系统回收长期积累会变成“僵尸线程”资源泄漏。void *retval NULL; int ret pthread_join(tids[i], retval); if (ret 0) { // retval 就是线程返回值 }pthread_join的第二个参数是void **它会接收线程函数return出来的值或者pthread_exit传入的值。如果你不关心返回值传NULL即可。这个阻塞特性也意味着那些用while(1)无限循环的线程或者生命周期长的服务线程不应该被主线程盲目join否则主线程会被卡死。这类线程应当用pthread_detach分离或者创建时就设置PTHREAD_CREATE_DETACHED让它在结束时自动释放资源。还要强调一点main函数如果直接return整个进程退出所有线程瞬间消亡。想让主线程停止执行但进程继续等其他线程跑完可以在main的最后调用pthread_exit(NULL)。这个细节在服务端常驻进程里非常有用后面第4章也会再提。3.4 运行起来如何观察线程并发代码写好了编译命令gcc -o thread_demo thread_demo.c -pthread ./thread_demo运行多次你会发现线程输出的顺序每次都不一样这是正常的因为线程调度由内核决定没有固定的执行顺序。如果你想亲眼确认线程是真正的内核级并发实体可以在程序里打印getpid()和gettid()printf(pid%d tid%ld\n, getpid(), syscall(SYS_gettid));你会发现所有线程的pid相同但tid各不相同。这就是“同一个进程的多个线程”的直观证据。另两个常用的课堂观察手段top -H -p pid可以列出进程下的所有线程视图ps -eLf可以看线程级别的列表。还有一个很琐碎但实际排错有用的点printf是有缓冲的子线程里打印的内容可能因为缓冲区没有及时刷新而看起来“缺失”。排错时在printf后加fflush(stdout)或者直接用fprintf(stderr, ...)因为stderr默认无缓冲能立刻刷出来。我第一次用线程打印日志时漏掉这个细节花了不少时间怀疑线程没执行。4. 线程创建后最容易踩的五个坑4.1 编译链接undefined reference to pthread_create这是最经典的问题没有之一。报错长这样/tmp/ccxxxxxx.o: In function main: thread_demo.c:(.text0x1a): undefined reference to pthread_create collect2: error: ld returned 1 exit status原因就是编译时没有链接POSIX线程库。解决方案就是回到3.1节在编译命令里加上-pthread。这里想多提醒一句链库选项的位置是有讲究的。.c源文件如果写在前面库写在后面是常规正确姿势gcc -o thread_demo thread_demo.c -pthread如果为了图省事把库放在源文件前面某些老版本工具链下静态链接阶段可能因为符号解析顺序问题再次报错。记住统一用上面的命令就行。4.2 传参陷阱为什么打印出了同一个数字第3.2节的错误示例其实就是这个坑。更进一步解释一下底层原因循环变量i是main函数栈上的一个局部变量它在每轮循环里地址不变、值变线程函数里的arg接收到的是这个地址。当多个线程去读取同一个地址时谁先谁后完全看调度所以最终打印结果通常是重复的、乱序的、无法预测的。这不是pthread_create的问题是并发访问共享变量导致的race condition问题。排查思路很简单如果多个线程拿到的参数内容高度一致、甚至等于循环终值先怀疑你传进去的是同一个地址。解决方式就是每个线程一份独立内存malloc或者用值拷贝的方式塞进指针。这是并发编程里最重要的习惯之一想清楚你传给线程的东西在线程执行的整个生命周期里是不是稳定可达、不会被无关代码改动。4.3 返回值陷阱返回了栈上的地址入口函数return出来的指针会被pthread_join的retval参数接到。有人喜欢直接返回局部变量的地址void *worker(void *arg) { int result 42; return result; // 错误示范 }这段代码看起来能运行但result是线程函数栈上的局部变量。函数返回后这块栈内存按规范已经失效了虽然刚返回的一瞬间可能还没被覆盖、值看上去还在但只要线程栈稍后被其他栈帧使用你读到的数值就会变成垃圾。更危险的是这种bug往往是“时好时坏”的极难稳定复现。正确的做法是返回堆内存的地址调用方负责释放、返回静态变量或全局变量的地址或者干脆在pthread_join前用输出参数把结果写到已分配好的内存里。这个经验同样适用于arg任何类型的指针只要它指向的对象生命周期比线程短就不要传给线程除非你能保证线程在对象销毁前已经结束了访问。4.4 主线程过早退出进程直接没了这个坑我见过太多次。有同学写完代码主线程创建完线程后就return 0然后一脸困惑地问“为什么我的线程没有执行完”因为main函数返回等于整个进程退出。进程一旦退出所有线程无论跑到哪里都会被内核直接终止没有“等一等”的说法。解决思路有三条在主线程中逐个pthread_join确保所有工作线程都结束后再返回在main的最后调用pthread_exit(NULL)这样主线程自爆但进程会继续活到所有其他线程结束用pthread_detach分离线程并且主线程不死通过其他机制如消息循环、信号量等待。实际服务器代码里主线程通常会被设计成管理线程它创建一堆worker后自己进入事件循环或者sleep等待不会轻易退出。新手阶段最好老老实实join这个习惯能帮你避免一大批“程序突然结束”的问题。4.5 线程数过多EAGAIN与默认栈大小pthread_create返回EAGAIN通常意味着你碰到了系统资源限制。最常见的原因有两个一个是真的创建了太多线程突破了/proc/sys/kernel/threads-max或RLIMIT_NPROC限制另一个是虚拟内存不足。不要小看第二条。Linux默认线程栈大小是8MBulimit -s可以看到这个8MB是虚拟内存。假设你创建1000个线程光栈区就占将近8GB地址空间。在32位系统或受限容器里很容易触发内存映射问题。而且注意这是虚拟地址空间的占用即便物理内存没满地址空间也可能先耗尽了。我处理过的一个线上案例一个Java服务在琐碎任务上大量新起线程线程池没上限最终创建到几万个线程直接因为线程栈内存耗尽导致整个节点不可用。所以生产代码里要严格控制线程数量优先使用线程池复用线程。如果你确实需要大栈可以单独设置pthread_attr_setstacksize而不是盲目调高系统级默认值。这里给一个排查命令速查命令作用ulimit -s查看默认线程栈大小KBcat /proc/sys/kernel/threads-max查看系统级线程数量上限cat /proc/sys/vm/max_map_count查看内存映射区域数量上限ps -eLf | wc -l粗略统计当前进程线程数5. 创建只是开始线程协作与线程安全5.1 竞态条件没有锁的counter是错的很多人创建完多个线程后第一件想做的事就是让它们一起操作某个全局变量比如统计计数。写个简单例子static int counter 0; void *worker(void *arg) { for (int i 0; i 1000000; i) { counter; } return NULL; }创建4个线程跑完理论上counter应该是4000000但你实际运行几次就会发现结果总比这个数字小而且每次都不一样。原因在于counter在底层不是一步完成的它要执行“读取旧值、加一、写回”三步两个线程可能同时读到同一个旧值各自加完后写回导致计数少了一次。这就是竞态条件race condition。解决办法是老生常谈的互斥锁static pthread_mutex_t mutex PTHREAD_MUTEX_INITIALIZER; void *worker(void *arg) { for (int i 0; i 1000000; i) { pthread_mutex_lock(mutex); counter; pthread_mutex_unlock(mutex); } return NULL; }加锁后每次操作同一时刻只有一个线程能进去计数自然不会丢。代价是性能下降所以实际工程里会用更细粒度的锁、无锁数据结构或者原子操作来优化。这个主题展开就大了这里先点到为止。我想强调的是当你决定“创建多个线程一起改同一个数据”时锁几乎必然登场pthread_create只是给了你并发的能力并没有给你安全的并发。5.2 死锁加锁顺序决定一切线程多了锁多了死锁就会找上门。最简单的死锁场景是两个线程互相持有一把锁又同时去抢对方手里的锁线程A持有锁1等待锁2线程B持有锁2等待锁1两个线程都在等对方释放于是永远等下去。用文字描述不如看一眼伪代码线程A: lock(mutex_a) lock(mutex_b) // 等待中... ... 线程B: lock(mutex_b) lock(mutex_a) // 等待中...避免死锁最实用的一条规则是全局约定加锁顺序所有线程都按同一个顺序获取多把锁。比如一律先锁mutex_a再锁mutex_b就不会出现互相等待的环。此外能用pthread_mutex_trylock尝试加锁、失败就释放已有锁并重试也是常见策略但会引入复杂度。刚写完创建线程的程序时你的注意力可能只在“怎么跑起来”但一旦开始用锁请始终把“会不会互相等死”放在脑子里。5.3 线程安全errno恰恰是安全的最后聊一个很多人误解的概念线程安全。有人以为只要自己代码里没写难看的共享变量就万事大吉了其实标准库函数也可能是不安全的。经典例子是strtok它内部用静态存储区记录拆分进度两个线程同时调用进度就会互相污染。POSIX为此提供了strtok_r这种带_r后缀的线程安全版本把进度信息交给调用方自己保存。但有一个反直觉的例子值得拿出来说errno。C语言传统上是全局变量但引入线程后标准规定每个线程都要有自己的errno。Linux上的实现就是用TLS机制通过一个函数返回线程私有存储区里的errno位置所以线程A报错了并不会覆盖线程B的错误状态。这一点恰好呼应了1.2节里说的“私有存储区”的实用价值——它是线程独立性的基础之一。我的实践建议是在写多线程代码前养成查函数有没有_r版本的习惯看到非线程安全函数优先换安全版本。等排查线上数据错乱的坑多了你就会明白线程安全问题往往不是出在你自己写的逻辑里而是出在那些你不以为意的库函数底层。最后分享一点个人体会线程的创建代码量上真的只有一行pthread_create但这一行背后绑定的是整个并发体系——从资源边界到生命周期从数据竞争到加锁策略。我踩过的最贵的教训之一就是“线程创建成功”和“线程能正确工作”之间隔着一整条天堑。建议起步阶段先在单线程里把入口函数的逻辑完全调通再拆到多线程里去跑一旦出现奇怪的结果不要急着怀疑编译器先回头检查你传给线程的地址是否稳定、你访问的数据是否被多个线程同时碰过。线程帮你省下的那点创建开销远不够填一个数据竞争带来的调试成本。
