ARTICLE DETAIL

资讯详情

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

Linux线程详解:从pthread创建到同步互斥与死锁排查

Linux线程详解:从pthread创建到同步互斥与死锁排查 刚接触Linux线程时我犯过一个特别低级的错误在线程入口函数里直接操作了一个全局变量两个线程同时跑结果那个计数器忽大忽小跟抽风一样。后来慢慢啃完概念、踩过死锁的坑才算摸清这套东西的脾气。今天这篇就从Linux系统出发把线程的概念与控制从头到尾捋一遍——线程到底是什么怎么创建和销毁同步互斥怎么做死锁怎么排查哪些坑是新手必踩的全放这儿了。如果你是刚开始写并发程序的C/C开发者或者正在复习Linux系统编程准备面试这篇内容应该能帮你省不少事。1. 线程是什么从进程到线程的一次认知升级想象你经营一家餐厅。进程就像一间独立的餐厅门店有自己的厨房、仓库、收银台和门面线程则是店里的员工大家共享同一间厨房、同一个仓库和同一套设备各自负责洗菜、炒菜、上菜。进程之间老死不相往来各有各的地盘线程之间天生就是来协作的共享所在进程的地址空间、文件描述符、全局变量等资源。Linux下的线程并不是什么全新物种。从内核视角看线程本质上是一个轻量级进程Lightweight Process, LWP创建时通过clone()系统调用指定资源共享的粒度。这带来一个直接结论在多核处理器上多个线程确实可以同时运行在不同核心上调度平等性与进程一致。区别在于开销——进程上下文切换要更换页表、刷新TLB代价高昂线程切换因为共享地址空间代价小得多。我经常用一个比喻帮朋友理清思路“进程是切地皮线程是分人。”新建进程要重新划分一块独立地址空间新建线程只是从已有进程里拉出一支队伍地址空间还是同一个。所以线程也常被称为“进程内的执行流”。正因如此线程之间通信非常方便直接访问共享变量就行不需要像进程间通信那样搞消息队列、共享内存、信号之类的花活。但方便背后藏着一个大坑多个线程同时读写同一个变量如果不加保护竞态条件race condition就会出现结果完全不可预测。还有一个关键点容易混淆Linux的调度单位是线程不是进程。你在ps命令里看到的一个“进程”其实是一个线程组组里有若干线程。用gettid()或者ps -eLf能看到每个线程独立的线程ID而用getpid()拿到的是线程组ID也就是平时说的进程号。这一点排查问题尤其重要——很多人以为“一个进程卡死了”其实是进程里某个线程出了问题把整个进程拖住了。1.1 什么时候该用线程IO密集型任务比如网络服务端要同时处理成千上万个连接每个连接阻塞在read/write上线程可以一边等IO一边互不干扰。多核计算密集型任务把一个大任务拆成若干子任务并发跑满CPU核。图像处理、矩阵运算就是典型场景。需要共享大量数据多个模块要频繁访问同一个缓存、同一个状态机用线程可以避免IPC的序列化和拷贝开销。反过来如果任务是独立且短命的或者必须强隔离一个崩了不影响另一个那进程更合适。线程有一个广为人知的缺点线程崩溃会导致整个进程退出其他线程跟着陪葬。线上服务里线程的数量和职责都要精心设计而不是无脑创建个万个。1.2 线程与进程的账本对比维度进程线程地址空间独立共享系统开销创建/切换开销大创建/切换开销小通信方式IPC管道、消息队列、共享内存共享全局变量稳定性一个崩溃不影响其他进程一个崩溃全进程遭殃调试难度相对简单竞态、死锁问题隐蔽这张表不是让你背而是帮你建立选择依据。做项目时先问自己并发实体之间是资源共享多还是隔离需求强资源共享多选线程隔离需求强选进程。多线程和多进程也可以混合用比如Nginx采用多进程加异步IO的模式而MySQL用多线程处理连接。2. 线程的创建与生命周期管理2.1 pthread_create创建线程的唯一入口Linux下操作线程的接口是POSIX线程库pthread使用前需要包含pthread.h编译时加上-lpthread。创建线程的入口是pthread_createint pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void *), void *arg);四个参数分别是返回线程ID的指针、线程属性传NULL用默认属性、线程入口函数、传给入口函数的参数。注意入口函数的签名是void()(void *)返回值和参数都是void *这样设计是为了能传递任意类型的数据。这里有一个新手必踩的坑如果传给线程的实参是局部变量的地址而主线程在子线程真正读取之前就修改了这个变量比如循环里传同一个变量i的地址那子线程拿到的值往往不是你预期的。我见过有人创建10个线程每个线程拿到的工作ID全是10。正确的做法是为每个线程分配独立的内存malloc一个int线程结束后再free。如果只是传整数值也可以直接强转成void *传进去但这种写法可读性差不推荐。编译命令也容易踩坑。很多人只写gcc test.c结果报错找不到pthread_create其实是忘了加-lpthread。正确的编译命令是gcc -o thread_demo thread_demo.c -lpthread2.2 线程的退出return、pthread_exit 与 pthread_join线程退出有三种方式在线程函数里执行return线程正常结束返回值可以被其他线程获取。调用pthread_exit(void *retval)效果类似return但可以在线程函数任意位置退出。被其他线程调用pthread_cancel取消属于异常退出。主线程如果需要等待某个线程结束并拿到它的返回值就调用pthread_joinint pthread_join(pthread_t thread, void **retval);pthread_join会阻塞调用线程直到目标线程终止。这里有两个细节容易被忽略。第一pthread_join只能等待“可结合的joinable”线程如果目标线程已经分离detachedjoin会返回错误。第二线程结束后如果不join又不detach那么线程所占的资源栈、内核线程结构等不会自动释放程序里会出现僵尸线程长时间运行可能把内存耗干这一点和进程的僵尸态非常像。我自己的习惯是如果一个线程需要主线程等待并获取结果创建时保持joinable如果只是后台跑个任务不关心结束时间果断detach。凡是和网络连接、定时任务相关的线程基本都是detach因为它们在服务退出前本来就要一直运行需要请求/响应模型的线程则join因为要拿结果。2.3 detach让线程自生自灭如果完全不关心子线程的返回值和结束时间可以在创建后调用pthread_detach或者创建时就通过线程属性把它设置为分离状态pthread_detach(tid);或者创建时设置pthread_attr_t attr; pthread_attr_init(attr); pthread_attr_setdetachstate(attr, PTHREAD_CREATE_DETACHED); pthread_create(tid, attr, worker, NULL); pthread_attr_destroy(attr);注意join和detach是互斥的一个线程不可能既分离又可结合。如果两个线程同时对同一个线程调用join行为是未定义的所以join之前要确认不会有其他线程抢着做这件事。2.4 线程取消与清理回调pthread_cancel(tid)可以向指定线程发送取消请求但是否立即取消取决于目标线程的取消状态。默认情况下线程会在取消点比如read、usleep、pthread_cond_wait这类可能阻塞的系统调用响应取消。如果线程函数里有需要释放的资源malloc的内存、持有的锁可以借助pthread_cleanup_push / pthread_cleanup_pop注册清理函数确保线程被取消或退出时能够回收资源。这里有个隐藏陷阱pthread_cleanup_push和pthread_cleanup_pop必须成对出现。而且在某些实现里pthread_cleanup_pop(0)只是把清理函数从队列里移除但不执行pthread_cleanup_pop(1)会立即执行并移除。很多人把这一对宏写在不同分支里导致编译或运行时报错排查半天才发现是括号不匹配。3. 线程同步与互斥让协作变得可控3.1 竞态条件到底是怎么发生的先看一个最简单的例子。全局counter初始为0两个线程各自执行counter循环10万次。理论上最终应该是200000但实际跑出来经常是十几万而且每次运行结果还不一样。原因在于counter不是原子操作它在底层被拆成了取内存值、加一、写回内存三步。线程A和线程B可能同时取到相同的旧值各自加一后写回结果只增加了一次丢了一次更新。这就引出了线程同步的两大目标互斥同一时刻只能有一个线程进入临界区和同步合理安排线程执行的先后顺序。Linux下的经典武器包括互斥锁、条件变量、读写锁和自旋锁。3.2 互斥锁临界区的守门员互斥锁mutex是所有同步工具里最基础的一种使用流程是初始化、加锁、访问共享资源、解锁、销毁。静态初始化用PTHREAD_MUTEX_INITIALIZER动态初始化用pthread_mutex_init。常用接口pthread_mutex_lock阻塞加锁拿不到锁就挂起等待。pthread_mutex_trylock非阻塞尝试拿不到锁立即返回EBUSY。pthread_mutex_timedlock带超时的尝试超时返回ETIMEDOUT。一个完整的计数器保护示例#include stdio.h #include pthread.h static int counter 0; static pthread_mutex_t lock PTHREAD_MUTEX_INITIALIZER; void *add_func(void *arg) { for (int i 0; i 100000; i) { pthread_mutex_lock(lock); counter; pthread_mutex_unlock(lock); } return NULL; } int main() { pthread_t t1, t2; pthread_create(t1, NULL, add_func, NULL); pthread_create(t2, NULL, add_func, NULL); pthread_join(t1, NULL); pthread_join(t2, NULL); printf(counter %d\n, counter); return 0; }使用互斥锁最常见的错误是忘记在某个分支解锁比如中途return了导致死锁。所以我在写代码时给自己定了一条规矩临界区里不要多做事只访问共享资源所有计算都放到锁外解锁之后不要再碰临界区变量。另外锁的粒度要合理——锁太粗并发度低多个线程互相排队锁太细加锁解锁本身的额外开销上来还可能引入复杂的嵌套锁顺序问题。3.3 条件变量让线程学会等待互斥锁解决的是“互斥”但很多场景是“协作”生产者生成了数据要通知消费者来取消费者没数据可消费就得等待。这些场景单靠mutex是不够的需要条件变量condition variable。条件变量的核心API是pthread_cond_wait(cond, mutex)原子地释放mutex并等待被唤醒被唤醒后重新获取mutex。pthread_cond_signal(cond)唤醒一个等待线程。pthread_cond_broadcast(cond)唤醒所有等待线程。pthread_cond_wait的“原子地释放并等待”这个设计很精妙。如果它不是原子的在释放mutex到进入等待之间会插入一个窗口就可能丢失唤醒信号——消费者还没开始等生产者就已经signal了然后消费者永远等不到。为了防止这种“通知丢失”更严谨的写法是使用while循环来检查条件而不是用if判断一次就完事。因为即使被唤醒了也可能有其他线程抢先消耗了条件资源。一个简单的生产者消费者模型#include stdio.h #include pthread.h #include unistd.h #define BUFFER_SIZE 10 pthread_mutex_t mutex PTHREAD_MUTEX_INITIALIZER; pthread_cond_t cond PTHREAD_COND_INITIALIZER; int buffer[BUFFER_SIZE]; int count 0; void *producer(void *arg) { for (int i 0; i 20; i) { pthread_mutex_lock(mutex); while (count BUFFER_SIZE) { pthread_cond_wait(cond, mutex); } buffer[count] i; printf(生产: %d, 当前库存: %d\n, i, count); pthread_cond_signal(cond); pthread_mutex_unlock(mutex); usleep(50000); } return NULL; } void *consumer(void *arg) { for (int i 0; i 20; i) { pthread_mutex_lock(mutex); while (count 0) { pthread_cond_wait(cond, mutex); } int val buffer[--count]; printf(消费: %d, 当前库存: %d\n, val, count); pthread_cond_signal(cond); pthread_mutex_unlock(mutex); usleep(100000); } return NULL; } int main() { pthread_t p, c; pthread_create(p, NULL, producer, NULL); pthread_create(c, NULL, consumer, NULL); pthread_join(p, NULL); pthread_join(c, NULL); return 0; }我写生产者消费者模型的固定模板是加锁、while(条件不满足) cond_wait、处理共享数据、解锁。唤醒端在修改共享数据后signal或broadcast。有个小经验signal之前不用持有锁先释放锁再signal可以更快唤醒等待线程减少锁竞争。3.4 读写锁与自旋锁不同场景的取舍读写锁pthread_rwlock_t适合读多写少的场景。它可以被多个读者同时持有但写者独占。接口和mutex类似只是区分了读锁定和写锁定。需要注意“写者饥饿”问题如果读者持续不断抢占读锁写者可能一直等不到机会。有些实现提供了写优先策略但使用前要确认你用的库是哪种行为。自旋锁spinlock则适合临界区极短的场景。它不会让线程睡眠而是原地自旋忙等避免了线程切换开销但会一直占用CPU。在单核机器上自旋锁如果临界区里有IO操作基本上等于自掘坟墓——一个线程在临界区里被调度出去另一个自旋的线程占满CPU谁也进不去。所以自旋锁只在多核且临界区极短时使用比如内核里的很多场景。用户态使用自旋锁的场景其实不多我一般会用mutex代替因为mutex在锁冲突时会睡眠对系统更友好。4. 线程死锁概念、复现与排查实录4.1 死锁的四个必要条件死锁是线程同步里最让人头疼的问题。它的产生需要同时满足四个条件互斥条件资源只能同时被一个线程持有。持有并等待线程持有至少一个资源又在等待其他资源。不可剥夺资源只能被持有者主动释放不能被其他线程抢走。循环等待存在一个线程等待环A等B持有的资源B等C持有的资源C又等A持有的资源。只要破坏其中一个条件死锁就不会发生。但现实中互斥和不可剥夺往往是业务强约束改不了能做的通常是打破“持有并等待”一次性申请所有资源或者打破“循环等待”给锁编号强制按顺序申请。4.2 现场复现两个线程互相等待下面的代码是一个典型的死锁复现两个线程分别持有锁A和锁B然后都去抢对方手里的锁#include stdio.h #include pthread.h #include unistd.h pthread_mutex_t lock_a PTHREAD_MUTEX_INITIALIZER; pthread_mutex_t lock_b PTHREAD_MUTEX_INITIALIZER; void *thread_1(void *arg) { pthread_mutex_lock(lock_a); printf(线程1 持有锁A等待锁B...\n); fflush(stdout); usleep(100000); pthread_mutex_lock(lock_b); printf(线程1 获取锁B成功\n); pthread_mutex_unlock(lock_b); pthread_mutex_unlock(lock_a); return NULL; } void *thread_2(void *arg) { pthread_mutex_lock(lock_b); printf(线程2 持有锁B等待锁A...\n); fflush(stdout); usleep(100000); pthread_mutex_lock(lock_a); printf(线程2 获取锁A成功\n); pthread_mutex_unlock(lock_a); pthread_mutex_unlock(lock_b); return NULL; } int main() { pthread_t t1, t2; pthread_create(t1, NULL, thread_1, NULL); pthread_create(t2, NULL, thread_2, NULL); pthread_join(t1, NULL); pthread_join(t2, NULL); printf(程序正常结束\n); return 0; }运行后程序会卡住两个printf打完之后就再没有输出。这就是死锁的典型症状程序既不退出也不报错就像被按了暂停键。usleep故意让两个线程在持有锁之后暂停一下增大死锁概率实际代码里即使没有usleep只要两个线程在特定时刻交错执行照样可能死锁。4.3 排查死锁的工具链遇到卡住的程序第一反应不是去翻代码而是先看现场。我常用的排查顺序用ps -eLf找到目标进程记住它的主线程PID。用top -H -p 看每个线程的CPU占用判断是哪个线程卡住。死锁时通常所有相关线程CPU占用都接近0。用gdb -p 附加到进程执行thread apply all bt看每个线程的调用栈。这一步基本能直接定位死锁现场。# 查看进程中的线程列表 ps -eLf | grep deadlock_demo | grep -v grep # 查看线程CPU占用 top -H -p pid # gdb附加后打印所有线程堆栈 gdb -p pid (gdb) thread apply all bt假设输出里看到Thread 1卡在pthread_mutex_lock的调用栈另一个Thread 2也卡在pthread_mutex_lock两个栈分别指向对方持有的锁那循环等待的证据就实锤了。gdb堆栈里通常会有源码文件名和行号直接对照就能找到问题代码。如果程序是长期运行的守护进程不方便用gdb附加怕打断业务可以用pstack 快速打印堆栈或者用valgrind --toolhelgrind做运行时检测。helgrind能自动识别锁顺序错误和竞态虽然跑起来比较慢但在测试环境里很实用。我自己排查死锁最常走的路径就是gdb加thread apply all bt十次里有九次能直接看穿。4.4 避免死锁的工程实践给锁定义全局编号所有线程按照编号从小到大申请破坏循环等待。尽量避免锁嵌套如果必须嵌套把最内层的锁尽量短甚至用trylock拿不到就释放外层锁重试。使用pthread_mutex_timedlock设置超时超时就回滚、释放已有锁然后重新尝试而不是无限等。写代码时画一张锁序图评审时重点看有没有环。我个人体会最深的是死锁往往不是逻辑复杂导致的而是“后来加需求”导致的。两个模块原本各用各的锁某天联调时需要跨模块调用锁顺序就不一致了。所以接口设计阶段就要约定好锁的层级和顺序并写成文档而不是靠大家自觉。5. 线程实战进阶线程池、线程安全与并发新思路5.1 线程池减少创建销毁开销的经典解法频繁创建线程的代价不容小觑。每个线程创建时都要分配栈空间默认8MB的虚拟内存还要初始化内核线程结构销毁时也有缓存失效、内存回收等开销。处理高并发请求时如果请求来了才建线程请求结束就销毁性能会被大幅拖垮。线程池的基本模型是启动时预先创建N个线程它们都阻塞在任务队列上外部只需要往队列里塞任务并唤醒一个空闲线程去执行。这样线程的创建销毁只发生在启动时运行期间线程的复用成本极低。队列通常用互斥锁加条件变量实现线程空闲时cond_wait有任务时signal。任务结构里可以保存函数指针和参数用链表或数组做队列。设计线程池时有几个问题必须想清楚线程数怎么定经验法则是结合CPU核心数和IO等待比例。纯计算型任务线程数等于核心数即可IO密集型线程数可以多一些。队列要不要有上限如果无限塞任务内存会被撑爆所以要设置队满策略阻塞还是丢弃。线程池退出时怎么优雅停机先停止接收新任务再通知线程退出并join回收。线程池是面试高频考点也是实战高发坑区。我曾经见过一个线程池任务队列加了锁但线程退出时没做join导致服务重启时旧线程还在跑新线程又起来两个线程同时操作同一批数据问题相当隐蔽。5.2 线程安全与可重入容易被忽略的细节很多C函数是线程不安全的比如strtok、localtime、rand。原因是它们内部使用了静态或全局缓冲区。strtok用静态指针保存剩余字符串的位置两个线程交替调用就互相覆盖导致解析结果错乱。解决办法有两个一是使用线程安全版本比如strtok_r、localtime_r、rand_r二是用互斥锁保护调用但锁的粒度要覆盖整个解析过程否则还是会竞态。还有一个容易忽略的点errno。errno在Linux线程里是线程局部存储Thread Local Storage, TLS的每个线程有自己的errno不用担心互踩。这其实是个好消息意味着你可以在每个线程里安全地检查错误码而不需要全局加锁。线程安全函数和可重入函数严格来说不是一回事。可重入函数要求函数内部不依赖任何共享可变状态即便被中断或嵌套调用也没问题线程安全函数只保证在并发环境下行为正确可能内部用了锁来实现。一个函数可以线程安全但不可重入比如用了static变量且加锁保护也可以可重入因此天然线程安全。写库代码时尽量让函数可重入这是更彻底的设计。5.3 延伸思考从原生线程到自由线程、虚拟线程与协程Linux pthread是系统级线程由内核调度。但近年来并发模型越来越丰富。比如Python 3.13引入了“自由线程”free-threaded模式尝试移除全局解释器锁GIL让Python的多线程真正能在多核上并行Java的虚拟线程virtual thread则把海量轻量级任务映射到少量平台线程上解决线程创建开销和每次请求一线程的模型瓶颈。再比如Go的goroutine和Rust的async/await本质上都是在用户态做任务调度。作为Linux从业者我的观点是无论上层模型怎么包装底层依然离不开操作系统线程这层地基。理解pthread的概念、生命周期、同步原语和死锁会让你在用虚拟线程或协程时更明白调度器在做什么出了问题也更容易定位。比如调度器把N个协程扔到1个内核线程上如果某个协程阻塞在内核调用上整个线程都会阻塞——这就是为什么协程库反复强调“不能做阻塞调用”的原因。最后聊两句踩坑心得遇到并发问题先看现象再猜原因永远不如直接抓现场ps -eLf看线程全貌top -H看CPU分布gdb thread apply all bt看栈。三步走完绝大多数死锁和竞态都能对上号。写代码的时候锁的顺序、临界区的长短、线程的join/detach这三件事只要有一件拎不清后期维护起来就是灾难。线程不是越多越好资源也不是抢得越快越好把控制做扎实了并发这块才算真正入门。最后再分享一个小技巧线程入口函数里尽量用断言记录入口参数如果传参错了断言能立刻暴露问题而不是等程序跑到一半才出现诡异数据。
返回列表