拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Linux线程开发实战:从pthread到线程池,从同步到死锁排查

在Linux下做服务端开发这几年我和线程打过太多交道了。从最早用pthread_create手搓线程到后来在Java和C里玩线程池再到线上服务线程数暴涨、死锁、互斥竞争这些翻车现场基本每个坑都踩过一遍。这篇博文我不打算写成一本正经的API手册而是想站在“我真正用过这些玩意”的角度把Linux线程从概念到实战、从同步到排查完整串一遍。里面会有代码、有参数、有排查命令也会有我在实际项目中踩过坑之后的反思。不管你是刚接触Linux编程的初学者还是写了几年业务代码想补一补并发底子的工程师这篇应该都能让你少走点弯路。1. 线程到底解决了什么问题为什么不是“多个进程”1.1 先说清楚线程和进程的边界很多初学Linux的人一上来就背“进程是资源分配的最小单位线程是调度的最小单位”背得很熟但一写代码就糊涂。我换个说法进程就像是公司里的一个独立部门有自己独立的办公区、独立的人事档案、独立预算线程则是这个部门里的员工大家共享办公区、共享打印机、共享项目资料但每个人手头的事可以分开干。从内核角度看Linux里的线程本质上就是一种“轻量级进程”它和进程一样都是用task_struct结构体来管理的也都有独立的栈和寄存器上下文。但同一个进程里的线程共享地址空间、共享全局变量、共享打开的文件描述符创建线程连页表都不用重新复制所以创建和切换开销远小于进程。这就引出一个关键问题既然共享地址空间这么多好处为什么不能全部用进程加共享内存来做不是不能Windows早期用进程做并发通信很痛苦Linux下用fork加管道、共享内存也能跑但你会发现进程之间的数据交换要么走内核管道、Socket要么处理页表映射共享内存每次通信都有额外开销而线程之间靠全局变量、堆内存就能直接交换数据延迟低得多。不过低延迟是要付代价的。共享意味着同步同步意味着锁、条件变量、原子操作这一整套并发控制工具。很多并发Bug的根源不是线程本身难用而是你用进程的思路去写线程共享代码。1.2 从一次压测翻车看线程的价值说个我自己的经历。早年间做网关转发服务最开始图省事每个客户端连接就开一个进程处理反正机器内存大进程也就几MB开销。上线后到1000个连接的时候还稳压到5000的时候CPU直接飙升到90%以上用top一看全是系统态开销。原因不难查进程切换要切换页表TLB全部失效5000个进程在那儿来回切光切换开销就把CPU吃满了。后来改成线程模型一个进程里开2000个线程处理连接系统态CPU从50%降到7%左右吞吐翻了两倍。这不是说进程没用而是说高并发场景下线程的轻量特性是硬需求。当然再往后我又发现线程也有上限——每个线程默认栈8MBulimit -s即使虚拟内存不真正全分配2000个线程的虚拟地址空间还是让人肉疼。再后来主流方案变成线程池事件驱动epoll线程数量压到几十上百个这是后话。2. Linux线程的创建、生命周期与调度策略2.1 pthread_create从一个“hello thread”说起Linux下的C/C线程接口是POSIX线程也就是pthread。最基础的创建方式长这样#include pthread.h #include stdio.h void* worker(void* arg) { int id *(int*)arg; printf(worker %d start\n, id); return NULL; } int main() { pthread_t tid; int idx 1; pthread_create(tid, NULL, worker, idx); pthread_join(tid, NULL); return 0; }这段代码有个非常经典的坑idx是栈上局部变量传给子线程后主线程可能继续修改或退出导致子线程读到的id不可预期。正确做法是传入堆上分配的参数或者用整数类型直接(void*)id传值。编译的时候记得加-lpthread否则链接会报“undefined reference to pthread_create”。我见过不少新手栽在这一步其实这只是一个小提醒。pthread_create的原型里第二个参数是线程属性pthread_attr_t大部分场景传NULL就行但如果你要设置栈大小、分离状态、调度策略就得在这里做文章。2.2 线程生命周期join 还是 detach这是个问题线程创建后只有两种归宿可连接joinable或分离detached。默认是joinable意味着你必须在某个时刻调用pthread_join去回收它否则它结束后的资源不会完全释放时间久了线程越积越多内存悄悄上涨这就是常说的“线程泄漏”。什么时候该detach你明确知道不需要等它返回、也不需要拿它的返回值的时候。比如后台写日志的线程、心跳线程创建后直接pthread_detach(pthread_self())让它自生自灭。但小心分离线程的所有资源在退出时自动释放你无法确认它到底跑完了没所以分离的线程内部必须自己把异常兜住。我自己习惯的做法是能join就join不能join就一定detach绝不留下“既没join也没detach”的孤儿。因为这类宽松代码在压力测试时最容易暴露问题进程退出时线程还在干坏事core dump一团糟。生命周期管理还有一点容易被忽略线程退出不等于进程退出。如果主线程执行了return 0或者调用exit()不管其他线程跑没跑完整个进程立刻结束没有“优雅关停”一说。想等所有子线程收工要么pthread_join逐个等要么用条件变量广播通知各自join的方式做线程协作退出。2.3 线程调度策略SCHED_OTHER、SCHED_FIFO、SCHED_RRLinux线程是内核级线程调度策略由内核决定。默认策略是SCHED_OTHER也叫CFS完全公平调度配合nice值调节抢CPU的权重。多数业务线程用默认策略就够但如果你的线程是实时的比如音视频处理、机器人控制就要考虑SCHED_FIFO或SCHED_RR。用pthread_attr_setschedpolicy可以在创建线程时指定策略#include pthread.h #include sched.h pthread_attr_t attr; struct sched_param param; pthread_attr_init(attr); pthread_attr_setschedpolicy(attr, SCHED_FIFO); param.sched_priority 80; pthread_attr_setschedparam(attr, param);SCHED_FIFO是“先到先得”只要FIFO线程就绪它会一直抢占CPU直到自己让出或阻塞适合对响应时间严格要求的场景。SCHED_RR在FIFO基础上加了个时间片轮转让同优先级的实时线程轮流跑。这两种策略的优先级范围是1-99数字越大优先级越高。这里有个反直觉的坑实时线程如果代码里没有主动让出CPU的操作可能会饿死其他所有普通线程。我遇到过一次某模块跑了个SCHED_FIFO的线程内部死循环没加sleep一上线CPU的其它核心全部受影响连SSH都卡成幻灯片。排查半天才发现是实时策略忙循环搞的鬼。所以实时策略是大杀器用之前想清楚用的时候要有让出点。3. 线程同步与互斥原子性、可见性、顺序性缺一不可3.1 一个计数器引发的血案假设有两个线程各自对同一个全局变量counter执行10万次。你觉得结果是多少20万现实中你会发现结果永远小于20万而且每次跑数字都不一样。原因是counter在底层不是一条指令而是“读取-加一-写回”三步。两个线程可能在同一时刻都读到同一个旧值然后分别加一写回这个加一操作就“覆盖”了一次。说白了并发修改共享数据必须保证原子性。最简单的解法是加互斥锁pthread_mutex_t lock PTHREAD_MUTEX_INITIALIZER; int counter 0; void* inc(void* arg) { for (int i 0; i 100000; i) { pthread_mutex_lock(lock); counter; pthread_mutex_unlock(lock); } return NULL; }锁的核心逻辑就是“我锁着的时候你等着”。但锁不是免费的每个互斥操作都有几十纳秒级别的开销临界区如果太粗性能会急剧下跌。3.2 互斥锁、读写锁、自旋锁和条件变量怎么选很多人在选锁的时候犯选择困难症我直接给一张我自己总结的决策表锁类型适用场景优点需要注意的点互斥锁pthread_mutex临界区不确定、可能阻塞线程挂起等待不占CPU临界区越小越好读写锁pthread_rwlock读多写少的共享数据读读可并发性能好写者可能饿死且频繁读锁也有开销自旋锁pthread_spinlock临界区极短、多核CPU等待不切换线程忙等单核或临界区长时反而更差条件变量pthread_cond等待某个条件满足后再干活避免忙等必须搭配互斥锁控制好状态和唤醒我曾经把一段配置热更新的代码从互斥锁换成读写锁效果立竿见影——读取配置的线程占了95%以上全部只读锁并发通过写锁几乎不触发压测吞吐提升了3倍多。但如果你的业务是写多读少读写锁的优势就没有了别迷信任何一种锁。自旋锁是最容易用错的东西。自旋锁等待时不睡眠而是死转CPU好处是线程不切换、延迟低坏处是如果临界区里做了耗时操作其他核心的线程就在那儿白白空转。我建议自旋锁的临界区控制在几十条指令以内比如更新一个指针、清一个标志位千万别在里面调函数、做IO。条件变量是线程同步里“会说人话”的工具。经典的生产者消费者模型pthread_mutex_t mutex PTHREAD_MUTEX_INITIALIZER; pthread_cond_t cond PTHREAD_COND_INITIALIZER; int queue_ready 0; // 生产者 pthread_mutex_lock(mutex); queue_ready 1; pthread_cond_signal(cond); pthread_mutex_unlock(mutex); // 消费者 pthread_mutex_lock(mutex); while (!queue_ready) { pthread_cond_wait(cond, mutex); } queue_ready 0; pthread_mutex_unlock(mutex);关键点是pthread_cond_wait在被唤醒后必须重新检查条件这就是为什么我用while而不是if逻辑是防止“虚假唤醒”。这一点只看API文档很难意识到实际测试时偶尔就莫名其妙多跑了一圈。3.3 死锁的产生、检测和止损死锁这个词听着高级其实本质就是“你等我、我等你大家都不让”。最经典的死锁场景是两个线程分别持有锁A、锁B然后互相去拿对方的锁谁也不放手。死锁产生的四个必要条件互斥条件资源不能共享锁只能一个人持有。持有并等待持有一把锁的同时等待另一把锁。不可剥夺别人不能抢走你已经持有的锁。循环等待多个线程形成一个锁等待的环。想避免死锁最实用的一招是给所有锁排个顺序所有线程都按同样顺序加锁破坏“循环等待”条件。比如规定必须先拿锁序号小的、再拿锁序号大的这样就没有环了。真到了排查阶段Linux下的三板斧gdbattach到进程后thread apply all bt看每个线程的栈重点看它们卡在哪个锁函数上。pstack命令可以快速打印进程内所有线程的堆栈快照不熟悉gdb的时候很好用。Java的话jstack pid输出里会自动帮你识别死锁并标出Found one Java-level deadlock。我在生产环境排查过几次死锁最后总结出一个经验死锁不像崩溃那么明显它更像是服务“卡住了”——请求堆积、超时激增、CPU很低但吞吐为零。遇到这种症状时第一反应不是去吹代码而是打印线程栈看看大家卡在哪往往一眼就能锁定环。4. 线程池从裸奔到工业化管理4.1 裸线程为什么不够用很多人刚学会pthread_create之后激动地给每个任务都开一个线程。短期没问题任务一多、并发一高问题全来了线程创建和销毁本身是有开销的频繁创建销毁会拖慢整体性能。线程数量失控后上下文切换开销飙升响应延迟反而变高。每个线程都有独立的栈空间即使只有一小部分真正用到虚拟地址空间压力也在。没个上限意味着系统资源可能被耗尽OOM或线程数打到进程上限就会挂。线程池的思想很朴素提前建好一批线程放进池子里有任务就丢给它们执行没任务就让它们待命。核心是“复用”和“限流”。4.2 Java线程池参数到底怎么定Java把线程池包装成ThreadPoolExecutor七个参数摆在那里很多新手不知道从哪下手。我直接给一个配置思路。核心参数先理清楚corePoolSize核心线程数默认一直存活。maximumPoolSize最大线程数线程不够用且队列满时扩容到这个值。keepAliveTime非核心线程空闲多久后回收。workQueue任务队列核心线程忙不过来时先排队。threadFactory线程工厂设置线程名很重要。RejectedExecutionHandler队列也满、线程也到上限时拒绝策略。最常被问的问题就是“corePoolSize配多少”。没有万能数字但我给两类经验公式CPU密集型任务线程数约等于CPU核心数1。比如8核机器9个线程左右。线程超过核数纯计算场景下并没有收益反而因为切换变慢。IO密集型任务线程数可以设大一点经验公式是CPU核心数 * (1 平均等待时间 / 平均计算时间)。比如一次请求1ms计算、99ms在等数据库返回8核机器按公式算下来要800个线程听着夸张但IO密集场景线程大多在阻塞等待线程等得起。实际的项目中我建议不要直接套公式而是先给一个初步值压测观察吞吐和延迟曲线再慢慢调整。线程池调参没有一次到位的压测数据才是标准答案。还有几个想提醒的细节线程池名一定设置。用Executors.defaultThreadFactory()生产出来的线程名是pool-1-thread-1线上排查时完全没法定位是哪个池。拒绝策略不要随便用DiscardPolicy任务被静默丢弃数据丢了都不知道。我一般用CallerRunsPolicy让提交任务的线程自己跑至少在堆积时把压力反馈回去。别直接用Executors.newFixedThreadPool()和newCachedThreadPool()它们用的无界队列或无限线程数都有隐患。阿里巴巴Java开发规范里也强调了线程池要么用ThreadPoolExecutor直接构造要么用Executors里带上限的方法。4.3 C线程池的一个参考思路C11之后有了std::thread配合互斥锁、条件变量可以搭一个非常轻量的线程池。一个最小可运行版本的核心逻辑大概是#include vector #include thread #include queue #include functional #include mutex #include condition_variable class ThreadPool { public: ThreadPool(int threads) { for (int i 0; i threads; i) { workers.emplace_back([this] { while (true) { std::functionvoid() task; { std::unique_lockstd::mutex lock(m_mutex); m_cv.wait(lock, [this] { return m_stop || !m_tasks.empty(); }); if (m_stop m_tasks.empty()) return; task std::move(m_tasks.front()); m_tasks.pop(); } task(); } }); } } templateclass F void enqueue(F f) { { std::unique_lockstd::mutex lock(m_mutex); m_tasks.emplace(std::forwardF(f)); } m_cv.notify_one(); } ~ThreadPool() { { std::unique_lockstd::mutex lock(m_mutex); m_stop true; } m_cv.notify_all(); for (auto worker : workers) worker.join(); } private: std::vectorstd::thread workers; std::queuestd::functionvoid() m_tasks; std::mutex m_mutex; std::condition_variable m_cv; bool m_stop false; };这个实现里最值得注意的地方是两个条件变量用的while和m_stop检查退出时先置m_stop再notify_all保证所有线程看到停止标志后处理完剩余任务再退出。如果你在~ThreadPool里不置停止标志直接notify工作线程可能永远空转退不出来程序析构时就卡死。线程池是解决绝大多数高并发场景的通用方案。很多人在写并发程序时第一反应是“来多少请求开多少线程”这个思维一定要扭转过来线程池才是工程化的“标准答案”。5. Linux下怎么看线程、查线程、收拾线程5.1 查看线程数量的三板斧运维和调试时第一步永远是“先看看现在系统里有多少个线程”。我的三板斧是# 查看某个进程内的线程数 ls /proc/pid/task | wc -l # 直接看进程的线程状态类似 top 的线程版 top -H -p pid # 带线程IDtid的进程列表 ps -eLf | grep 进程名ps -eLf输出的LWP就是线程IDNLWP是线程数。top -H会列出所有线程的CPU占用如果某个线程CPU飙到100%一眼就能锁定。如果你想实时监控线程数的变化趋势写个一行循环watch -n 1 ls /proc/pid/task | wc -l线程数正常情况下应该在预期的稳定范围内。如果一直涨十有八九是线程泄漏——比如pthread_create成功但既没join也没detach或者Java里线程池用完后没shutdown。线程数涨到进程上限ulimit -u的时候新线程会直接创建失败服务就“假死”了。5.2 线程栈不健康怎么定位服务卡顿、CPU飙升是排查最多的两类问题。查CPU飙升先top -H -p pid找到烧CPU的线程ID然后转成十六进制printf %x\n 线程ID再用gdbattach进程通过线程ID定位到对应线程的栈gdb -p pid (gdb) thread 线程号 (gdb) bt这个套路在Java里更好用jstack直接文字化打印所有线程栈CPU飙高时看哪个线程在疯狂执行方法栈基本就是凶手。服务卡顿如果是锁等待gdb里能看到大量线程阻塞在__lll_lock_wait之类。这些信息配合线程名还记得Java里要设置线程名吧定位效率会高很多。5.3 线程局部存储不用锁的并发优化锁用多了降低性能但有些数据本质上就是“每线程一份”的根本不该共享。C/C里可以用__thread修饰变量让每个线程拥有独立副本__thread int trace_id 0;这样就不用加锁了因为每个线程操作的变量是自己的。线程局部存储在实现全局traceID、请求级缓存时非常好用在性能敏感场景里是最便宜的“线程安全”。Java里对应的就是ThreadLocal。每次业务上线我都会查一遍有没有把不该放ThreadLocal的东西放进去比如连接对象、大数组、带状态的对象。因为线程池里线程是复用的ThreadLocal不会随请求结束自动清空上次请求的脏数据可能被下次请求读到这个坑踩一次就能记一辈子。5.4 线程与全局异常、信号处理的“握手”多线程程序崩溃时core dump里经常能看到多个线程的栈。某线程段错误会导致整个进程退出但调试信息是全部线程的。我第一次在gdb里看到一堆线程栈时愣了一下后来才明白线程崩溃是进程级的灾难但调试时所有线程都会定格在那一刻。处理异步信号时也要注意只有主线程能控制signal的处理时机子线程里处理信号行为在某些版本下并不符合直觉。我建议服务里统一集中在主线程做信号监听比如SIGTERM、SIGINT子线程专注业务逻辑就好。另外Linux的栈溢出也是多线程容易踩的坑。默认线程栈8MB如果你在子线程里递归很深或者分配大数组直接段错误。排查时ulimit -s看当前栈大小上限pthread_attr_setstacksize可以设置更小或更大的栈。生产环境里我尝试过把大量线程的栈从8MB缩到2MB内存占用立即降了一大截而且没有影响稳定性。6. 从实际操作角度说说线程安全这件事线程安全这词人人会讲但判断标准写起来非常玄学。我自己的标准是“如果我写的数据可以被两个以上线程看到就必须通过同步手段保证访问是安全的”而不是“我测了半天没出问题就觉得它安全”。并发Bug有个可怕特性——概率低但一旦发生后果随机且难复现。典型的例子是C里两个线程分别读写一个大数组。你要是只有一个线程写、一个线程读建议仔细想想读写顺序是否需要同步保证。比如写线程先更新数组再过一会儿更新长度字段读线程先读长度再读数组。如果长度比数组内容先变读线程拿新长度读旧内容就是经典的“撕裂读”。这种情况下要么加锁要么用原子变量配合内存序要么在逻辑上确保读线程能容忍新旧版本交错。我处理这类问题时最常用的方案是“双缓冲”或“写时拷贝”写线程在本地副本里改完再一次性切换指针读线程看到指针变化前后都是完整的快照。这种做法比锁更轻逻辑简单基本上不需要担心并发崩溃问题。Java里也有类似的思路比如CopyOnWriteArrayList。但要注意它的适用场景是读多写少写操作会拷贝整个底层数组写太频繁的话性能反而很差。说到底线程安全不是靠某一个工具而是靠一套“谁可以写、何时写、读的时候写是否安全”的设计约束。先想清楚再动手才能少写几个线上事故。7. 关于线程调度、性能调优的几条实操心得线程写对了只是及格性能调优才是拉开差距的地方。说几个我从项目里总结出来的心得。CPU缓存命中率是个常年被忽视的变量。线程A不停修改变量X线程B不停读取变量Y如果X和Y恰好被分配在同一个缓存行通常64字节一个线程写X会强制另一个线程的Y缓存失效这种无谓的竞争叫“伪共享”。实际项目里我会把经常被不同线程访问的变量用__attribute__((aligned(64)))或padding隔开性能提升立竿见影。锁粒度永远不要大到无所谓。临界区里除了必要的共享数据操作什么都不要放。IO操作千万不能带锁否则所有线程都在等一个磁盘或网络。线程数量的“黄金分割”要靠压测确定不要猜。我是这么做的写一个精简压测脚本逐步提高线程数同时记录QPS和P99延迟画成曲线。QPS涨到一定程度不再涨甚至下降的点就是你的最优线程数。这个数据在Java线程池配置和C线程池设计里都非常有价值。最后想说一说线程安全的“设计先行”。不要等代码写完了再拿锁去缝缝补补先明确数据归属哪些是线程独享的、哪些是全局共享的、共享数据怎么协作。我见过太多代码一上来就疯狂加锁结果死锁满天飞也见过半路疯子加锁性能一塌糊涂。线程这东西理解越深越会发现它的核心不是“开线程”而是“管线程”——管生命周期、管共享数据、管资源上限。把这几点管好你的服务才能在高并发的洪流里安安稳稳地跑下去。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门