单机调度基础:进程、线程与协程的调度艺术

聊到分布式任务调度,很多人一上来就扎进复杂的集群架构里。但我个人的习惯是,先把这个基础打牢——单机调度。你想想看,如果连一台机器上的任务都调度不好,分布式环境只会把问题放大十倍。

今天我们就来拆解单机调度的核心:进程调度、线程调度、协程调度,以及操作系统里那些经典的调度策略。

进程调度:操作系统的最底层博弈

进程调度,说白了就是操作系统决定「下一个该谁用CPU」。这事看起来简单,但背后全是权衡。

我记得刚入行时,写了一个多进程的爬虫程序。在单核机器上跑,开了8个进程,结果CPU使用率才30%。我当时就懵了——后来才发现,进程切换的开销远比我以为的大。每次切换都要保存寄存器、内存映射、打开文件表……这些上下文信息,重得很。

进程调度的核心矛盾:

  • 公平性:每个进程都能分到CPU时间
  • 效率:尽量减少上下文切换的开销
  • 响应时间:交互式进程需要快速响应
  • 吞吐量:批处理任务希望单位时间内完成更多

这四个目标,很多时候是冲突的。比如追求公平性,频繁切换,吞吐量就下来了。追求吞吐量,让一个进程跑久一点,交互式应用就卡顿了。

线程调度:轻量级的进程

线程的出现,就是为了解决进程切换太重的问题。线程共享进程的地址空间,切换时只需要保存寄存器、栈指针这些轻量级信息。

我在做高并发网关时,曾经测试过进程和线程的切换开销。同样是100万次上下文切换,进程版本耗时约3.2秒,线程版本只有0.8秒。差距就是这么明显。

但线程也有自己的麻烦——共享内存带来的并发问题。你想想看,两个线程同时修改一个变量,不加锁的话,结果大概率不是你想要的。

我的经验:在I/O密集型的场景下,多线程优势明显。但在CPU密集型的计算任务中,线程数最好不要超过CPU核心数,否则切换开销反而会拖慢速度。

协程调度:用户态的终极武器

协程,很多人叫它「轻量级线程」。但我更愿意把它理解为「用户态调度的任务单元」。

为什么这么说?因为协程的调度权在用户程序手里,不在操作系统手里。这意味着什么?意味着你可以自己控制什么时候切换,切换的开销极小——只需要保存几个寄存器和栈指针。

我曾经在一个Go语言的项目中,用goroutine(Go的协程实现)同时处理了10万个WebSocket连接。如果用线程,光线程栈就要吃掉几十GB的内存。但goroutine的初始栈只有几KB,10万个协程加起来才几百MB。

注意:协程虽好,但有个前提——你的代码里不能有阻塞的系统调用。一旦调用了read()、write()这些阻塞函数,整个线程都会被挂起,协程也就失去了意义。所以协程通常配合异步I/O使用。

操作系统调度策略:CFS与时间片轮转

聊完了进程、线程、协程,我们来看看操作系统具体是怎么调度的。这里重点讲两个经典策略:时间片轮转和CFS。

时间片轮转(Round Robin)

这是最直观的调度算法。每个进程分到一个时间片,比如10毫秒。时间到了,就切换到下一个进程。循环往复。

听起来很公平对吧?但实际用起来问题不少。我曾经在一个嵌入式项目中用过这个策略,结果发现:如果时间片设得太短,切换开销占比太高;设得太长,交互式应用响应慢得像蜗牛。

时间片长度 优点 缺点
太短(< 5ms) 响应快 切换开销大,吞吐量低
适中(10-50ms) 平衡 需要根据场景调优
太长(> 100ms) 吞吐量高 交互式应用卡顿

CFS(完全公平调度)

Linux从2.6.23版本开始,默认调度器换成了CFS。它的思路很有意思——不是给每个进程固定时间片,而是维护一个「虚拟运行时间」的概念。

说白了,CFS会记录每个进程已经运行了多久(经过权重调整后的时间),然后每次都选虚拟运行时间最少的那个进程来执行。这样,所有进程的进度就基本一致了。

我调优过一个数据库服务,发现某些查询总是延迟很高。用perf一看,原来是CFS的调度延迟在作怪——当系统负载高时,每个进程分到的时间片会变得很短,频繁切换导致缓存命中率下降。后来我调整了sched_latency_ns参数,把调度延迟从6ms改到12ms,查询延迟反而降了30%。

CFS的关键参数:

  • sched_latency_ns:调度延迟目标,默认6ms
  • sched_min_granularity_ns:最小时间片,默认0.75ms
  • nice值:权重系数,范围-20到19,值越小权重越高

三种调度维度的对比

为了让你更直观地理解进程、线程、协程的调度差异,我画了一张对比图:

进程 / 线程 / 协程 调度对比 进程调度 调度者:操作系统内核 切换开销:高(~1-10μs) 独立地址空间 切换需保存: 寄存器、内存映射 打开文件表、信号等 适合:隔离性要求高的场景 线程调度 调度者:操作系统内核 切换开销:中(~0.1-1μs) 共享进程地址空间 切换需保存: 寄存器、栈指针 线程局部存储 适合:I/O密集型、多核并行 协程调度 调度者:用户程序 切换开销:极低(~0.01μs) 共享线程栈空间 切换需保存: 少量寄存器 栈指针 适合:高并发、异步编程 从左到右:调度开销递减,并发能力递增

实际项目中的选择建议

说了这么多理论,到底该怎么选?我根据实际经验给你几个参考:

  1. 计算密集型任务:用进程,数量不超过CPU核心数。每个进程独占一个核心,避免切换开销。
  2. I/O密集型任务:用协程,配合异步I/O。比如Go的goroutine + netpoll,或者Python的asyncio。
  3. 需要强隔离的场景:用进程。比如浏览器每个标签页一个进程,一个崩溃不影响其他。
  4. 需要共享大量数据:用线程。但要注意加锁,或者用无锁数据结构。

避坑指南:我曾经在一个项目中,用多线程处理网络请求,每个请求开一个线程。结果并发量一上来,线程数飙到几千,系统直接OOM了。后来改成协程+线程池,同样的机器,并发量提升了10倍。

嗯,单机调度这块,其实还有很多细节可以深挖。比如CFS的组调度、实时调度策略、NUMA架构下的调度优化……但今天这些基础,已经足够你理解后续的分布式调度了。

记住一句话:调度本质上是在做「资源分配」的决策。理解了这个本质,不管面对什么调度器,你都能快速上手。

可可精品资料库