资讯动态

Linux进程与线程底层机制:clone、写时复制与上下文切换实战

发布时间:2026/10/26 19:49:13 来源:尧图企业网站定制
谈 Unix 的进程与线程绕不开一个绕口令式的结论Linux 上压根没有独立的“线程”这个内核对象线程就是共享了地址空间的进程。这句话第一次听会觉得是文字游戏真正动手写过pthread、翻过/proc目录、在gdb里见过LWP编号之后才发现它是理解整套机制的钥匙。这篇接着上一篇的进程基础往下走重点放在进程与线程的资源边界、fork到clone的底层分野、上下文切换的真实成本以及多线程编程里那些文档不会告诉你的坑。内容偏实战适合已经能写fork()、跑过ps -ef但一遇到“线程数开多少合适”“为什么多线程程序 fork 之后卡死”“共享内存到底比 socket 快多少”这类问题时心里没底的同学。读完至少能做到三件事看懂clone()的参数表、给线程池定一个有理有据的数量、在线上环境把上下文切换的锅定位到具体代码行。1. 从 fork 到 cloneUnix 进程与线程的底层分野1.1 为什么说 Linux 的线程本质是“共享地址空间的进程”想搞明白这件事最直接的办法是去看/proc目录。一个普通进程在/proc/pid/下面有一堆文件status、maps、fd/、cwd等等。而一个多线程程序主进程目录下会多出一个task/子目录里面按线程 ID 排着一个个子目录每个子目录里的内容结构和主进程几乎一模一样。这件事透露的信息非常关键内核眼里的调度单位是 task进程和线程的区别只在于这些 task 之间共享了多少东西。传统操作系统教材会把进程定义为“资源分配的基本单位”把线程定义为“调度的基本单位”。这个说法在教科书语境下没错但落到 Linux 实现上内核只维护一种数据结构task_struct。所谓“创建进程”是克隆一个task_struct并且几乎不共享任何资源所谓“创建线程”是克隆一个task_struct但把地址空间、文件描述符表、信号处理函数表这些东西全部指向同一份。这个“共享还是不共享”的选择就是clone()系统调用那一长串CLONE_*标志位在干的事。所以你会在很多地方看到getpid()和gettid()的区别。getpid()返回的是线程组 IDTGID同组所有线程返回同一个值gettid()返回的是内核真正分配的那个 task ID。用ps -eLf看PID列都一样LWP列各不相同那个LWP才是内核调度时的身份标识。我第一次在gdb里看到Thread 2.1这种编号的时候愣了半天后来才反应过来那个 “2” 就是 TGID“1” 是组内序号。理解了这一层很多“奇怪”的现象就有解释了。比如为什么一个线程崩溃收到SIGSEGV整个进程都挂掉——因为信号处理函数表是共享的而致命信号默认动作是终止整个线程组。再比如为什么errno在多线程下不会互相干扰——它被放在了线程局部存储TLS里每个 task 一份。这些都是共享/私有边界的直接后果。1.2 fork、vfork、clone 三者的参数差异与选型逻辑三个函数经常被摆在一起比较但它们的关系不是并列而是层层递进。fork()是最“重”的一个完整复制父进程的地址空间靠写时复制偷懒、文件描述符表、信号处理方式等等返回两次父子进程各自往下跑。vfork()是个历史产物它不复制地址空间父进程直接阻塞子进程借用父进程的栈跑唯一的正经用途是“立刻execve换掉自己”——因为不复制页表省掉了一次开销。clone()则是前两者的通用化版本把“复制什么、共享什么”全部变成参数交给调用者决定。从内核源码角度看fork()、vfork()、clone()最终都走到do_fork()新版本内核里叫kernel_clone()传进去的clone_flags不同而已。大致对应关系是这样的调用等效的 clone 标志典型用途fork()SIGCHLD通用子进程创建vfork()CLONE_VFORK | CLONE_VM | SIGCHLD创建后立刻 execpthread_create()CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD | CLONE_SYSVSEM | CLONE_SETTLS | CLONE_PARENT_SETTID | CLONE_CHILD_CLEARTID用户态线程CLONE_VM共享地址空间这一条是线程和进程最本质的分界线。CLONE_FILES共享文件描述符表意味着一个线程close(fd)之后别的线程也用不了了——这一点经常被忽略我在一个网络服务里就栽过某个线程处理完连接顺手关了 fd结果另一个正在epoll_wait的线程收到一堆EBADF排查了半天。CLONE_FS共享文件系统信息包括当前工作目录、根目录、umask所以线程里chdir()会影响整个进程的所有线程。CLONE_SIGHAND共享信号处理函数表但注意信号掩码blocked mask是每个线程私有的。CLONE_THREAD让新 task 加入调用者的线程组这是getpid()返回同一个值的原因。clone()有一个容易踩的地方调用者必须自己准备栈。fork()是内核帮你复制栈clone()不会你得mmap一块内存再传进去而且在 x86-64 上栈是向下增长的传的指针要指向这块内存的高地址端。写个最小可运行的例子感受一下#define _GNU_SOURCE #include sched.h #include stdio.h #include stdlib.h #include sys/mman.h #include sys/wait.h #include unistd.h #define STACK_SIZE (1024 * 1024) static int child_fn(void *arg) { printf(child: pid%d ppid%d\n, getpid(), getppid()); return 0; } int main(void) { char *stack mmap(NULL, STACK_SIZE, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK, -1, 0); if (stack MAP_FAILED) { perror(mmap); exit(1); } /* 栈向下增长从高地址开始 */ char *stack_top stack STACK_SIZE; pid_t pid clone(child_fn, stack_top, CLONE_VM | CLONE_FS | CLONE_FILES | SIGCHLD, NULL); if (pid -1) { perror(clone); exit(1); } waitpid(pid, NULL, 0); munmap(stack, STACK_SIZE); return 0; }这段代码跑起来child_fn里打印的getpid()和父进程一样因为带了CLONE_THREAD才会一样这里没带所以其实不一样可以自己改一下对比但注意clone返回的pid是子 task 的 ID。把这个参数表挨个改一遍观察输出比看十篇博客都管用。1.3 写时复制到底省了什么又在什么时候失效fork()之后父进程立刻复制一份完整的地址空间这个说法是错的。真实做法是内核把父进程的页表复制一份给子进程但把所有可写的页在父子双方的页表项里都标成只读然后两边共用同一份物理页。谁先写谁触发缺页异常内核这时候才真正分配一个新物理页、拷贝内容、把权限改回可写。这就是写时复制Copy-On-WriteCOW。省下的开销有多大一个占 1GB 内存的进程fork()之后如果子进程只做一点点事就退出实际拷贝的物理页可能只有几十 KB。这就是为什么很多服务喜欢用fork()做“预加载 并行处理”的模型父进程把配置、字典、模型全部加载好然后 fork 出一批子进程共享这份只读数据内存占用几乎不涨。但 COW 有个反直觉的失效场景如果 fork 之后父子进程都频繁写内存成本会翻倍甚至更高。每次写都要走一次缺页异常异常处理比普通内存写慢两三个数量级。我见过一个日志处理程序父进程fork之后子进程不停地往一个大的std::vector里 push结果性能比单进程还差。后来改成子进程自己malloc一块独立缓冲区问题就没了。经验是fork 之后的写操作要尽量落在新分配的内存上别去碰从父进程继承来的大结构体尤其是那些被频繁修改的计数器、缓存。还有一个细节COW 的粒度是页通常 4KB。如果你的数据结构里只读部分和频繁写部分混在同一个页里那这个页每次写都得复制。做高性能场景时可以把只读配置和可变状态分开分配让它们落在不同的页上避免互相“污染”。另外fork()复制的不只是内存。文件描述符表会被复制引用计数加一指向同一个file对象所以父子进程共享文件偏移量。这一点在多进程写同一个日志文件时特别容易出问题——你以为各写各的实际偏移量是共享的输出会交错。解决办法是每个进程自己open一次或者用O_APPEND保证追加写的原子性。2. 进程与线程的资源账本哪些共享哪些私有2.1 一张表把地址空间、fd 表、信号处理的归属说清楚面试里最爱问的就是“进程和线程的区别”标准答案背起来很顺但真到写代码的时候能准确说出“这个变量到底几个线程共用一份”的人并不多。我把常见资源列成表建议直接存下来遇到诡异 bug 时对着查一遍。资源进程内同进程的多个线程之间说明代码段、数据段、堆独立共享一个线程free了别的线程手里的指针立刻变野指针栈独立独立每个线程有自己的一块默认大小受ulimit -s影响寄存器上下文独立独立切换时保存/恢复文件描述符表独立共享一个线程close全体失效当前工作目录、umask独立共享线程里chdir影响全进程信号处理函数表独立共享sigaction是进程级设置信号掩码独立独立pthread_sigmask只影响当前线程errno独立独立放在 TLS 里用户 ID、组 ID独立共享线程改不了需要特权的操作要谨慎进程组、会话独立共享整个线程组一个调度策略、优先级独立独立pthread_setschedparam是按线程设的CPU 亲和性独立独立sched_setaffinity可以只绑某个线程这张表里最坑的两行是“堆”和“文件描述符表”。堆共享意味着所有内存分配函数必须是线程安全的glibc的malloc是但你自己写的对象池大概率不是。文件描述符共享意味着“谁打开谁关闭”这条规矩在多线程里必须严格执行否则就是互相踩脚。还有一个隐含的共享项共享库里的全局变量和静态变量。很多人写 C 代码时在.c文件里定义一个static int counter单线程测试没问题上了多线程直接数据竞争。这类 bug 特别隐蔽因为编译器不会报错跑一百次可能只错一次。2.2 线程栈的大小陷阱与 TLS 的实际开销pthread_create创建线程时如果不指定栈大小用的是默认值。这个默认值在 glibc 上取自RLIMIT_STACK通常ulimit -s显示 8192也就是 8MB。听起来很多但你要知道这个 8MB 是虚拟地址空间预留不是一创建就占 8MB 物理内存实际用到多少才分配多少。可即便如此几百个线程就是几个 GB 的虚拟地址空间而且每个线程栈在内核里至少占两个 VMA一个栈本体一个保护页会消耗vm.max_map_count。vm.max_map_count的默认值一般是 65530。一个线程两个 VMA再加上程序本身的映射理论上限就摆在那儿。线程开太多之后pthread_create返回EAGAIN很多人以为是内存不够实际是 VMA 数量打满了。cat /proc/sys/vm/max_map_count看一眼心里有个数。线程栈大小的调整有讲究。栈太小会栈溢出而且这种崩溃往往表现为莫名其妙的SIGSEGVgdb里看栈帧全是乱的。栈太大浪费地址空间。我的经验值是普通业务线程 512KB 到 1MB 足够递归深度大的算法线程按需放大不要图省事一律用默认 8MB。设置方法pthread_attr_t attr; pthread_attr_init(attr); pthread_attr_setstacksize(attr, 512 * 1024); /* 512KB */ pthread_t tid; pthread_create(tid, attr, worker, arg); pthread_attr_destroy(attr);如果确实需要更小的栈比如几万个协程级别的并发可以自己mmap一块内存然后用pthread_attr_setstack指定但要自己处理 guard page不然栈溢出会直接踩到相邻内存问题更难查。TLS线程局部存储的成本也值得说一下。__thread修饰的变量在每个线程里有一份副本访问它需要经过 TLS 模型寻址。-ftls-model有几个选项默认的global-dynamic走的是最通用的路径性能不是最优。如果变量只在可执行文件内部用加-ftls-modelinitial-exec能省掉一层间接寻址。这个优化在热路径上能看出差别但别为了这点性能把代码搞复杂先测量再优化。2.3 进程组、会话与控制终端的关系这部分内容平时用得少但在写守护进程、处理终端信号比如CtrlC的时候绕不开。进程组process group是一组进程的集合有个组长进程组 ID 等于组长 PID。会话session是进程组的集合一个会话最多有一个控制终端。终端里敲CtrlC终端驱动会给前台进程组的所有进程发SIGINT。所以你在 shell 里跑一个管道a | b | c这三个进程通常被放进同一个进程组一按 CtrlC 全部收到信号。SIGTSTPCtrlZ、SIGQUITCtrl\同理。守护进程化的经典步骤里setsid()就是用来脱离控制终端的调用之后当前进程成为新会话的首进程、新进程组的组长不再有控制终端。这样它就不会因为终端关闭而收到SIGHUP。完整做法通常还要fork两次、chdir(/)、重定向标准输入输出到/dev/null每一步都有明确目的不是玄学仪式pid_t pid fork(); if (pid 0) exit(0); /* 父进程退出子进程被 init 收养 */ setsid(); /* 脱离控制终端成为新会话首进程 */ pid fork(); if (pid 0) exit(0); /* 第二次 fork确保不是会话首进程永远拿不到终端 */ chdir(/); umask(0); /* 关闭并重定向 0/1/2 到 /dev/null */第二次fork的原因很多人不清楚会话首进程有资格重新获得控制终端通过打开一个终端设备而普通进程没有这个资格。多做一次 fork 就把这个资格彻底断掉避免程序哪天不小心打开/dev/tty又被终端信号影响。3. 上下文切换的真实成本与观测手段3.1 用户态切换和内核态切换分别贵在哪“线程切换比进程切换快”这句话是对的但要知道快在哪否则很容易用错地方。进程切换的核心开销在于地址空间切换要把 CPU 的页表基址寄存器x86 上是 CR3换成新进程的随之而来的是 TLB 里缓存的虚拟地址到物理地址映射大面积失效。TLB 一失效后续访问内存都要走多级页表慢几倍到几十倍。现代 CPU 有 PCID 特性可以缓解给 TLB 项打标签不全部刷掉Linux 也会尽量复用地址空间但成本依然存在。线程切换不用换页表因为同一个进程内的线程共享地址空间CR3 不用动TLB 也不失效。剩下要做的就是保存/恢复寄存器、切换内核栈、更新调度信息。这部分开销在几微秒的量级。但真正的隐性成本是cache 污染。线程 A 正在跑它的热数据在 L1/L2 缓存里切到线程 BB 的数据要重新从内存搬进缓存A 的数据被挤出去。等切回 A又得重来一遍。如果两个线程的工作集加起来超过 LLC 容量这种反复冲刷的开销可能远大于切换本身。所以有一条实用结论不要在同一个核上放太多计算密集的线程切换省下来的等待时间抵不过缓存失效的损失。反过来说IO 密集场景下切换反而是好事因为线程阻塞时立刻让出 CPU 给别的活干吞吐能上去。还有一类“切换”被很多人忽略用户态的协程切换。它完全在用户空间完成只保存少量寄存器成本大概是几百纳秒比内核线程切换低一个数量级。代价是调度器要自己写、阻塞调用要自己包、多核利用要自己设计。选择它的判断标准很简单并发连接数上万、单连接工作量小、且以 IO 为主这时候协程划算如果是计算密集型、需要真并行老老实实用线程。3.2 用 pidstat、perf 和 /proc 把切换次数看清楚线上遇到“CPU 不高但吞吐上不去”的情况第一件事就是看上下文切换。工具有几个层次从粗到细。最粗的是vmstat 1cs列就是每秒上下文切换总数包括进程和线程。这个数只能看个趋势判断不了是谁在切。$ vmstat 1 3 procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu----- r b swpd free buff cache si so bi bo in cs us sy id wa st 8 0 0 812345 12345 456789 0 0 12 34 890 15230 45 8 47 0 0cs每秒一万五配合r列运行队列长度8说明 CPU 在多个可运行任务之间频繁轮转可能是线程数开太多也可能是锁竞争激烈导致频繁阻塞唤醒。中间层次是pidstat -w -p pid 1直接按进程给出每秒的自愿切换cswch/s和非自愿切换nvcswch/s。自愿切换是线程主动让出 CPU比如等锁、等 IO、sleep非自愿切换是被抢占时间片用完或者被更高优先级顶掉。这两个指标的含义完全不同自愿切换高说明线程在等资源去看看锁和 IO。等锁多的线程池加线程没用得减少临界区。非自愿切换高说明 CPU 不够分线程数超过核数了或者有高优先级任务在抢。$ pidstat -w -p 12345 1 5 Average: UID PID cswch/s nvcswch/s Command Average: 1000 12345 8520.00 120.00 server再往细走就是直接读/proc/pid/status$ grep ctxt /proc/12345/status voluntary_ctxt_switches: 1234567 nonvoluntary_ctxt_switches: 8901这两个是累计值隔一秒读两次做差就是速率。脚本化监控很方便不用装额外工具。最细的是perf可以定位到具体函数$ perf stat -e context-switches,cpu-migrations,page-faults -p 12345 sleep 5 $ perf record -e sched:sched_switch -p 12345 -g -- sleep 3 $ perf sched latencyperf sched那一套能把“谁切给了谁、切了多久、被谁唤醒”全画出来排查诡异延迟的时候非常好用。缺点是学习曲线陡而且内核要开相应选项。3.3 线程数到底开多少一个有据可循的经验公式这是被问得最多的问题也是最容易拍脑袋的地方。先给结论不存在普适的最优线程数但存在一个能用测量修正的估算公式。对于混合型负载一个经典估算是线程数 核数 × 目标CPU利用率 × (1 平均等待时间 / 平均计算时间)举个例子8 核机器希望 CPU 利用率跑到 80%每个请求平均计算 20ms、等待 IO 80ms那等待/计算 4代入得 8 × 0.8 × 5 32 个线程。这个数字显然比 8 大得多因为大部分时间线程在等多开几个才能把 CPU 喂饱。但这个公式只是起点实际要拿测量去修正。做法是从估算值的一半开始逐步加线程同时盯着三个指标——吞吐QPS、P99 延迟、上下文切换速率。吞吐还在涨、延迟平稳就继续加吞吐不涨了或者延迟开始抖就停。通常会出现一个明显的拐点过了拐点再加线程纯粹是在增加切换开销。反面案例也要说。CPU 密集型任务线程数超过核数几乎一定是负收益。我见过一个做图像处理的模块为了“提高并行度”开了 64 个线程在 16 核机器上跑结果比 16 个线程慢了接近一倍vmstat里cs高得吓人perf显示大量时间花在futex和调度器上。改成核数 1之后性能立刻回来了。那个 1 是为了在某个线程偶尔缺页或做系统调用时还有活干属于保险量。还有一种情况要特别注意线程数要跟下游资源匹配。数据库连接池只有 20 个连接你开 200 个线程去抢只会让 180 个线程在锁上排队还不如开 20 个线程把活干完。这类“上游并发数远超下游容量”的设计表面上有并发实际全是排队和切换。4. 多线程编程的实操从创建到回收的正确姿势4.1 创建、传参、join、detach每一步都有坑pthread_create的签名看着简单但传参这一关就绊倒过不少人。第四个参数是void *很多人图省事直接传一个栈上变量的地址/* 错误示范 */ for (int i 0; i 4; i) { pthread_create(tid[i], NULL, worker, i); /* i 的地址循环结束就没了 */ }这段代码的后果是四个线程拿到的是同一块内存读到的值取决于调度时序大概率全是 4 或者 -1。正确做法是给每个线程一份独立的数据用malloc分配或者用数组typedef struct { int id; int input_fd; long processed; } worker_arg_t; worker_arg_t args[4]; for (int i 0; i 4; i) { args[i].id i; args[i].input_fd -1; args[i].processed 0; pthread_create(tid[i], NULL, worker, args[i]); }更大的坑在返回值。pthread_create成功返回 0失败返回错误码它不设置errno。所以判断失败不能写if (ret -1)也不能perror得直接用返回值。这一点跟大部分 POSIX 接口相反我第一次遇到时排查了好久perror打出来的信息牛头不对马嘴。pthread_join的行为也要说清楚。它阻塞等待目标线程结束并回收其资源栈、TLS 等。如果线程是 joinable 的默认且一直没人 join线程结束后资源不会被释放这就是所谓的“僵尸线程”跑久了内存和 VMA 一路涨。解决办法二选一要么确保每个线程都有对应的join要么在创建后立刻pthread_detach(tid)让它自动回收。pthread_t tid; pthread_create(tid, NULL, worker, arg); pthread_detach(tid); /* 之后不能再 join */对于长期运行的服务线程池是更稳妥的模型固定数量线程常驻任务队列分发。这样既避免了频繁创建销毁的开销一次线程创建大概几十微秒也天然控制了线程总数。写线程池要注意队列的关闭语义主线程要能通知所有工作线程“没活了退出”通常是用一个shutdown标志加条件变量广播。4.2 互斥锁、条件变量、读写锁选错就白干pthread_mutex是基础但它的性能特征值得说清楚。在没有竞争的时候pthread_mutex_lock基本就是用户态一条原子指令x86 上是cmpxchg成本极低几十纳秒。只有真的抢不到锁才会进内核走futex睡眠。所以“加锁一定慢”是错的无竞争的锁几乎不要钱慢的是竞争。这就引出一个设计原则缩小临界区但别缩到把一次操作拆成多次加锁。前者减少冲突后者增加冲突还可能破坏原子性。我见过把counter拆成“读锁取值、解锁、计算、再加锁写回”的代码等于没加锁还多付了四次加锁开销。pthread_cond_wait有一个必须记住的细节它内部会原子地释放锁并进入等待被唤醒时重新获取锁所以调用前必须持有锁而且醒来后必须用while重新检查条件不能用ifpthread_mutex_lock(mtx); while (queue_empty(q) !shutdown) { pthread_cond_wait(not_empty, mtx); } if (shutdown) { pthread_mutex_unlock(mtx); return NULL; } item queue_pop(q); pthread_mutex_unlock(mtx);用if的问题是虚假唤醒spurious wakeup条件变量可能在没有任何人 signal 的情况下返回POSIX 明确允许这种行为。用while至少保证条件不满足时继续等。这个坑很经典但每年还是有人踩。pthread_rwlock适用于“读多写少”的场景听起来很美实际用起来要小心。如果读操作本身很短读写锁的额外记账开销可能超过它带来的收益更糟的是某些实现下读锁的获取也需要原子操作多个读线程会在这个原子变量上产生缓存行乒乓反而比加普通互斥锁还慢。我的判断标准是临界区里如果只有几条指令别用读写锁用普通锁甚至无锁原子操作只有临界区里确实有较重的操作比如遍历一个上千项的链表且读远多于写读写锁才划算。还有一点常被忽视锁的粒度要和数据布局配合。如果两个不相干的数据结构共用一把锁它们就被无谓地串行化了。如果两个频繁一起访问的数据分别用两把锁又容易死锁。合理做法是按业务不变量划分锁一个不变量一把锁每次加锁只保护一个不变量。4.3 多线程程序里 fork、信号处理和线程取消的三重陷阱fork()在多线程程序里的行为是这门课里最反直觉的部分。POSIX 规定fork()之后子进程只保留调用fork的那个线程其他线程全部消失。听起来合理但问题在于其他线程持有的锁在子进程里永远不会被释放。如果某个线程在 fork 的瞬间正持有malloc的锁子进程里再调malloc就直接死锁。这就是为什么在可能调用fork的多线程程序里有一条铁律子进程在execve之前只能调用异步信号安全的函数。malloc、printf、pthread_mutex_lock全都不在其中。安全的做法是 fork 完立刻 execpid_t pid fork(); if (pid 0) { /* 只做 exec 相关的准备dup2、close、execve */ execve(/path/to/prog, argv, envp); _exit(127); /* exec 失败用 _exit 不用 exit */ }_exit和exit的区别在于前者不跑 atexit 回调、不刷 stdio 缓冲后者可能碰到锁。这种细节平时无所谓在 fork 后的子进程里就是生死之别。如果确实需要在子进程里跑逻辑可以用pthread_atfork注册三个回调preparefork 前调用用来把所有锁都拿到手、parent父进程恢复、child子进程恢复。但说实话这套机制很难写对锁一多就容易漏或者顺序错。我的建议是能不用就不用实在要用就把“fork 后要用的状态”全部设计成无锁的。信号处理在多线程里也是个大坑。信号是发给进程的但具体由哪个线程处理是不确定的——内核会挑一个没有屏蔽该信号的线程。这导致两个问题一是不确定性二是如果某个线程恰好持有关键锁时被信号打断处理函数里再去拿同一把锁就死锁。成熟的方案是集中处理所有线程都屏蔽关心的信号专门开一个线程用sigwait同步地等信号sigset_t set; sigemptyset(set); sigaddset(set, SIGTERM); sigaddset(set, SIGINT); pthread_sigmask(SIG_BLOCK, set, NULL); /* 主线程先屏蔽新线程继承 */ /* 专门的处理线程 */ int sig; while (1) { if (sigwait(set, sig) 0) { if (sig SIGTERM || sig SIGINT) break; } }这样信号处理就变成了普通的事件循环不用再担心异步安全性退出流程也能写得干净。线程取消pthread_cancel建议能不用就不用。它有两种模式延迟取消默认只在特定的取消点比如read、sleep、pthread_cond_wait生效和异步取消随时可能中断极度危险。延迟取消听着安全但取消点位置不可控线程可能正好在分配了一半内存的状态下被杀留下一堆半成品数据。更麻烦的是清理工作必须用pthread_cleanup_push/pop注册清理函数而且这两个宏必须成对出现在同一个作用域中间不能有return否则编译报错或者行为诡异。相比之下用一个volatile的停止标志加条件变量通知让线程自己走到安全点退出可维护性高得多。5. 进程间通信与线程间通信的取舍5.1 管道、共享内存、消息队列、socket 的适用边界这部分的选择经常被“性能至上”的直觉带偏实际上要综合考虑的不只是速度。先给一个对照表方式跨机器数据量级同步语义典型场景匿名管道否小到中阻塞读写自带流控父子进程、shell 管道命名管道 FIFO否小到中同上无亲缘关系的本地进程消息队列否小每条有上限按消息边界控制指令、状态通知共享内存否大无上限无需自己加锁大数据块、图像、共享缓存本地 socket否中到大双向语义清晰结构化请求响应TCP socket是中到大双向可靠跨机器、需要解耦共享内存是本地通信里最快的因为数据从用户态写进去、另一个进程直接读中间没有内核拷贝。但它本身不提供任何同步必须配信号量或者互斥锁。很多人第一次用共享内存时会发现读到半新半旧的数据就是忘了同步。本地 socketAF_UNIX的速度常被低估。它在同一台机器上走的是内核里的缓冲区不经过网络协议栈实测吞吐能到几个 GB/s比 TCP 回环还快一些。优势是 API 统一、天然支持双向和连接管理、有清晰的读写边界。如果不是对着极限性能压榨本地 socket 往往是更省心的选择。选型的一个实用判断优先考虑可维护性性能不够了再换。管道和 socket 都有现成的阻塞语义、错误处理、进程退出时的自动清理对端关闭会返回 0 或EPIPE共享内存这些全要自己做谁创建、谁删除、进程崩溃了内存段残留怎么清理、大小不够怎么扩容。这些都是实打实的维护成本。5.2 共享内存加信号量的实操细节真要用共享内存POSIX 接口比 System V 那套清爽得多。一个最小可用的例子/* 写端 */ #include fcntl.h #include sys/mman.h #include semaphore.h #include string.h #include unistd.h typedef struct { sem_t lock; int ready; char payload[4096]; } shm_block_t; int fd shm_open(/demo_shm, O_CREAT | O_RDWR, 0600); ftruncate(fd, sizeof(shm_block_t)); shm_block_t *blk mmap(NULL, sizeof(shm_block_t), PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); close(fd); /* 只在创建者里初始化一次 */ sem_init(blk-lock, 1, 1); /* 第二个参数 1 表示进程间共享 */ sem_wait(blk-lock); memcpy(blk-payload, hello, 6); blk-ready 1; sem_post(blk-lock);几个必须注意的点。第一sem_init的第二个参数必须传 1否则信号量只在单进程内有效跨进程用会出问题且不报错。这个错误极其隐蔽。第二初始化只能做一次。如果两个进程同时shm_open然后都sem_init信号量的值会被重置成 1之前所有的等待状态全丢。标准做法是让创建者用一个标志位或者创建时的O_EXCL判断只有真正创建成功的那个进程负责初始化。生产环境更稳的做法是用sem_open创建命名信号量配合O_CREAT | O_EXCL由内核保证只初始化一次。第三共享内存里的指针是无效的。因为不同进程mmap到的虚拟地址不一样你在里面存一个指针另一个进程拿来用必然崩溃。所以共享结构里的所有引用必须用偏移量表示而不是地址。这是新手最容易犯的错。第四清理是个大问题。shm_open创建的对象在内核里是有生命周期的进程退出不会自动删除必须有人调shm_unlink。如果程序崩溃了/dev/shm/下面就会留一堆垃圾文件。建议在启动时用shm_unlink先清一次忽略ENOENT退出时再清一次做好幂等。第五MAP_SHARED和MAP_PRIVATE别写错。前者才是在进程间共享后者是私有副本用了MAP_PRIVATE你会发现数据根本传不过去。6. 常见问题与排查实录6.1 一份可以直接照着查的故障速查表下面这些是我这些年实际遇到过、并且不止一次遇到的典型问题整理成表格方便对号入座。现象最可能的原因排查手段处理方式pthread_create返回EAGAINVMA 数量或线程数达到上限cat /proc/sys/vm/max_map_count、cat /proc/sys/kernel/threads-max减小线程栈、减少线程数、适当调大限制线程跑了但内存一直涨线程 joinable 但没 join/proc/pid/status看Threads与VmSize加pthread_detach或补joinCPU 不高但吞吐上不去锁竞争或线程数过多pidstat -w、perf record -e sched:sched_switch缩小临界区、减线程、改用无锁结构多线程程序 fork 后卡死子进程继承了别的线程持有的锁gdbattach 子进程看栈fork 后立刻 exec或用pthread_atfork偶发数据错乱共享变量没有同步ThreadSanitizer-fsanitizethread加锁或用原子操作性能随线程数线性下降到某个点后崩超过物理核数导致缓存抖动perf stat看cache-misses、cs线程数收到核数量级信号处理里偶尔死锁异步信号安全性被破坏审查处理函数里调用的每个函数改sigwait集中处理共享内存读到半截数据忘了同步加日志打时间戳补信号量或改用 socketThreadSanitizer 值得单独提一句。它是编译器插桩工具gcc -fsanitizethread -g编译一下跑起来只要有两个线程没加锁访问同一块内存它立刻报出来还告诉你两次访问分别在哪一行、由哪个线程发起。相比靠肉眼找竞争效率高太多。代价是程序跑得慢几倍到十几倍、内存占用高所以只在测试环境用别上生产。但它确实能抓到那些靠压测跑一万次才偶发一次的 bug值回票价。6.2 几个只有踩过才知道的细节第一个是伪共享。两个线程分别改同一个结构体里的两个相邻变量比如struct { long a; long b; }线程 1 改a、线程 2 改b。逻辑上毫无关系但它们在同一个 64 字节的 cache line 里。按 MESI 协议一个核要写这个 cache line其他核的副本必须失效于是这条 line 在两个核之间来回弹性能可能差十倍。解决办法是加 padding 把两个变量隔到不同的 cache linestruct { long a; char pad[64 - sizeof(long)]; long b; } __attribute__((aligned(64)));这个问题在计数器数组上特别常见多个线程各自累加数组不同下标的元素看着没有竞争实际全挤在相邻的 cache line 上。第二个是乐观读的陷阱。用__atomic_load_n(flag, __ATOMIC_RELAXED)读一个标志位判断“数据准备好了没”如果只用了RELAXED而没有配套的ACQUIRE语义读到flag 1之后去读数据可能读到还没写完的旧值。这是 CPU 乱序执行和编译器重排导致的。正确做法是写端用__ATOMIC_RELEASE存标志读端用__ATOMIC_ACQUIRE读标志。这个细节在 x86 上可能测不出来x86 内存模型比较强换到 ARM 上立刻现形。跨平台代码一定要按标准写。第三个是线程数与文件描述符的联动。每个线程如果都去accept连接、open文件fd 消耗是按线程叠加的。ulimit -n是进程级限制所有线程共享这个额度。有的服务线程开了一百个每个线程缓存了十几个 fd跑着跑着就EMFILE而且报错的位置五花八门很难一眼看出是 fd 不够。最后一个是用strace -f观察多线程程序的系统调用。-f会跟随所有线程包括clone出来的输出里能看到每个线程的系统调用序列排查“到底哪个线程在不停futex”“哪个线程卡在read上”特别有用。缺点是输出量巨大最好配合-e trace过滤只留下关心的那几类。7. 一点个人体会整套机制捋下来我最大的感受是进程和线程的分野不是概念上的而是资源边界上的。你在设计一个多进程还是多线程的架构本质上是在回答“哪些东西必须共享、哪些必须隔离”。数据要共享、需要低延迟访问同一块内存就用线程然后承担锁和数据竞争的代价。模块之间要隔离、希望一个崩了不影响另一个就用进程然后承担序列化和通信的代价。没有免费的那一边。还有个习惯我觉得挺值钱遇到并发问题先怀疑数据共享再怀疑调度。绝大多数“多线程跑飞了”的 bug根子都在某块内存被两个线程同时碰了而不是内核调度器抽风。工具上先上 ThreadSanitizer再上perf最后才去翻内核参数这个顺序能省掉大量无用的排查。至于线程池开多大我现在基本不看公式了直接用一组压测数据说话从 4 个线程开始每次翻倍记录 QPS 和 P99画出曲线拐点位置就是答案。不同机器的拐点不一样同一个服务换了依赖库拐点也会变所以这个数字是要定期复测的不是一劳永逸的配置项。这件事花半天做一次比事后花一周排查性能问题划算得多。

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

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

免费获取报价 →
↑