十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

Linux线程底层原理与pthread同步实践:从内核模型到性能优化

Linux线程底层原理与pthread同步实践:从内核模型到性能优化 很多学Linux的人对线程都有一个误解以为线程就是更轻量的进程API简单跑起来快就行。前几年我独自维护一个高并发网关的时候也是这么想的。当时业务逻辑直接丢给一个固定线程池压测时CPU使用率在20%和99%之间疯狂跳动服务响应时间忽高忽低。查了三天最后翻strace数据才明白线程数量、锁冲突和上下文切换这几件事相互叠加早把系统打满了。从那以后我把Linux线程从内核模型到用户态API重新系统学习了一遍亲手复现了不少多线程故障。这篇文章本质上就是那份学习笔记的整理版覆盖了Linux线程的本质、pthread常用接口和属性细节、同步原语的选型逻辑、调度与线程池配置还有几个我实际踩过的坑。适合两类人看一类是刚接触Linux多线程编程、想把底层原理搞清楚的初学者另一类是写过多线程程序但遇到性能抖动或偶发死锁怀疑自己哪里没想明白的开发者。1. Linux线程与进程不止是轻量级这么简单1.1 从LinuxThreads到NPTL一段绕不开的历史Linux内核早期并没有真正意义上的线程支持。系统里最早的多线程实现叫LinuxThreads由Xavier Leroy在1996年左右完成。它用clone创建一个带着一堆共享标志的子进程来模拟线程每个线程在内核里都有独立的PID和POSIX线程标准的要求差得很远。LinuxThreads的问题我在接触老代码时感受很深线程组里各线程调用getpid()结果不一致导致依赖PID做日志或管理的程序行为错乱主线程退出后整个进程的信号处理也变得非常诡异。所以后来glibc从2.3.2开始大约2003年全面切换到NPTLNative POSIX Thread Library一直沿用至今。NPTL采用1:1线程模型——每个用户态线程对应一个内核调度实体LWPLightweight Weight Process轻量级进程。创建、切换、同步性能相比LinuxThreads都有巨大提升。现代Linux系统上看到的pthread行为几乎全部是NPTL模型下的行为。1.2 clone系统调用与线程的身份证虽然用户态用pthread_create但内核里创建线程实际走的是clone()系统调用。创建一个线程大致等价于clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD, ...)这些标志的含义非常直白CLONE_VM线程之间共享地址空间CLONE_FS共享文件系统信息当前目录、umask等CLONE_FILES共享打开的文件描述符表CLONE_SIGHAND共享信号处理函数表CLONE_THREAD加入同一个线程组这也是为什么同一个进程里的线程看起来像兄弟进程——内核里每个线程都是一个独立的task_struct都有自己的栈、寄存器上下文和调度状态。区别在于共享了一大堆资源。线程的身份证也很好认标识获取方式说明tgid线程组IDgetpid()整个线程组都一样pid内核线程IDgettid()每个线程各不相同LWPps -eLftop里按H键可展开线程视图理解这个模型之后很多问题就顺了。比如线程崩溃为什么会拖垮整个进程因为线程共享地址空间一个线程触发段错误信号后核心转储和进程终止都是进程级行为。你看线上服务莫名其妙整个进程没了多半就是这个原因。多进程架构比如Nginx的worker进程能隔离这种故障这就是两种模型的取舍。后来出现的协程、goroutine、Java虚拟线程都是在用户态做调度配合少量LWP跑任务这已经是另一套话题但理解内核线程模型仍然是看懂它们的基础。1.3 线程共享了什么又独占了什么资源线程之间是否共享说明地址空间代码、堆、全局变量是线程间传指针方便也容易出错文件描述符表是一个线程close(fd)其他线程再操作就会出错信号处理函数是信号发给进程后由任意线程处理errno否TLS线程局部存储隔离栈否每个线程独立栈默认8MB虚拟内存排查问题前先对照这张表能节省大量时间。2. pthread_create之后线程属性、取消与栈空间细节2.1 线程属性不是所有线程都必须可joinpthread_create的第二个参数attr经常被忽略。系统的默认行为是joinable一个线程退出后它的资源栈、TCB不会自动释放必须有另一个线程调用pthread_join把它收走。否则线程虽然死了资源还挂着时间长了就是内存泄漏。很多新手写的多线程程序RSS莫名上涨查了半天找不到谁分配了内存最后发现是没join的线程太多。如果你不需要等待某个线程结束最好在创建前设置detach状态pthread_attr_t attr; pthread_attr_init(attr); pthread_attr_setdetachstate(attr, PTHREAD_CREATE_DETACHED); pthread_create(tid, attr, worker, arg); pthread_attr_destroy(attr);另外有个容易忽略的是栈大小。默认栈大小由ulimit -s控制常见8MB。注意这是虚拟内存不是物理内存一次性分配8MB实际用多少分配多少。但如果线程里有深递归或大数组会一不小心栈溢出反过来在嵌入式或容器环境内存紧张可以把栈调小pthread_attr_setstacksize(attr, 1 * 1024 * 1024); // 1MB2.2 线程取消不是杀线程是配合pthread_cancel经常被误解为让线程立刻消失。实际上它只是发送一个取消请求线程能不能被取消、什么时候取消取决于线程的取消状态和取消点。默认取消类型是deferred延迟取消线程只会在取消点函数处响应像printf、read、write、pthread_cond_wait、pthread_mutex_lock这类可能阻塞的函数都是取消点纯计算循环如果不调用任何取消点函数即使调用了pthread_cancel线程也可能暂时死不了如果希望线程在计算密集区域也能响应取消可以先启用async类型pthread_setcanceltype(PTHREAD_CANCEL_ASYNCHRONOUS, NULL);但这是高危操作可能在线程持有锁或正在更新数据结构时被中断最容易产生死锁和资源泄漏。更稳妥的做法是协作式退出用一个原子标志让工作线程在循环中检查标志后主动退出。清理资源用pthread_cleanup_push和pthread_cleanup_pop无论线程被取消、正常返回还是调用pthread_exit清理函数都会执行相当于C里手动实现的RAII。2.3 TLS与errno为什么每个线程的errno互不影响写多线程程序最容易被忽略的是可重入性和线程安全。比如strtok不是线程安全的strtok_r才是因为strtok内部用静态变量保存上下文。errno也一样它其实是一个宏#define errno (*__errno_location())__errno_location返回当前线程私有存储的指针。正因为TLS线程局部存储的存在两个线程同时做一个系统调用失败时各自读到的errno才互不干扰。如果你自己也有些线程私有数据可以用__thread修饰static __thread int tls_cache 0;这在写无锁缓存和日志上下文时非常实用。3. 同步原语选型互斥量、条件变量、读写锁与自旋锁的真实使用场景3.1 互斥量futex让锁快速路径几乎零开销pthread_mutex_t在设计上极力避免直接进内核。大多数互斥量基于futexfast user-space mutex实现无竞争时加锁/解锁全部在用户态用一个原子操作完成大约几十纳秒只有发现锁被占用才通过futex_wait系统调用进入睡眠等待。这就是为什么互斥量在临界区很短时效率很高——并不是每次加锁都会陷入内核。但要注意锁的粒度。临界区太长锁竞争激烈大量线程睡在futex上临界区太碎把本可以合并的操作拆成一堆锁反而增加开销。经验上临界区内不要做耗时的系统调用不要把锁跨函数到处传。死锁问题大多来自ABBA线程1持有锁A等待锁B线程2持有锁B等待锁A。解决的黄金法则是固定加锁顺序——所有线程都按A、B、C的顺序加锁ABBA就天然不存在了。3.2 条件变量从忙等思维切换成通知思维如果需要线程等待某个条件变为真最朴素的写法是循环判断while (!ready) { /* 空转 */ }问题很直接忙等白白烧CPU而且条件变化后需要线程去检查效率极低。条件变量解决的就是让等待线程睡眠条件满足时唤醒的问题。标准用法是固定的四步// 等待侧 pthread_mutex_lock(mutex); while (!condition) { pthread_cond_wait(cond, mutex); } // 处理条件成立后的工作 pthread_mutex_unlock(mutex); // 通知侧 pthread_mutex_lock(mutex); condition 1; pthread_cond_signal(cond); pthread_mutex_unlock(mutex);新手最容易问为什么pthread_cond_wait要传入mutex原因很简单wait的实现必须原子地完成释放锁 睡眠否则会出现经典的丢失唤醒——线程刚判断完条件还没睡眠通知线程就signal了signal自然没有效果这个线程接下来永远睡在wait上。等待条件一定要用while循环而不是if。原因有两类一类是spurious wakeup虚假唤醒这是POSIX允许的另一类是多个消费者被broadcast唤醒后第一个消费完了后面几个检查条件时发现已经不满足了。用了while唤醒后会重新检查条件不满足就继续睡才能保证逻辑正确。通知侧用signal还是broadcast也值得想一下。如果多个线程在等待而每次资源只够一个消费者处理signal就够了如果是类似批次数据到位所有等待者都要去处理才用broadcast。我见过一个案例把broadcast当默认习惯结果高并发下大量线程被无意义唤醒锁冲突急剧增加吞吐掉了30%以上。3.3 读写锁与自旋锁适用面比想象中窄pthread_rwlock_t允许多个线程同时读、写独占很适合配置数据几乎不变但被频繁读取的场景。但要注意作家饥饿问题如果读者不断进来写者可能一直抢不到锁。不少系统后来改用RCU或直接搞不可变数据结构就是这个原因。pthread_spinlock_t则是纯忙等的锁不会睡眠。使用前提非常苛刻临界区非常短几十条指令以内、CPU有多余核心、并且能容忍持有锁的线程被抢占导致其他CPU空转。如果你在单核虚拟机上用自旋锁那几乎是灾难因为锁持有者被调度出去后等锁线程只能白白转圈直到时间片耗尽。核数少、临界区又不短的场景老老实实用互斥量。锁类型等待方式适用场景风险互斥量睡眠临界区较长、竞争较激烈死锁自旋锁忙等临界区极短、多核核少时CPU飚高读写锁读并发、写独占读多写少作家饥饿条件变量配合互斥量等待条件成立丢失唤醒、虚假唤醒4. 线程调度与亲和性让线程跑在合适的内核上4.1 调度策略普通线程与实时线程的差距Linux默认调度策略是SCHED_OTHER走CFS完全公平调度器。普通线程之间靠nice值调节优先级范围是-20到19数值越小优先级越高。运行中可以用sched_setscheduler或pthread_setschedparam调整。如果需要确定性的调度可以用实时策略SCHED_FIFO高优先级实时线程先运行除非自己阻塞或让出CPU低优先级线程没机会运行SCHED_RR实时策略里的轮转版相同优先级按时间片轮流运行使用实时线程要特别小心一个写得不好的SCHED_FIFO线程在循环里死转整个系统的普通进程都会卡到几乎无响应包括ssh都连不上。所以实时线程的循环体一定要收敛循环里务必留出阻塞点或sched_yield。我在调音频采集线程时用过FIFO效果确实可以但也见过同事在实时线程里写了个while(1)不sleep直接把测试机卡死。4.2 CPU亲和性缓存命中率的隐形杠杆线程从一个核心迁移到另一个核心是有代价的原核心的L1/L2缓存全部失效新核心要重新加载热数据。所以线程数小于核数时最好把关键线程钉在固定核上。Linux提供sched_setaffinity进程和pthread_setaffinity_np线程cpu_set_t set; CPU_ZERO(set); CPU_SET(2, set); pthread_setaffinity_np(pthread_self(), sizeof(set), set);常见做法还包括把中断处理线程、网络收包线程和业务线程分别绑到不同核减少相互干扰。在NUMA架构下还要考虑内存亲和性比如numactl --cpunodebind0 --membind0避免线程在node 0执行却去访问node 1的内存这种跨节点访问的延迟差距可能达到1.5到2倍。4.3 线程池参数从C到Java的参数映射线程池解决的痛点是每次创建和销毁线程太贵pthread_create涉及clone、栈分配、调度器接入高频创建销毁时开销不可忽略。线程池的核心设计就是三个参数核心线程数、最大线程数、任务队列。CPU密集型任务线程数建议按N_cpu或N_cpu1来设多出的一个是为了弥补偶尔的页缺失、系统调用阻塞IO密集型任务线程数要参考阻塞比例常见公式N N_cpu * (1 等待时间 / 计算时间)道理很简单线程阻塞在IO上时不占CPU只有计算时才需要CPU资源所以要把阻塞时间折算进去。比如一个线程平均阻塞80%时间四核机器上线程数就可以开到大约4 * (1 4) 20个。Java里ThreadPoolExecutor的corePoolSize、maximumPoolSize、workQueue三个参数也是同一套逻辑C侧自己实现线程池时通常会用一个带互斥量和条件变量的任务队列。这里有个常见误区最大线程数不是越大越好。线程超过核数后多出来的线程只是在互相争抢时间片同时上下文切换开销和缓存失效呈线性上升。我做过一次对比测试四核机器上把线程数从4调到64同任务吞吐不升反降约50%。如果任务都是CPU密集型的线程数等于核数才是最优解盲目加大只会让调度器更忙。5. 踩坑实录我实际碰过的线程问题与排查方式5.1 丢失唤醒一个小小顺序问题服务直接挂住某次写了一个消费队列通知线程先更新标志位再signal以为这是安全的。结果等待线程那边在循环开头检查标志位发现是0于是进入wait。如果通知线程的时序是先signal后更新标志位或者更新标志位和signal之间没有用同一把锁保护就可能出现等待线程已经决定要sleep通知线程已经发完signal的情况这个signal就丢了。修复方案就是标准写法等待侧在持有互斥量的前提下检查条件并wait通知侧在持有互斥量的前提下设置条件再signal。排查这个问题的过程也很典型服务表现是队列有数据但消费者线程集体在睡觉没有崩溃没有日志异常只有压测时任务堆积。用gdb attach一看所有工作线程都阻塞在pthread_cond_wait同时队列里躺着一堆待处理的任务基本可以锁定是唤醒丢失。5.2 用if等待条件变量偶发数据错乱有同事把while等待写成了if等待理由是都是同一个生产者来通知应该不会有问题。结果上线后偶发出现空数据或者重复消费。原因是多消费者场景下三个线程同时醒来队列里只有两个任务第一个线程拿完两个任务后第二个线程从wait返回但没有重新检查条件直接去取任务发现队列是空的或者取到越界数据。改回while并在取数据前重新判断判空条件问题消失。条件变量的唤醒语义是可能满足条件不是一定满足条件这一点必须刻在脑子里。5.3 伪共享两个线程越努力越慢伪共享是我性能排查中遇到的最隐蔽问题之一。两个线程分别读写两个不同的变量但这两个变量恰好落在同一个64字节缓存行里。每次任何一个线程更新自己的变量都会导致另一个线程的缓存行失效双方反复互相拖慢。测试数据很吓人无冲突时每秒几百万次操作伪共享时降到几十万次。解决方案就两种把热变量按缓存行对齐__attribute__((aligned(64)))让两个变量之间填充到不同缓存行找伪共享最直接的工具是perf观察cache-miss率异常偏高时多想想这个方向。5.4 多线程调试三板斧gdb、strace、perfgdb调试多线程时最有用的几条命令info threads看当前进程有哪些线程thread apply all bt让所有线程都打印调用栈死锁排查首选set scheduler-locking on单步调试一个线程时锁住其他线程避免切换导致步骤混乱死锁发生时的表现通常是进程卡住但CPU不高。用gdb attach上去执行thread apply all bt两个线程各自持有锁等待对方一眼就能看到。如果怀疑是futex层面的问题用strace -f -e tracefutex -p PID能看到线程阻塞在哪些futex地址。perf top则适合在性能抖动时看热点是用户态自旋、内核调度还是锁基本能快速定位方向。5.5 线程数量的幻觉上下文切换到底有多贵有人觉得线程多跑得就快实际上上下文切换不仅涉及寄存器保存恢复还有内核调度器、缓存污染、TLB刷新一次切换可能损失一到几微秒。如果每个线程只干几十微秒的活就被切走那大部分时间都在切来切去。压测时可以用vmstat看cs列上下文切换次数和r列运行队列cs高且r远大于核数时就该考虑削减线程数而不是增加。用pidstat -t -p pid 1可以看到每个线程单独的CPU占用率如果一个线程的%CPU长期接近100%而其他线程都在个位数说明代码本质上是单线程串行的多线程只是并发假象靠加线程治不好。我在实际项目里形成了一套自己的习惯线程数宁少勿多能用线程池绝不裸开线程所有共享资源的访问先想清楚锁的粒度和加锁顺序所有等待条件的地方一律用while加条件变量。还有一个不常被注意但很重要的经验不要手动创建线程之后放任不管每个线程创建前都要问一句这个线程是join还是detach退出后谁负责收尾。回答不上来就不要创建。Linux线程这套东西API确实不多会用的门槛很低真正用得明白就难在每一条底层规则都符合直觉。理解内核模型、守住同步纪律、尊重调度器多线程程序才能真正扛住生产环境的折腾。
返回列表