ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Linux多线程编程:pthread并发机制与同步实践

Linux多线程编程:pthread并发机制与同步实践 简介《Linux下的多线程编程实例解析》是一份面向Linux开发者与C语言初学者的PDF学习资料聚焦多线程编程的核心概念与pthread接口应用。文档从线程相比进程的资源开销、通信效率与多核利用等优势讲起再结合简单可编译运行的C代码示例演示pthread_create、pthread_join等函数的用法并进一步梳理互斥锁、读写锁、条件变量及线程局部存储等同步机制帮助读者快速掌握线程安全与并发管理的要点。资源压缩包内为1个PDF文件大小仅134KB轻量便携适合随时查阅。目前已有1282人学习下载对于希望系统理解Linux下多线程开发基础、规避竞态条件与死锁问题的技术人员这是一份实用且高效的入门参考资料。1. 30 倍开销差距背后为什么还要重新啃一遍 Linux 多线程做 Linux 下的多线程编程最常被问到的一个面试题就是“为什么用线程不用进程”。我第一次在嵌入式 Linux 上写并发时也被问住了明明 fork 也能并行为什么要 pthread后来查到一组反直觉的数字一个进程的开销大约是线程的 30 倍。启动新进程要分配独立地址空间还要维护代码段、堆栈段、数据段的多张表属于“昂贵”的多任务方式而线程共用地址空间切换成本低一个数量级数据共享也更直接。这份老教程讲的正是 Linux 下 pthread 的完整脉络从最小可运行模型到线程属性、互斥锁、条件变量、信号量再到线程局部存储。适合刚接触多线程、想快速把概念落成可编译代码的人也适合准备 Linux 面试题时系统过一遍同步机制的人。看完你就能理解线程间共享数据是福是祸以及那些网上说不清的坑到底踩在哪。2. 最小可运行模型example1.c 与 pthread_create/join 三件套拆解2.1 先跑起来完整代码、编译命令与两次运行结果对比老教程开篇就是一个主线程加一个子线程分别打印三行的例子。我按 POSIX 标准整理了一下保证能直接编译通过。注意原文档里入口函数写的是void thread(void)这种写法在老编译器上能蒙混过关但在 64 位体系上pthread 要求入口函数必须是void *(*)(void *)签名否则传参和返回值都容易出幺蛾子。下面这个版本是安全的/* example1.c */ #include stdio.h #include stdlib.h #include pthread.h void *thread(void *arg) { int i; for (i 0; i 3; i) printf(This is a pthread.\n); return NULL; } int main(void) { pthread_t id; int i, ret; ret pthread_create(id, NULL, thread, NULL); if (ret ! 0) { printf(Create pthread error!\n); exit(1); } for (i 0; i 3; i) printf(This is the main process.\n); pthread_join(id, NULL); return 0; }编译命令是 Linux 常用命令里最容易被忽略的一环gcc example1.c -lpthread -o example1 ./example1第一次运行可能得到main、pthread、main、main、pthread、pthread的交叉顺序第二次又变成另一套。这不是程序写错了而是两个线程在抢 CPU 时间片调度器先让谁跑谁就先打印。printf 虽然有内部缓冲和文件锁但它保证不了跨线程的输出顺序极端情况下多行输出还会被拆乱。老教程里还有一句值得注意Linux 的 pthread 实现是通过clone()系统调用完成的clone()和fork()的差别在于它允许子进程共享父进程的地址空间和资源。理解这一点就能明白为什么线程比进程“轻”那么多——不是靠什么黑魔法而是内核层面就走了一条省内存、省页表的路。2.2 pthread_create 的四个参数逐个拆解pthread_create是这一切的入口原型如下int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void *), void *arg);四个参数按顺序说参数含义本例取值thread指向线程标识符的指针创建成功后写入新线程 IDidattr线程属性对象NULL表示使用默认属性NULLstart_routine线程入口函数地址threadarg传给入口函数的参数本例不需要所以传空NULL返回值是重点。创建成功返回 0失败时返回的是错误码不是 errno。最常见的两个是EAGAIN和EINVAL前者表示系统限制创建新线程比如线程数过多后者表示attr指向的属性值非法。所以判断返回值时别只写if (ret ! 0)至少要区分这两个码日志里才好定位问题。顺带说一个隐蔽点老资料里pthread_t定义成unsigned long int所以当初很多人直接拿%lu打印线程 ID。现代 glibc 里pthread_t已经是不透明类型了不要假设它一定能强转成整数更不要用判断两个线程 ID 是否相等标准做法是pthread_equal()。翻新老代码时这是最容易踩的坑之一。2.3 线程结束的两条路正常返回与 pthread_exit线程跑完入口函数就结束这是最自然的方式也可以主动调pthread_exit(void *retval)提前退出返回值会交给pthread_join的第二个参数。pthread_join是阻塞调用谁调它它就等到目标线程结束才返回同时回收目标线程的资源。void pthread_exit(void *retval);/* 等待线程并拿到返回值 */ void *result; pthread_join(id, result);这里有一个很多人理解反了的地方如果 main 函数里直接pthread_exit(NULL)当前进程不会退出其他线程会继续跑而 main 里return 0则会把整个进程一起带走。所以写“主线程等子线程”时要么用pthread_join逐个收尾要么在 main 末尾调pthread_exit让子线程有机会善后。还有一个硬约束一个线程不能被多个线程同时等待。两个线程对同一个pthread_t调用pthread_join第一个能正常返回第二个会拿到ESRCH。这个放到第 5 章展开现在只需要记住一点——写代码前就要说清楚“谁负责 join”。3. 线程属性不是黑匣子绑定、分离、优先级里的参数细节3.1 pthread_attr_t 的初始化与默认值清单pthread_create第二个参数传NULL用的是默认属性绝大多数小程序够了。但只要涉及栈大小、分离状态、调度策略就要先建一个pthread_attr_t对象在创建线程前改好。属性对象必须用pthread_attr_init初始化用完再用pthread_attr_destroy清理这个顺序不能反。pthread_attr_t attr; pthread_t tid; pthread_attr_init(attr); /* 修改属性... */ pthread_create(tid, attr, thread_func, NULL); pthread_attr_destroy(attr);默认值不是“没有值”而是一组确定的参数。老文档上写的是非绑定、非分离、缺省 1M 堆栈、与父进程同优先级。这里面最容易误导人的是 1M 栈。现代 glibc 在 64 位平台默认线程栈通常是 8MB而且能用ulimit -s查到。把旧文档的 1M 当真理去算内存占用几百个线程就能算出巨大偏差。我一般会先用pthread_attr_getstacksize把真实值打出来再设计size_t stacksize; pthread_attr_getstacksize(attr, stacksize); printf(default stack size: %zu bytes\n, stacksize);3.2 绑定线程与轻进程 LWP一个来自 Solaris 时代的过时概念属性里的 scope 字段涉及轻进程LWP的概念。LWP 可以理解为介于用户线程和内核线程之间的一层抽象系统靠它分配 CPU 时间。非绑定模式下启多少个 LWP、哪个线程跑在哪个 LWP 上由系统决定绑定模式下线程固定挂在一个 LWP 上响应更快适合实时性要求高的场景。pthread_attr_t attr; pthread_t tid; pthread_attr_init(attr); pthread_attr_setscope(attr, PTHREAD_SCOPE_SYSTEM); pthread_create(tid, attr, thread_func, NULL);PTHREAD_SCOPE_SYSTEM表示绑定PTHREAD_SCOPE_PROCESS表示非绑定。但这里要泼一盆冷水LWP 这套模型来自 Solaris 的 M:N 线程模型Linux 的 NPTL 实现是 1:1 模型每个用户线程直接对应一个内核任务。在主流 Linux 发行版上pthread_attr_setscope基本是个空操作——设置它不会报错也不会带来实时性提升。如果你在别的书里看到“绑定线程响应更快”跑到 Linux 上验证不出效果不是你写错了是平台模型根本不一样。真正的实时手段要靠sched_setscheduler配合实时调度策略而不是这个属性。3.3 分离线程与非分离线程谁负责释放资源默认的非分离线程资源要等pthread_join返回后才释放。如果主线程不需要等某个子线程也不想管它什么时候结束就该把线程设成分离的让它跑完自己释放资源省得 join 那里挂着一个永远等不到的阻塞。pthread_attr_t attr; pthread_t tid; pthread_attr_init(attr); pthread_attr_setdetachstate(attr, PTHREAD_CREATE_DETACHED); pthread_create(tid, attr, thread_func, NULL);第二个参数只有两个有效值PTHREAD_CREATE_DETACHED分离和PTHREAD_CREATE_JOINABLE非分离。设成分离之后再对同一个 ID 调pthread_join会拿ESRCH因为资源已经还给系统了。分离和 join 是互斥的选了一个就别想另一个。老教程特别提醒过一个极端场景分离线程如果运行得飞快可能在pthread_create还没返回时就已经终止了线程号和资源被系统立刻分配给其他线程。这种情况下pthread_create写回调用者的 ID 很可能是“别人的线程号”。要留出时间窗口常见做法是在被创建的线程里先pthread_cond_timedwait等一小会儿或者用条件变量通知主线程“我已经起来了”。这里要注意pthread_cond_timedwait的第三个参数是绝对时间而不是延时需要基于clock_gettime(CLOCK_REALTIME)计算。还有绝对不要用wait()这类让整个进程睡眠的函数它会把所有线程一起挂起和线程级同步毫无关系。3.4 优先级参数先取再改再写回的固定姿势线程优先级存放在struct sched_param里。我不建议直接往里面塞值因为调度策略决定了哪个字段有效。常规姿势是先读再改再写回struct sched_param param; int policy; pthread_attr_getschedpolicy(attr, policy); pthread_attr_getschedparam(attr, param); /* 修改结构体里的 sched_priority */ param.sched_priority 20; pthread_attr_setschedparam(attr, param);这套“先取再改再写回”的流程是有原因的。Linux 默认的SCHED_OTHER是非实时策略这种策略下sched_priority只有 0写任何非零值都会被忽略或返回错误。想让优先级真正生效得先把调度策略改成SCHED_FIFO或SCHED_RR而普通用户改实时策略通常需要 root 权限。换句话说优先级不是你想调就能调的它跟调度策略、进程权限绑在一起。老教程里没有讲这一层很多人在 Linux 上调优先级发现没反应卡在这里半天。4. 共享数据的保护互斥锁、条件变量、信号量的分工与代码4.1 互斥锁读写线程共享缓冲区的最小实现线程共享同一份内存是优势也是灾难来源。多个线程同时写一个缓冲区结果往往不是“最后的赢家覆盖前面的”而是交错写入的垃圾。互斥锁保证一段代码同一时间只有一个线程在跑这是最基础的同步手段。下面这个例子是老教程的读/写模型一个缓冲区只能存一条信息writer 写reader 取。我改掉了两个不兼容的地方一是把非标准的pthread_delay_np换成了 POSIX 的nanosleep二是统一了线程函数签名。/* mutex_demo.c */ #include stdio.h #include pthread.h #include time.h char buffer; int buffer_has_item 0; pthread_mutex_t mutex; struct timespec delay {2, 0}; void *writer_function(void *arg) { while (1) { pthread_mutex_lock(mutex); if (buffer_has_item 0) { buffer A; buffer_has_item 1; printf(write: %c\n, buffer); } pthread_mutex_unlock(mutex); nanosleep(delay, NULL); } return NULL; } void *reader_function(void *arg) { while (1) { pthread_mutex_lock(mutex); if (buffer_has_item 1) { printf(read : %c\n, buffer); buffer_has_item 0; } pthread_mutex_unlock(mutex); nanosleep(delay, NULL); } return NULL; } int main(void) { pthread_t reader; pthread_mutex_init(mutex, NULL); pthread_create(reader, NULL, reader_function, NULL); writer_function(NULL); /* 主线程扮演 writer */ pthread_join(reader, NULL); return 0; }pthread_mutex_init(mutex, NULL)的第二个参数是属性NULL表示默认属性PTHREAD_PROCESS_PRIVATE即只在本进程内同步。如果需要跨进程同步要改成PTHREAD_PROCESS_SHARED这个属性在嵌入式多进程场景里偶尔会用到。pthread_mutex_lock是阻塞的锁被别人持有时调用线程挂起等待直到对方unlock。pthread_mutex_trylock是非阻塞版本拿不到锁立刻返回EBUSY不会卡住线程适合做试探性加锁。这个 demo 只是教学模型实际工程里不会让主线程死循环当 writer也不会让 reader 永不结束但它把“锁住状态变化”这件事演示得很清楚——真正要保护的是buffer_has_item这个共享标志而不是 printf 本身。4.2 条件变量while 重新检查条件而不是 if互斥锁只有锁定和非锁定两种状态适合保护短临界区。但它解决不了“等一个条件成立”的问题比如一个线程要等 count 不为 0 才继续。条件变量弥补的就是这个缺口它允许线程在条件不满足时阻塞条件满足时被唤醒。pthread_mutex_t count_lock PTHREAD_MUTEX_INITIALIZER; pthread_cond_t count_nonzero PTHREAD_COND_INITIALIZER; unsigned count 0; void decrement_count(void) { pthread_mutex_lock(count_lock); while (count 0) pthread_cond_wait(count_nonzero, count_lock); count count - 1; pthread_mutex_unlock(count_lock); } void increment_count(void) { pthread_mutex_lock(count_lock); if (count 0) pthread_cond_signal(count_nonzero); count count 1; pthread_mutex_unlock(count_lock); }pthread_cond_wait做了两件事原子地释放count_lock并把自己阻塞被唤醒后重新拿回锁。这个“原子释放 阻塞”是条件变量能工作的根基。关键在while (count 0)老教程反复强调这里不能用if。为什么因为条件变量可能被虚假唤醒也可能在被唤醒但还没拿到锁的间隙条件又被另一个线程改回不满足。用while重新检查一次不满足就继续睡才是安全写法。另一个细节pthread_cond_signal的调用要放在锁内。如果放在锁外可能出现“发出信号时等待线程还没来得及调用pthread_cond_wait信号已经丢了”的窗口等待线程就永远睡下去。信号量没有这个问题但条件变量必须小心这个时序。4.3 信号量四线程读文件与加乘运算的完整示例信号量本质上是一个非负整数计数器。资源增加时sem_post加一资源被消费时sem_wait减一减到 0 还不能减时就阻塞。老教程给了一个四线程例子两个线程从两个文件读数据写栈两个线程从栈取数据算加法和乘法。/* sem.c */ #include stdio.h #include pthread.h #include semaphore.h #define MAXSTACK 100 int stack[MAXSTACK][2]; int size 0; sem_t sem; void *ReadData1(void *arg) { FILE *fp fopen(1.dat, r); while (!feof(fp)) { fscanf(fp, %d %d, stack[size][0], stack[size][1]); sem_post(sem); size; } fclose(fp); return NULL; } void *ReadData2(void *arg) { FILE *fp fopen(2.dat, r); while (!feof(fp)) { fscanf(fp, %d %d, stack[size][0], stack[size][1]); sem_post(sem); size; } fclose(fp); return NULL; } void *HandleData1(void *arg) { while (1) { sem_wait(sem); printf(Plus:%d%d%d\n, stack[size][0], stack[size][1], stack[size][0] stack[size][1]); --size; } return NULL; } void *HandleData2(void *arg) { while (1) { sem_wait(sem); printf(Multiply:%d*%d%d\n, stack[size][0], stack[size][1], stack[size][0] * stack[size][1]); --size; } return NULL; } int main(void) { pthread_t t1, t2, t3, t4; sem_init(sem, 0, 0); pthread_create(t1, NULL, HandleData1, NULL); pthread_create(t2, NULL, HandleData2, NULL); pthread_create(t3, NULL, ReadData1, NULL); pthread_create(t4, NULL, ReadData2, NULL); pthread_join(t1, NULL); return 0; }sem_init(sem, 0, 0)的三个参数分别是信号量指针、共享范围0 表示仅当前进程内线程共享、初始值0 表示一开始没有可用数据。sem_post在每次读入数据后把计数加一sem_wait在缓冲区有数据时通过并把计数减一读线程和计算线程就这样靠一个计数器完成速度匹配。这个例子运行起来输出顺序会很乱。注意代码里size的和--根本没有加锁保护两个读线程可能同时往同一个stack[size]写两个计算线程可能读到同一个位置甚至计算线程拿到的size和写线程的不是同一个值。老教程把这种现象归结为“各个线程间的竞争关系”——它正好展示了信号量能保证“有几个数据”但保证不了“哪个线程拿哪条数据”。想管住数据归属单靠信号量不够还得配合互斥锁。这其实也是嵌入式 Linux 面试题里常考的区分点信号量管数量互斥锁管所有权。5. 多线程常见问题排查五条血泪踩坑记录5.1 现象同一个程序跑两次输出顺序完全不同这是 example1.c 的运行结果。第一次主线程先打完三行子线程后打第二次两者交替第三次又是另一种顺序。新手第一反应是代码写错了。原因pthread_create之后主线程和子线程同时处于可运行状态谁先拿到 CPU 由调度器决定没有任何保证。printf 的行缓冲还会让输出叠加更不可控。解决这不是 bug是并发的真实样子。要顺序就明确用pthread_join让主线程等子线程要按业务顺序执行就把“顺序”交给互斥锁或条件变量。指望调度顺序本身就是设计错误。5.2 现象pthread_create 返回 11线程创建失败循环里创建几百个线程后pthread_create开始返回EAGAIN打印strerror(ret)会看到Resource temporarily unavailable。原因EAGAIN常见两种来源——进程可创建的线程数到了上限或者系统无法为线程栈分配内存。默认线程栈在 64 位 Linux 上可能是 8MB几百个线程的虚拟内存占用是个大数字。解决先用ulimit -u查用户进程线程数上限再用pthread_attr_getstacksize看默认栈大小必要时用pthread_attr_setstacksize调小。但最根本的解法是别开这么多线程改用线程池复用。5.3 现象第二个 pthread_join 返回 3ESRCH两个线程同时对同一个pthread_t调用pthread_join第一个成功返回第二个报ESRCH。原因一个线程只能被 join 一次。第一个 join 已经把目标线程的资源回收线程 ID 不再有效后续 join 变成了“等一个不存在的线程”。解决设计阶段就规定好谁来 join。多个模块需要感知线程退出时别让大家抢同一个 join而是让一个线程负责 join再把结果通过条件变量或消息广播出去。5.4 现象分离线程运行太快pthread_create 返回的 id 可能已经失效设置PTHREAD_CREATE_DETACHED后子线程瞬间跑完并释放资源。此时主线程再拿这个 ID 做操作很可能操作的是被复用后的新线程。原因分离线程结束后资源和线程号立即归还系统新线程可能很快占用同一个 ID。老教程推荐的缓解办法是让子线程先睡一小会儿给pthread_create留出返回时间。解决别靠睡眠解决同步睡眠时间长短完全是玄学。正确做法是创建后用一个初始化完毕的条件变量或信号量通知主线程让主线程确认“新线程已经拿到参数、开始执行”再继续后面的流程。这也是线程池创建线程时都要带一个 ready 标志的原因。5.5 现象程序卡死gdb 显示两个线程都停在 pthread_mutex_lockA 线程持锁 1 等锁 2B 线程持锁 2 等锁 1谁都不让死锁。这是多线程同步里最容易翻车的地方。原因两个线程对多个互斥锁的加锁顺序不一致。比如 A 先锁mutex1再锁mutex2B 先锁mutex2再锁mutex1只要两个锁同时被竞争就必然死锁。解决根本办法是全局统一加锁顺序所有线程都按同一个顺序拿锁。工程上还可以用pthread_mutex_trylock做非阻塞尝试拿不到就释放自己手里的锁退回去重试。另外把互斥锁类型设成PTHREAD_MUTEX_ERRORCHECK会在重复加锁等错误场景里返回错误码而不是未定义行为。这种锁性能稍差但调试阶段换上去能少熬几个夜。6. 线程局部存储 TSD 与一个低成本的并发自检习惯6.1 errno 的困境TSD 键的创建、绑定与读取线程局部存储TSD解决的是“每个线程要有自己的私有数据”这个需求。最经典的例子是errno它不能是全局变量否则 A 线程看到的错误码可能是 B 线程产生的又不能是局部变量因为每个函数都要访问。正经做法是给errno分配一个 TSD 键让每个线程各自存一份。老教程配套的源码包里有一段完整的 TSD 示例我重写了一个可独立编译的版本/* tsd_demo.c */ #include stdio.h #include stdlib.h #include string.h #include pthread.h static pthread_key_t win_key; static pthread_once_t win_key_once PTHREAD_ONCE_INIT; static void destructor(void *ptr) { free(ptr); } static void make_key(void) { pthread_key_create(win_key, destructor); } void *worker(void *arg) { long id (long)arg; char *win; pthread_once(win_key_once, make_key); win malloc(32); snprintf(win, 32, window-%ld, id); pthread_setspecific(win_key, win); char *my_win pthread_getspecific(win_key); printf(thread %ld sees %s\n, id, my_win); return NULL; }这段代码里三个关键点。pthread_key_create的第二个参数是析构函数线程退出时如果键仍绑着数据系统会调用它释放内存pthread_once保证make_key只执行一次防止多个线程各自创建一份键pthread_setspecific把每个线程自己的数据挂到同一个键上pthread_getspecific再取回来。不同线程即使同时调用拿到的也是各自之前绑定那份。这就是errno这类变量的正确打开方式。6.2 一个低成本的并发自检习惯跑 100 次再下结论多线程程序最怕“这程序跑了一下午没事一发版就出事”。我现在的习惯是任何带共享数据的验证先编译成带 ThreadSanitizer 的版本跑一遍gcc -fsanitizethread -g sem.c -lpthread -o sem_tsan ./sem_tsanTSan 会在数据竞争发生时输出具体的读写位置比靠肉眼盯输出快得多。没有 TSan 的环境也可以用valgrind --toolhelgrind ./sem做类似的检查拿到报告先看是不是手动加的锁没有覆盖所有共享变量。从那以后我每次写带共享数据的线程程序都强制走三步先把共享变量一个一个列出来再定统一加锁顺序最后跑一遍 TSan 或 helgrind。多线程的坑多半不是玄学而是“谁在写、谁在读、谁负责等”没在设计阶段说清楚。希望帮到你。本文还有配套的精品资源点击获取
返回列表