提到 Linux 多任务并发处理,不少人的第一反应是:并发不够,线程来凑。我在不少线上环境里都见过类似操作,业务方把服务的工作线程数从 8 调到 128,重启之后负载不降反升,响应延迟反而更高。后来仔细看代码才发现,业务锁竞争才是瓶颈,线程一多,CPU 全消耗在互相等待和上下文切换上,真正干活的时间少得可怜。多任务处理的难点从来不是“能启动多少个任务”,而是清楚每个任务消耗什么资源,执行单元之间怎么协作,以及 Linux 内核到底怎么调度它们。
这篇文章里,我会把 Linux 下多任务并发处理这件事拆开讲清楚。不需要你已经精通内核原理,但如果你要改造一个现有服务、设计一个并发模块,或者正在排查线上抖动问题,这里面的思路可以直接落到你手头的环境里。内容会围绕六个阶段展开:任务怎么划分、线程池怎么写、共享数据怎么加锁、CPU 调度怎么做、高并发 IO 怎么选模型、线上异常怎么排查。
1. 任务划分:并发执行前必须完成的决策
很多并发问题的根源不在实现阶段,而在任务划分阶段。你把一个任务拆成进程、线程还是协程,决定了后续所有代码的复杂度和性能上限。
1.1 先理解 Linux 的执行单元和调度对象
先澄清一个常见误区:进程不是 Linux 内核的最小调度单位,线程才是。对于一个多线程进程来说,进程更像是资源容器,它持有地址空间、文件描述符、信号处理器这些公共资源;而线程是真正排队等待 CPU 时间片的执行流。每个线程都有自己的内核栈、寄存器上下文和调度策略,内核调度器按照线程来分配 CPU 时间。
这意味着,你服务的“进程数”并不直接等于并发能力。真正参与并发的是进程里跑着多少个线程,以及这些线程是否同时处于可运行状态。用ps -eLf能看出进程和线程的关系,第一列是 PID,第二列 LWP 是线程 ID,同一个 PID 下的多行就是同一个进程内的不同线程。
那协程呢?协程是用户态自己管理的执行流,内核完全不知道它的存在。协程之间的切换只发生在进程内部,不需要陷入内核做调度,所以切换开销很小。但代价是:协程缺乏抢占能力,必须依赖代码主动让出控制权,而且一个 CPU 核上同一时刻真正在跑的还是一个协程。如果某个协程里写了死循环或者长时间阻塞调用,同线程内的其他协程都会被卡住。
1.2 按资源类型决定任务模型,别只看吞吐量
我在选择任务模型时,第一件事不是看框架,而是看任务的资源画像。核心区分是 CPU 密集、IO 密集和延迟敏感型。
| 任务特征 | 并发模型倾向 | 数量参考 | 主要瓶颈 |
|---|---|---|---|
| CPU 密集(计算、压缩、加密) | 进程 / 线程 | 接近可用核数,可额外加1 | CPU 时间片和缓存争抢 |
| IO 密集(网络请求、磁盘读写) | 线程 + 异步 IO / 协程 | 可超出核数数倍,取决于等待比 | 等待时间和阻塞点 |
| 延迟敏感(实时响应类) | 专用线程 + CPU 绑定 | 独占或少量 | 调度延迟和抢占 |
CPU 密集任务开 128 个线程,在 8 核机器上通常只会更慢。因为每个核要不停地在多个线程之间切换,线程调度本身要保存和恢复寄存器、更新调度队列、清空一部分缓存状态,这些成本会直接吃掉计算时间。
IO 密集任务则相反。线程发起一次网络请求,在等待响应的几毫秒到几百毫秒内 CPU 几乎是空闲的,这时候多开一些线程能让其他任务填上这段空闲时间。但也不能无限开,每个线程默认栈占用空间不小,线程一多内存先报警,而且线程切换成本也在累积。
我自己的经验是:先把任务按 CPU 计算和 IO 等待的比例拆开,再根据这个比例选模型。计算占比高就老老实实控制线程数量,等待占比高才去考虑大规模并发和异步化。先接受一个朴素的结论:并发量高不等于性能好,只有让 CPU 尽量持续干有效活,性能才是真的好。
2. 动手实现线程池前,先想清这几个问题
线程池可以说是 Linux 并发程序设计里最通用的骨架。它解决的核心问题是:避免频繁创建和销毁线程,同时限制线程总数不超过系统能承受的范围。
2.1 为什么我倾向于用线程池而不是并发临时线程
直接为每个任务创建一个线程,代码写起来最简单,任务结束就pthread_join。但实际运行中这种方式有两个隐藏成本:每次创建线程都要做系统调用、分配内核栈、初始化调度实体,耗时可能在几十微秒到几百微秒;任务量大了以后,并发线程数无法控制,系统负载会直线上升。
线程池的思路是提前创建一组固定数量的线程,让它们循环从任务队列里取活干。创建线程的成本只产生一次,后续任务到达时只需要往队列里塞一个数据项,然后唤醒一个空闲线程。线程数量可控,也更容易配合 CPU 核数做资源规划。
如果你是写 C 或 C++,POSIX 线程接口下通常不会自带线程池,需要自己封装。核心组件有三个:一个任务队列、一组 worker 线程、以及负责同步的互斥锁和条件变量。
2.2 一个最小可用的 C 线程池实现骨架
这里给出一个精简但能直接运行的思路。任务结构体保存函数指针和参数,全局队列保存待执行任务,每个 worker 线程不断尝试从队列里取任务。
#include <pthread.h> #include <stdio.h> #include <stdlib.h> typedef struct work_unit { void (*fn)(void *); void *arg; struct work_unit *next; } work_unit; static pthread_mutex_t pool_lock = PTHREAD_MUTEX_INITIALIZER; static pthread_cond_t pool_cond = PTHREAD_COND_INITIALIZER; static work_unit *task_queue = NULL; void submit_task(void (*fn)(void *), void *arg) { work_unit *unit = malloc(sizeof(work_unit)); unit->fn = fn; unit->arg = arg; unit->next = NULL; pthread_mutex_lock(&pool_lock); unit->next = task_queue; task_queue = unit; pthread_cond_signal(&pool_cond); pthread_mutex_unlock(&pool_lock); } void *worker_thread(void *arg) { (void)arg; for (;;) { pthread_mutex_lock(&pool_lock); while (task_queue == NULL) { pthread_cond_wait(&pool_cond, &pool_lock); } work_unit *unit = task_queue; task_queue = unit->next; pthread_mutex_unlock(&pool_lock); unit->fn(unit->arg); free(unit); } return NULL; } void thread_pool_init(int threads_count) { pthread_t tid; for (int i = 0; i < threads_count; i++) { pthread_create(&tid, NULL, worker_thread, NULL); pthread_detach(tid); } }这个骨架有一个明显问题:队列操作全部靠一把全局锁,任务量很大时锁竞争会成为瓶颈。但它作为第一版是对的。先把功能跑通,再用性能工具确认锁开销确实高,然后才值得做无锁队列或分片队列。直接上来写无锁队列,多数情况下只会收获一个更难排查的 bug。
2.3 worker 数量不能拍脑袋,要结合核数和阻塞时间估算
线程池设多少个线程,取决于任务类型。纯 CPU 任务,我一般设为可用核数或者核数 +1,+1 是为了处理少量由于缺页中断、系统调用造成的短暂停顿。如果任务里有明显 IO 等待,可以用一个简单估算:期望吞吐量乘以每个任务的 IO 等待时间,再除以单线程实际工作窗口,就能得到一个理论线程数。
举个例子,某个任务平均耗时为 50ms,其中 40ms 在网络等待上,单线程 1 秒内最多处理 20 个任务;如果业务要求每秒 200 个任务,就需要大约 10 个线程才能补齐等待空档。当然这是理想模型,实际还要考虑内存、锁竞争和 GC,但这个公式能让你在第一版就有个合理范围,而不是凭感觉从 64 开始试。
线程池加任务队列这套组合,至今仍然是多数通用业务系统的主干。它的好处是并发控制直观:任务进来丢队列,worker 拿任务、执行、再拿下一个,整个生命周期可控可观测。
3. 共享状态下的加锁策略:从互斥锁到原子操作踩过的坑
并发程序一旦涉及多个执行流访问同一份数据,就必须讨论同步。很多人以为只要是同一个线程池里的任务就不需要锁,这是最容易踩的坑。
3.1 竞争条件比你想的更隐蔽
两个线程同时对一个变量执行value++,看起来是一条语句,但底层可能是加载、加一、写回三步。线程 A 读取 value 为 10,线程 B 也读取为 10,然后各自写回 11,最终结果丢了一次更新。这个现象在低并发、低概率下不容易暴露,一旦线程数上来,数据错乱的症状就开始出现,而且错误往往出现在最不能出错的地方。
锁的作用就是把“检查-修改-写回”这个复合操作变成原子临界区。但锁本身有代价:获取锁可能阻塞线程、唤醒阻塞线程涉及内核调度、还会造成缓存行在不同核心之间反复失效。所以高并发场景的优化方向,永远是先减少锁的持有时间,再考虑能不能去掉锁。
3.2 互斥锁和条件变量是正确解决问题的地基
线程池里最常见的同步组合是互斥锁保护队列,条件变量负责唤醒等待任务的线程。写法上有几个细节容易出错。
第一,条件变量等待必须放在循环里,不能只判断一次。
pthread_mutex_lock(&pool_lock); while (task_queue == NULL) { pthread_cond_wait(&pool_cond, &pool_lock); }原因是存在“虚假唤醒”。线程被唤醒后,队列可能已经被其他线程抢空了,必须重新检查条件。
第二,发送唤醒信号要在持有锁的状态下做。有人为了微优化把pthread_cond_signal放到解锁之后,看起来没毛病,但可能造成唤醒发生在等待线程检查条件之前,导致一次唤醒丢失。直接把 signal 放在解锁之前,简单且安全,性能损失完全可以忽略。
第三,临界区要尽可能短。锁里面只做队列指针的摘除和插入,不要做业务计算,也不要分配内存。把不必要的工作放进临界区,并发度会立刻下降。
3.3 什么时候用自旋锁,什么时候用互斥锁
自旋锁不会让出 CPU,而是不停循环检查锁状态。临界区极短、等待时间很小时用自旋锁效率更高,省去了线程睡眠和唤醒的系统调用成本。但临界区稍微变长,自旋锁就会浪费 CPU,导致整个系统性能下降,甚至出现调度抖动。
我给你一个简单判断标准:临界区只有几条指令、通常不超过几百纳秒,优先用自旋锁;临界区中包含锁外调用、文件操作、网络 IO、内存分配,或者不确定,一律用互斥锁。把互斥锁换成自旋锁之前,先用perf或strace确认真的存在大量 futex 系统调用和线程切换,再动手改。
3.4 避免死锁的三个实用原则
死锁一旦出现,程序可能直接卡死,也可能只是某个线程卡住,线上故障定位极其痛苦。我见过并总结出来的规律有三条:加锁顺序必须全局一致;不重复持有同一把锁;尽量不用嵌套锁,如果一定需要,务必保证所有线程都按相同的 A->B 顺序加锁。
第三个原则是从真实事故里得来的。有一次排查一个线程池卡死问题,两个任务各持有一把锁,又都在等待对方持有的锁,形成经典循环等待。最后解决办法不是调整超时参数,而是把嵌套锁结构改成了单锁加业务状态机,既消除了死锁,也顺带优化了复杂度。
原子操作是在锁之外的另一条路。简单的计数器、状态标志可以用atomic_fetch_add这类原子指令;但复杂的数据结构并发读写,不要轻易尝试无锁方案。内存序问题、ABA 问题、伪共享,任何一个都足够让有经验的开发人员头疼很长时间。
4. 调度策略与 CPU 绑定:让多任务跑出确定性
同一个程序在不同机器上跑出不同效果,很多时候和代码无关,而是和调度策略有关。Linux 默认的 CFS 调度器追求公平,它会尽量让所有线程轮流使用 CPU。但对于并发任务来说,公平不一定等于高效。
4.1 为什么有时候需要绑核
线程在多个核之间迁移,会带来缓存失效和数据局部性丢失。一个线程上次在 CPU0 上运行,这次被调度到 CPU3,它之前访问的内存可能在 CPU0 的 L2 缓存里,换到 CPU3 后只能重新从内存读取,延迟明显增加。对延迟敏感、数据访问量大的任务,这种现象可能造成显著波动。
用taskset可以把某个进程或线程固定在特定 CPU 上。
taskset -pc 0,1 12345上面的命令把 PID 12345 的线程绑定到 CPU0 和 CPU1 两组上。更细的程序内部做法是调用sched_setaffinity系统调用。但绑核不是银弹。如果机器的可用核数不平衡,绑核可能让一个核忙死而其他核空闲,反而降低整体吞吐。我的使用原则是:只有在对延迟稳定性要求高的模块上绑核,普通后台任务不绑。
4.2 nice 值和实时调度的边界
Linux 调度器支持设置线程优先级,但普通线程只分 nice 值。-20 到 19 之间,值越小优先级越高。renice可以调整运行中进程的 nice 值:
renice -5 -p 12345nice 值能影响 CFS 调度器分配的时间片权重,但它不是硬实时,高 nice 值的线程不会绝对饿死,只是分配相对较少。
如果任务对响应时间要求非常高,可以考虑 SCHED_FIFO 或 SCHED_RR 实时调度策略。使用chrt可以提高优先级,但实时线程可能抢占系统的关键进程,包括内核维护线程。这类操作必须非常谨慎,一般只建议用在高精度定时任务或者有明确独占资源的场合,千万不要把普通业务线程批量改成实时优先级,否则系统最终会整体不稳定。
4.3 用 cgroup 限制整个服务的调度配额
单进程的策略调整只是针对一个线程或进程。如果想限制一整个服务能使用的 CPU 总量,最好用 cgroup 或者 systemd 服务配置。
systemd 服务文件里的CPUQuota控制服务最多占用的 CPU 时间比例:
[Service] CPUQuota=200%表示服务最多使用 2 个完整核心的量。这个限制是包含所有子线程的,比在代码里手工控制线程数更可靠。配合MemoryMax和TasksMax,可以防止并发任务把整台机器拖垮。
TasksMax这个参数容易被忽略。它限制服务内允许创建的线程总数。有些并发程序在异常场景下会疯狂创建线程,没有 TasksMax 限制时,单个服务的线程数可能涨到几千甚至上万,系统开始出现各种奇怪问题。加上限制后,最差情况下服务只是报错失败,而不会影响同机的其他服务。
5. 高并发连接的异步化:为什么我最终选择事件驱动而不是无限线程
线程池适合任务并发,但面对大量长连接和间断性 IO 时,每个连接占一个线程的模式很快就撑不住了。假设每连接平均空闲时间占 99%,线程大部分时间都在等待网络数据,这种浪费直接决定了系统无法扩展。
5.1 从阻塞 IO 到非阻塞 IO 再到 epoll
传统阻塞套接字上,一个线程调用read时,如果数据没到达,这个线程会一直睡在内核里。如果每个连接一个线程,系统能处理的连接数非常有限。select和poll可以把多个连接交给一个线程轮询,但每次调用都需要把整个监视列表从用户态拷贝到内核态,同时线性扫描所有 fd,连接数一多性能就下降。
epoll 的改进在于事件驱动。内核只在 fd 状态变化时通知用户程序,程序不需要反复轮询所有连接。创建监听、注册 fd、等待事件这三步足够清晰。
int epfd = epoll_create1(0); struct epoll_event ev; ev.events = EPOLLIN; ev.data.fd = client_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev); struct epoll_event events[64]; while (1) { int n = epoll_wait(epfd, events, 64, -1); for (int i = 0; i < n; i++) { if (events[i].events & EPOLLIN) { handle_readable(events[i].data.fd); } } }这个模型里,一个线程就能管理几千个连接。真正读数据时只要不阻塞,就可以高效运行。更复杂一点的做法是把事件循环和多线程结合起来:epoll 负责网络事件通知,收到事件后把任务交给线程池执行,网络层和业务层解耦,这是很多网络服务的常见架构。
5.2 边缘触发和水平触发的一个关键差异
epoll 支持水平触发和边缘触发。水平触发模式下,只要 fd 还有数据可读,它就会一直通知你;边缘触发模式下,fd 从不可读变成可读时只会通知一次,你必须一次性把数据读完,否则后续事件可能丢失。
边缘触发性能更高,也容易踩坑。正确的做法是在读取循环里一直读,直到返回EAGAIN,表示当前没有更多数据了,然后结束本轮读取。很多人第一次用边缘触发时没有处理EAGAIN,结果明明有数据却没收到通知,线上表现为消息延迟或丢包。我个人的建议是:如果是新项目,又不需要极致性能,就用默认的水平触发,代码好写,问题也少;确认性能瓶颈确实在网络事件通知效率上时,再切边缘触发并做好EAGAIN处理。
5.3 协程和异步库只是换了一种事件驱动封装
现在不少语言都提供协程或者类似虚拟线程的能力,底层原理其实就是把 epoll 或者 IO 多路复用封装起来,遇到阻塞 IO 时主动让出调度权,由框架管理大量等待中的任务。使用体验上,协程能让代码从“回调地狱”回到接近同步的写法人思维方式;但本质没有跳出事件驱动的逻辑,依然是没有数据时注册等待,有数据时恢复执行。
所以选型时不必纠结“用了协程就不用管 IO 模型”。你依然需要理解非阻塞 IO 和事件循环,否则遇到框架瓶颈时完全无从下手。比如某个线上的协程服务吞吐量不稳定,最终排查到原因是某个库在调用阻塞 IO 而没有让出控制权,这种问题如果不懂底层,纯靠调整参数很难解决。
6. 线上并发异常排查:线程数、负载和上下文切换如何辅助定位
并发程序故障的一大特点是触发条件苛刻,不稳定复现。但系统本身会留下很多痕迹,关键是知道该看什么指标。
6.1 先看 CPU 状态,再猜并发模型
接到一个性能问题,我一般先看top汇总数据。重点不是只看总的 CPU 占用,而是看每项 CPU 时间的分布。如果sys占比高,说明系统调用很频繁,大概率是线程上下文切换、锁竞争或频繁的 IO 操作;如果user占比高,说明业务代码确实在跑,这时需要继续看哪个函数耗 CPU。此时可以用top -H -p PID查看某进程下每个线程的 CPU 占用。某个线程长期占满一个核,往往是热点任务所在。
如果总负载很高,但每个核的空闲比例也高,那么多半有线程在频繁休眠和唤醒。结合上下文切换指标看会更准确:
pidstat -w -p PID 1 pidstat -w -p PID -t 1-t能显示线程级的切换统计。每列有自愿上下文切换和非自愿上下文切换数量。非自愿切换很多时,说明线程数量超过了 CPU 能承载的范围,调度器在强制抢占;自愿切换很多时,通常是锁等待或 IO 等待过快,需要进一步定位是哪种等待。
6.2 区分可运行队列和等待状态的本质
vmstat 1输出的r列表示当前等待 CPU 运行的线程数量,b列表示不可中断的阻塞线程数量。如果r值长期大于 CPU 核心数,说明任务排队了;b值高则说明大量线程卡在内核态等待,比如等待磁盘 IO。
再结合strace查看线程卡在哪个系统调用:
strace -f -p PID -e futex大量 futex 等待往往指向锁竞争。这时把线程栈打出来,看哪些线程互相等待资源,就能画出锁关系图。不要一上来就优化代码,先确定是锁问题还是 IO 问题,方向错了等于白干。
6.3 用资源限额防住最坏情况
并发程序上线前,我强烈建议顺手加上资源限额。就算本地测试没问题,生产环境的任务量一上来,很难保证线程数量、内存占用不会失控。
systemd 服务单元里的常用配置:
[Service] LimitNOFILE=1048576 TasksMax=1024 MemoryMax=4G CPUQuota=200%LimitNOFILE限制文件描述符数量,防止连接数过多导致 fd 耗尽;TasksMax限制线程总数;MemoryMax限制内存;CPUQuota限制 CPU 使用。这几个参数加完,即使代码里有并发失控,服务顶多报错或者被降级,不会把宿主机拖死。
6.4 复盘:一次线程池卡顿的定位流程
最后分享一个典型定位过程。某个服务突发高延迟,我先top看到负载从 3 涨到 20,但各核 CPU 使用率不高。然后pidstat -w -p PID 1发现自愿上下文切换数非常高,初步判断是在频繁加锁或频繁 IO 等待。接着用strace -f -p PID -e futex,大量线程卡在FUTEX_WAIT_PRIVATE,锁竞争嫌疑极大。再用perf top看到锁操作占比较高,最终定位到同一个业务锁被推到了所有线程的必经路径上。
解决思路不是单纯换更高性能的锁,而是重构代码,把大锁拆成细粒度锁,同时把串行化的热点路径改成无锁容器。改造后同一台机器吞吐翻了近一倍,这个结果充分说明:并发问题多数时候不是硬件不行,而是执行模型向下层资源妥协不够。
这些方法如果你自己走一遍,会发现大部分并发问题的共性非常强。只要会看 CPU 分布、上下文切换和线程等待状态,再配合本章里的基础工具,绝大多数问题都能找到明确方向,不需要玄学调参。