☰
Linux线程创建详解:从pthread_create到常见坑全解析
2026/9/26 23:23:54 网站建设 项目流程

说真的,Linux系统编程绕不开线程,而线程的创建几乎是一切多线程代码的第一块砖。很多教程喜欢把pthread_create一笔带过,好像调用一下就能跑出完美的并发程序。但实际上,我在实际开发和带新人时遇到的那些诡异问题,一大半都出在这一行看似简单的调用上:编译报错、线程不执行、传进去的参数全变成同一个值、主线程一退出整个程序直接就没了。这篇文章想把线程创建这件事的来龙去脉讲透,包括它到底解决什么问题、底层由什么支撑、函数参数怎么理解、代码怎么写、编译命令怎么敲、常见坑怎么避开。刚开始上手Linux系统编程的人适合读,已经用过线程但偶尔翻车的朋友,应该也能从中找到几条有用的排查思路。

1. 创建线程之前:先搞清楚线程到底是什么

1.1 进程与线程:为什么有了进程还不够

在很多人的第一印象里,Linux系统编程就是fork()一把梭,创建子进程来并发处理任务。这个思路在早期没问题,但随着服务器要抗的并发越来越高,进程的代价就暴露出来了。一个进程自带一份独立的地址空间、独立的环境变量、独立的文件描述符表,fork()之后还要走写时复制之类的机制,创建和切换的开销都不小。更麻烦的是,进程之间通信要走管道、消息队列、共享内存这些外部手段,稍微复杂一点的协作逻辑写起来就非常啰嗦。

线程的出现就是为了补这个短板。同一个进程内的多个线程共享同一份地址空间、全局变量、文件描述符和堆内存,但各自有独立的执行栈、寄存器上下文和调度状态。打个不太严谨的比方:进程像是一间独立的办公室,每开一间都要配齐桌椅、复印机、门禁;线程则是在同一间大办公室里多摆几张工位,大家共用打印机和茶水间,但每个人都干各自的活。创建线程的代价比创建进程小得多,上下文切换也快得多,线程之间要共享数据更是直接读写同一块内存就行,省去了通信协议那一整套弯弯绕。

但便宜没好货也是相对的。共享是一把双刃剑,一个线程写崩了内存,整个进程连带所有线程一起崩溃;一个线程改了全局变量,其他线程全都看得见。所以刚接触线程的朋友最容易踩的坑,不是创建本身,而是没弄明白哪些资源是共享的、哪些是独享的,结果在共享数据上打架。后面我会专门说竞态和互斥的问题,这里先把基础结构讲清楚。

1.2 线程的组成:TCB、私有存储区与共享资源

很多人会把线程理解成“一个函数跑起来了”,这个说法作为入门可以,但若想排查问题,还是得把线程的组成拆开看。一个线程至少包含这几样东西:

  • 线程ID(pthread_t),用来标识和操作线程;
  • 独立的用户态栈,用来承载函数调用和局部变量;
  • 独立的内核栈,用于内核态处理系统调用时的临时数据;
  • 寄存器上下文,包括程序计数器(PC)、栈指针(SP)等,线程切换时保存和恢复就靠它;
  • 线程控制块(TCB,Thread Control Block),这是操作系统内核维持线程运行状态的核心数据结构,记录调度状态、优先级、CPU时间等元信息;
  • 私有存储区(TLS,Thread Local Storage),用于存放线程私有的全局数据,比如errno就是通过TLS实现的。

热词里提到的“线程控制块和私有存储区的关系”,其实可以这样理解:TCB相当于这个线程的“身份档案”,内核调度它、中断它、切换它,都是先翻这份档案;而私有存储区是这个线程自己专属的“抽屉”,别人无权打开,抽屉里的东西跟着线程出生、跟着线程销毁。TCB里会记录指向私有存储区、栈空间这些资源的位置信息,所以两者是管理结构与其管理对象之间的关系。

线程之间共享的那部分包括:进程地址空间(代码段、数据段、堆)、全局变量、静态变量、文件描述符表、信号处理器、当前工作目录。线程独占的部分包括:线程ID、栈空间、寄存器状态、errno、调度优先级、信号掩码。记住这个划分非常重要,后面讲传参、返回值和线程安全,底层都是这一套资源边界在起作用。

2. 核心API:pthread_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 返回值不是errno

pthread_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:(.text+0x1a): 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默认线程栈大小是8MB(ulimit -s可以看到),这个8MB是虚拟内存。假设你创建1000个线程,光栈区就占将近8GB地址空间。在32位系统或受限容器里,很容易触发内存映射问题。而且注意,这是虚拟地址空间的占用,即便物理内存没满,地址空间也可能先耗尽了。

我处理过的一个线上案例:一个Java服务在琐碎任务上大量新起线程,线程池没上限,最终创建到几万个线程,直接因为线程栈内存耗尽导致整个节点不可用。所以生产代码里要严格控制线程数量,优先使用线程池复用线程。如果你确实需要大栈,可以单独设置pthread_attr_setstacksize,而不是盲目调高系统级默认值。

这里给一个排查命令速查:

命令作用
ulimit -s查看默认线程栈大小(KB)
cat /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,但这一行背后绑定的是整个并发体系——从资源边界到生命周期,从数据竞争到加锁策略。我踩过的最贵的教训之一,就是“线程创建成功”和“线程能正确工作”之间隔着一整条天堑。建议起步阶段,先在单线程里把入口函数的逻辑完全调通,再拆到多线程里去跑;一旦出现奇怪的结果,不要急着怀疑编译器,先回头检查你传给线程的地址是否稳定、你访问的数据是否被多个线程同时碰过。线程帮你省下的那点创建开销,远不够填一个数据竞争带来的调试成本。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询