资讯动态

Linux线程本质剖析:从LWP、TID到并发编程避坑指南

发布时间:2026/10/13 12:53:30 来源:尧图企业网站定制
我给新人讲 Linux 并发编程的时候第一课几乎永远是“线程的概念”。很多人会背进程和线程的区别但一落到实际代码、性能分析、线上故障排查就开始露怯线程到底是一个进程里的多个执行流还是一组共享地址空间的轻量级进程Linux 内核眼里有没有“线程”为什么pthread_create之后ps能看到一堆 PID这篇文章把这些问题从头到尾拆一遍重点不是教你写pthread的代码而是把概念这块地基打牢。看完你就能看懂ps、top里的线程输出能跟别人解释清楚 LWP、TID、TGID 是什么也能避开多线程编程里最典型的几个坑。1. 先搞清楚进程和线程究竟差在哪1.1 为什么进程做并发“重”得让人肉疼Linux 的进程传统意义上是一个“资源分配单位”。每个进程都有自己独立的地址空间、页表、文件描述符表、信号处理函数、当前工作目录、用户 ID、组 ID 等等。你可以把进程想象成一家独立的公司有自己独立的办公室、独立的财务、独立的档案室。公司之间要协作只能通过正式的公文管道、消息队列、共享内存、信号量来通信。用fork()创建进程时内核对父进程地址空间进行“写时复制”处理页表要复制一份任务结构体task_struct、文件表、信号表都要拷贝或引用计数增加。这套操作放在几十年前是合理的因为那时候进程就是最基础的执行单位没得选。但到了并发需求多起来之后进程模式就显得太重了进程切换要切换地址空间TLB 缓存失效代价比线程切换高。进程间通信麻烦性能也远不如直接读写一块共享内存。进程数量一多内存和系统资源开销增长很快。设想一个服务器要同时处理一万个连接如果每个连接都开一个进程光是任务结构体和地址空间的内存开销就能把机器压垮。所以必须有一种更轻量的并发单位这就是线程出现的根本原因。1.2 线程的本质把资源所有权和执行单位拆开线程这个概念的本质是“资源所有权”与“执行单位”解耦。进程仍然负责持有资源地址空间、文件描述符、信号、堆、全局变量。线程只负责“执行”它有自己的栈、寄存器上下文、程序计数器是 CPU 调度器看到的那个实体。同一个进程里的多个线程共享进程的大部分资源但各自有独立的执行轨迹。这个过程可以用办公室的类比来理解。进程就像一间共享办公室桌椅、电脑、文件柜都是公用的所有员工线程都在同一间办公室里干活。员工各自有手中的纸笔栈和寄存器但文件柜、打印机的访问权是共享的。员工之间要交换信息直接在办公室里喊一声就行共享内存不需要去另一个楼里递文件进程间 IPC。这个设计带来三个直接好处线程创建开销远小于进程不需要重新分配地址空间和页表共享现有资源即可。线程切换开销低于进程切换同一进程内的线程切换不需要切换地址空间TLB 压力小。线程通信成本极低共享全局变量和堆就是天然通信机制。1.3 一张表看清单线程共享与私有资源很多资料喜欢笼统地说“线程共享进程资源有独立栈”。但“共享哪些、私有哪些”必须背得清清楚楚因为这是理解线程同步的前提也是面试里反复被拷打的点。共享资源说明地址空间代码段、数据段、BSS、堆、共享库所有线程都可见全局变量与静态变量天然“多线程共享”不加锁访问就是竞态文件描述符表一个线程open的文件其他线程可以read/write信号处理函数进程级设置线程收到同一信号时由任意线程处理当前工作目录chdir后所有线程都受影响用户 ID / 组 ID进程级属性进程 PIDTGID对用户可见的“进程号”线程组内相同私有资源说明线程 IDTID内核调度实体编号gettid()返回值栈每个线程有独立栈默认约 8MB 虚拟内存寄存器上下文SP、PC、通用寄存器切换时保存/恢复信号掩码每个线程可以单独屏蔽信号errno避免不同线程互相干扰errno是线程局部变量线程局部存储TLS__thread声明的变量每个线程一份拷贝调度优先级可通过 pthread API 单独设置实践中不推荐随意调这条私有资源清单里最容易被忽略的是errno。老代码喜欢用errno判断错误如果你的程序单线程跑正常、多线程就出现奇奇怪怪的错误先查是不是用了全局errno导致 A 线程的错误码覆盖了 B 线程的判断。现代 glibc 里errno是宏展开为带 TLS 的变量但如果你用了某些非线程安全的旧库函数这个问题依然会冒出来。2. 从内核角度看线程LWP、TID 与调度单位2.1 LWP内核眼里根本没有“线程”这是 Linux 线程概念里最违反直觉的地方内核里没有单独的“线程”数据结构。在 Linux 内核中线程和进程本质上是同一个结构体task_struct都被称为“任务”。线程的实现在底层是通过clone()系统调用创建的只是clone()的第三个参数带上不同的共享标志位。clone()可以控制父子进程共享什么CLONE_VM共享地址空间。CLONE_FILES共享文件描述符表。CLONE_SIGHAND共享信号处理函数表。CLONE_THREAD加入同一个线程组。当这些标志位组合起来clone()创建出来的“任务”在用户视角看就是“同一进程里的一个线程”。内核把这种任务称为轻量级进程即 LWPLight Weight Process。所以内核调度的最小单位就是 LWP而不是用户感知的进程。你写程序的时候觉得是“开了 8 个线程”内核看到的是“8 个共享了地址空间的 LWP 排着队等上 CPU”。2.2 命令行三件套ps、top、/proc 看线程概念说再多不如自己上手看一次。在 Linux 上查看线程最常用的是这几条命令。第一条是ps -eLfps -eLf这条命令的输出里每行代表一个 LWP也就是一个线程UID PID PPID LWP C NLWP STIME TTY TIME CMD root 1234 1 1234 0 5 08:30 ? 00:00:00 ./demo root 1234 1 1235 0 5 08:30 ? 00:00:00 ./demo root 1234 1 1236 0 5 08:30 ? 00:00:00 ./demo root 1234 1 1237 0 5 08:30 ? 00:00:00 ./demo root 1234 1 1238 0 5 08:30 ? 00:00:00 ./demo其中PID是线程组 IDTGID对线程组内所有线程都一样LWP列才是内核里真正调度的线程 IDNLWP表示这个进程里总共有多少个线程。看到PID相同、LWP不同就说明这是一个多线程进程。第二条是top -H -p PIDtop -H -p 1234-H表示按线程展示-p指定进程号。这条命令能实时看到每个线程的 CPU 占用率。线上排查“哪个线程把 CPU 跑满”时第一步就是用这条命令定位到具体 LWP再转成十六进制和gdb里的线程号对应。第三条是/proc文件系统cat /proc/1234/status | grep Threads ls /proc/1234/task/status里的Threads字段是线程组内线程总数task/目录下列出的数字就是每个线程的 TID。这算是内核最原始的暴露方式各种工具最终都是读这些接口。2.3 线程的出生到结束创建、加入、分离在 C 语言里线程通过 POSIX 线程库pthread创建。最精简的例子大概是这样的#include pthread.h #include stdio.h #include stdlib.h void* worker(void* arg) { long id (long)arg; printf(thread %ld started\n, id); return NULL; } int main(void) { pthread_t tid; long num 1; int ret pthread_create(tid, NULL, worker, (void*)num); if (ret ! 0) { perror(pthread_create); exit(1); } pthread_join(tid, NULL); printf(main thread done\n); return 0; }记住编译时加-pthread选项这个选项不只是链接libpthread还会定义一些与线程安全相关的宏比如_REENTRANT影响部分标准库实现gcc -o demo demo.c -pthreadpthread_create之后子线程和主线程的执行顺序不定。如果不调pthread_join主线程退出时整个进程就结束了子线程可能还没跑完。如果调了pthread_join主线程会阻塞等待这个线程结束并回收其资源。还有一种方式是pthread_detach把线程设为“分离态”。分离态的线程运行结束后资源由系统自动回收不需要也不能再join。服务器里成千上万个线程通常用分离态因为主线程没精力逐个等待。2.4 一个容易背错的点线程 ID 到底是谁很多教材说“每个线程有自己的 PID”这话扔到 Linux 内核里不够准确。准确说法是这样的pthread_self()返回pthread_t类型它是 pthread 库里的线程句柄本质是一个无符号长整数在 glibc 里常被实现为线程栈地址或相关结构体指针。gettid()返回内核 LWP 的 ID也就是前面ps -eLf里LWP列的值。getpid()返回调用者的线程组 IDTGID也就是用户看到的“进程号”。主线程的 TID 就等于 TGID。严谨地说同一进程的所有线程共享同一个 PIDTGID但它们的内核 TID 不同。很多人写多线程程序时想打印“当前线程 ID”习惯性去调getpid()结果发现所有线程打印出来的都是同一个进程号不奇怪因为本来就不是一回事。想打印线程独有的 ID应该用syscall(SYS_gettid)或pthread_self。3. 用户态线程与内核态线程别再混淆三类概念3.1 三种线程模型1:1、N:1 与 N:M线程的实现模型有三类各有各的历史和适用场景。搞清楚这个很多“为什么我的多线程程序卡在一句话上出不来”的问题就有答案了。1:1 模型每个用户线程对应一个内核线程。Linux 上的pthread用的是这个模型NPTL 时代之后。好处内核负责调度能并行跑在多核上某一个线程阻塞在read上不会影响同一进程里其他线程。坏处每次创建和切换线程要陷入内核态量大时有开销。N:1 模型多个用户线程映射到同一个内核线程由用户态线程库自己调度。完全不需要内核参与切换非常快但缺点致命某一线程发起系统调用阻塞时整个进程都阻塞而且无法利用多核除非内核调度到不同的进程上。历史上有些库用过现在基本退出主流。N:M 模型用户线程与内核线程是多对多中间加了一层调度用户线程库先把若干用户线程绑到一个调度实体上再由内核调度实体。这种两级模型理论最优但实现太复杂。Go 的 GMP 调度模型可以理解为一个近似 N:M 的混合调度器Go 的 goroutine 并不直接被内核调度而是由 Go runtime 把可运行的 G 分派到多个系统线程 M 上内核看到的单位其实是 M。一个容易混淆的点很多教材会画“用户级线程/内核级线程”的图把pthread说成用户级线程。实际上现代 Linux 的pthread是 1:1 实现创建出一个用户线程内核里就多一个 LWP。真正的“用户态线程/协程”概念是不被内核感知的线程状态和栈完全由运行时库管理。3.2 pthread 库在处理什么clone、栈与管理结构从用户视角看pthread_create是一个库函数。从内核视角看它最终会调用clone()系统调用。库在这两者之间还做了一大堆事分配线程栈。pthread 默认给每个线程分配约 8MB 的虚拟内存栈可以通过ulimit -s或pthread_attr_setstacksize调整。这 8MB 是虚拟内存不是物理内存一次性映射但线程一多虚拟地址空间消耗依然很可观。初始化线程属性结构。优先调度策略、栈地址、大小、守护标志等都存在pthread_attr_t里。设置 TLS 和errno相关区域。构造pthread_t管理结构保存线程状态、退出码、join 状态等信息。调用clone()并传入一个函数指针让新线程从指定函数开始执行。所以说“线程是用户态的”这句话在 Linuxpthread语境下是错的。用户线程的创建和调度确实是库包装了内核操作但调度实体已经放进内核了。3.3 协程和线程都在“并发”但不是一个东西平时说的协程、纤程、goroutine它们本质是用户态线程或者叫“有栈协程”。它们和内核线程的区别用一个类比最好懂协程是“人工切换”一个函数执行到一半主动让出把寄存器状态和栈保存到自己的协程对象里另一个协程恢复执行。这一切都不需要操作系统参与所以切换极快单线程里可以跑上百万协程。线程是“系统调度”它的切换由内核的调度器决定线程被抢占、阻塞、时间片耗尽都有可能被切换出去。选协程还是选线程不是看谁“先进”而是看场景。高并发 IO成千上万个连接适合协程或异步 IO计算密集型的并行任务适合用多个内核线程分摊到多核。关于“为什么线程不能被随意开十万个”下一节详细算账。4. 什么时候该用线程场景分析与避坑清单4.1 三类典型并发场景不是所有并发都要用线程。什么时候该用线程要看任务的类型。第一类是 IO 密集并发型。比如一个服务器要同时响应 N 个客户端请求大部分时间在等网络包、等磁盘数据。如果用单进程顺序处理一个请求阻塞在read上后面所有请求都要排队。这时用多线程每个请求一个线程或从线程池取一个线程在等待 IO 时被内核切换出去其他线程继续跑。第二类是多核计算密集并行型。比如视频编码、图像处理、大型矩阵计算任务没有太多 IO纯粹吃 CPU。这时目标明确让 N 个线程分摊计算跑满 N 个核。线程数一般等于核数或核数加一。第三类是共享资源的服务模型。比如一个在线商城的内存缓存、一个任务调度中心、一个日志缓冲队列。多个并发请求要同时读写同一份数据用线程天然共享地址空间的特性加上锁或原子操作比用多进程再搞共享内存要简单得多。4.2 “开线程快”是错觉上下文切换与资源账很多新手一上来就“为了并发而并发”随手创建几百个线程结果程序反而更慢。原因要算清楚。每个线程有默认约 8MB 的虚拟栈空间开 1000 个线程光栈的虚拟地址空间就是 8GB。在 32 位系统上3GB 用户空间直接就炸了64 位系统虚拟空间大但页表和物理内存压力依然存在。线程切换并不免费。每次切换内核要保存当前线程的寄存器上下文、栈指针加载下一个线程的这些状态还可能涉及页表切换虽然同进程线程页表共享省了一部分开销。线程切换的消耗是纳秒级但竞争激烈、切得很勤时这个开销会被放大。比如你创建 64 个线程跑一个只需要 4 个核就能算完的任务大量 CPU 时间被花在线程调度和等待上实际收益是负的。一个经验值计算密集场景线程数约等于 CPU 核数IO 密集场景可以适当多开因为线程大部分时间在阻塞等 IO但也要用线程池限制上限而不是无脑while(1) pthread_create。4.3 线程池与异步 IO现代代码为什么不手写裸线程手写裸场景线程最大的问题不是创建慢而是生命周期不可控线程多了调度变乱线程少了峰值流量来了又不够用。所以工程上几乎都先生成一个线程池预先启动固定数量的线程任务丢进队列谁有空谁取来执行。线程池有几个好处避免频繁创建/销毁线程的系统调用开销。限制并发线程总数防止系统过载。池内线程常驻任务队列可以平滑吸收突发流量。用 C/C 的话可以去 GitHub 找开源的 ThreadPool 库但这类项目质量参差不齐遇到崩溃还不易排查C 标准库目前还没把线程池写进标准业界有人用 Boost.Asio、std::async加自定义队列或者用work_stealing模式的方案来近似线程池。除了线程池高并发 IO 场景里现在更推荐异步模型epoll配合事件循环一个线程管几万个连接或者上io_uring把读写请求提交给内核完成后再通知用户程序。这些模型里线程数量很少每个线程的工作强度却很高比“一个连接一个线程”的并发能力高一个数量级。线程和异步不是互相排斥而是可以组合异步事件循环负责 IO线程/线程池负责真正耗时的计算。5. 线程安全实战竞态、死锁与排查技巧5.1 竞态条件 都可能是惊雷就算你理解了线程概念第一个遇到的实践问题几乎都是“这个多线程程序结果不对”。最常见的竞态条件就是对共享变量执行非原子操作。static int counter 0; void* increment(void* arg) { for (int i 0; i 1000000; i) { counter; // 问题根源 } return NULL; }counter在底层是三条指令读取 counter、加一、写回。两个线程同时执行这个操作可能都读到同一个值比如 5都加一写回变成 6结果应该是 7实际却丢了一次更新。最后 counter 远小于 2000000。修法有三种层级互斥锁pthread_mutex_lock包住临界区。简单但高竞争下性能一般。原子操作__atomic_add_fetch(counter, 1, __ATOMIC_SEQ_CST)或 C11 的atomic_fetch_add。无锁开销适合简单计数器。业务层面重设计每个线程维护私有计数最后再汇总。这个方案最好因为把共享降到最低。别迷信“无锁”三个字。无锁编程要考虑内存序、ABA 问题、失败重试难度陡增。先保证不出错再考虑极致性能。5.2 死锁四个条件与打破方式死锁比竞态更隐蔽因为它让程序“停着”而不是“算错”。系统论里死锁发生需要四个条件同时满足互斥资源同时只能被一个线程占有。占有且等待线程占着资源 A还在等资源 B。不可剥夺资源不能被系统强行拿走。循环等待线程 1 占 A 等 B线程 2 占 B 等 A。四个条件只要打破一个死锁就不会发生。最简单的破解手段是加锁顺序一致化所有线程都先锁 A 再锁 B循环等待即告消除。// 错误示范 // thread1: lock(mutex_a); lock(mutex_b); // thread2: lock(mutex_b); lock(mutex_a); // 正确示范 // 所有线程统一: lock(mutex_a); lock(mutex_b);实际工程里还有“锁超时”方案用pthread_mutex_timedlock加上超时时间拿不到锁就放弃并回滚比死锁后靠人肉定位要主动得多。但时间锁并不能根治问题它只是把“永久卡死”变成“失败返回”真正的根因还是设计层面的加锁顺序和锁粒度问题。5.3 线程栈大小与过度创建导致的崩溃线程栈默认 8MB但并非只有真正用了那么多才报错。在地址空间中每个线程栈是一段虚拟内存访问栈越界时会触发段错误Segmentation Fault。如果你在线程函数里定义了一个超大的局部数组void* worker(void* arg) { char buf[16 * 1024 * 1024]; // 16MB超过默认栈 ... }线程一启动就栈溢出崩溃。这种问题在进程编程里不容易遇到因为进程栈大且只有一个线程多了之后栈空间是按每个线程单独算的。可以通过pthread_attr_getstacksize查看默认栈大小用pthread_attr_setstacksize调小栈来压榨内存。但栈调太小会引发随机崩溃需要谨慎。一般来说线程栈默认值不用动除非你对每个线程的实际栈使用量有明确估算并且线程数以千计虚拟内存压力大到必须调。过度创建线程不只是栈问题。线程数超过系统限制pthread_create会返回EAGAIN意思是资源不够了。可以用ulimit -u用户最大进程数和cat /proc/sys/kernel/threads-max查看系统上限。线上如果日志里出现 “pthread_create failed: Resource temporarily unavailable”先查这两项。5.4 排查工具清单gdb、valgrind、TSan线程概念相关的排查手段我常用这几个gdb 查看线程状态gdb -p PID (gdb) info threads (gdb) thread apply all btinfo threads列出所有线程及其当前函数thread apply all bt打印全部线程的调用栈。死锁排查时看到两个线程互相等锁就能定位了。valgrind Helgrindvalgrind --toolhelgrind ./demoHelgrind 能检测数据竞争和锁使用不当包括错误使用已经被销毁的锁、锁顺序问题。缺点是慢程序会跑得龟速适合做离线分析。ThreadSanitizerTSangcc -fsanitizethread -g -o demo demo.c -pthreadTSan 是编译期插桩工具比 Helgrind 快很多检测数据竞争非常有效。缺点是线上的复现率低时要多跑几轮才能抓到。我排查过一个典型的“偶现崩溃”程序在单线程测试时完全没问题压测 200 并发时偶尔崩溃。用 TSan 跑一遍立刻定位到两个线程并发访问一个链表的节点一个线程在释放另一个线程在读 next 指针经典的 use-after-free。没有工具辅助这种问题靠肉眼 review 非常难找。排查线程问题有一个朴素的套路先看是不是竞态数据竞争检测工具跑一遍再看是不是死锁gdb看两把锁的持有关系最后再看是不是栈/资源耗尽查系统参数和栈轨迹。大多数多线程问题的根子都能归属到这三类里。最后说点个人经验我后来给新人补充的“最后一句话”通常是这样不要把线程想成“更小的进程”而要想成“共享资源的执行流”。线程是内存模型和并发需求之间的一种折中——它让多个执行流共享状态、低成本通信代价是你要自己承担同步责任。理解了这个定位后面学锁、学原子操作、学内存序、学线程池、学协程调度才有立足点。如果是刚接触 Linux 线程强烈建议做一个实验写一个只有两个线程的程序一个for循环不停打印线程 ID另一个干等然后在/proc/PID/task/下看看出现了几个 LWP再用perf或者strace -f看一眼线程创建时clone系统调用带上了哪些标志。这一步做完你对线程的感性认识会比看任何文章都深。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑