向单线程 MCU 学习多任务管理

 

人脑只有一颗”单线程”的 CPU,却总被要求同时处理很多件事。与其假装自己能多线程运行,不如像单线程 MCU(微控制器)一样,把”同时运行”这件事分解成:任何时刻只有一个任务占用执行核心,其他任务只保存状态,等待被合适的事件重新拉回调度视野。


任务的四状态模型

MCU 里的任务调度器通常会把任务抽象成几个状态。借鉴过来,我把手头的事务分成四种状态:

             ┌──────────┐  完成   ┌──────┐
             │ RUNNING  │ ──────→ │ DONE │
             │ 正在处理 │         │ 归档 │
             └──────────┘         └──────┘
               ↑       ↓
         调度执行       无法继续
           ↑           ↓
   ┌──────────┐  ┌──────────┐    ┌──────────┐
   │  READY   │←→│ WAITING  │ ←→ │ SLEEPING │
   │随时可处理│  │等外部反馈│    │ 暂不处理 │
   └──────────┘  └──────────┘    └──────────┘

各状态之间的迁移时机会在下文的”中断”部分逐一展开,这里先给出概览:RUNNING 只有两个去向——完成(归档为 DONE)或无法继续(落入 WAITING);READY 中的任务被调度后成为 RUNNING;WAITING 中等待的条件满足后回到 READY;SLEEPING 则在定期检查时被唤醒,移入 READY 或 WAITING。

  • RUNNING:此刻真正正在做的事。只允许 1 个。这是单线程的核心约束——既然只有一个执行核心,就不要假装能同时做两件事。
  • READY:随时可以被执行的任务。万事俱备,只欠调度。
  • WAITING:已经启动,但正在等待外部输入的任务——等别人回复、等实验结果、等快递、等审稿意见。这些任务不需要占用注意力,但需要被记录,这样等到反馈到达时能被准确唤醒。
  • SLEEPING:今天不需要处理的任务。除了”三个月后要续费的域名”、”明年想参加的会议”这类远期事项,还包括那些概念上虽然在等外部输入、但好几天内都不可能有反馈的任务——既然 SLEEPING 每天都会被检查一遍,让它们躺在这里就够了,不必占用 WAITING 列表的注意力。

关键的区别在于:WAITING 和 SLEEPING 的任务不占用任何心智资源,它们只是躺在列表里保存状态。你不需要”记住还要等某个人的回复”,只需要信任列表。

中断:什么时候切换任务

MCU 不会漫无目的地轮询所有任务,它靠中断来触发调度。人脑也可以设计几类”中断”,来决定什么时候停下来看看列表、切换任务。

中断一:任务完成或无法继续

这是最自然的切换点:当前 RUNNING 的任务完成了,或者推进不下去了(比如需要等别人提供信息)。严格来说这在 MCU 里不叫中断,更像是任务主动让出 CPU,这里只是借用”中断”的说法作为统一的框架。

切换前,先把当前任务推进到一个可以保存状态的断点,然后按以下流程处理:

  1. 安置当前任务:如果已完成,直接从列表中移除、归档;如果无法继续,移入 WAITING 列表,并记一笔”在等什么”。
  2. 检查 WAITING 列表:有没有等待的条件已经满足的,可以移回 READY?
  3. 从 READY 中选一个任务,切换为 RUNNING。

中断二:Timer Interrupt

进入 RUNNING 状态一段时间(比如一小时)后触发一次检查:

  • WAITING 列表里有没有可以移入 READY 的?
  • READY 列表里有没有优先级明显更高的任务?如果有,考虑与当前 RUNNING 交换。

当然,人不必像 MCU 那样真的定一个闹钟。更”人类”的做法是把定时检查挂在身体的自然信号上:口渴了去接水、去一趟卫生间,顺便完成一次调度检查。起身活动本来就是该做的事,顺手巡检一下列表,成本几乎为零。

中断三:每日检查

每天选一个固定时间(比如早上刚开始工作或晚上收尾时),检查 SLEEPING 列表:有没有任务该被唤醒了?唤醒后根据情况移入 WAITING 或 READY。

WAITING 和 SLEEPING 的区别就在检查频率上体现:WAITING 里的任务随时可能有反馈,值得在每次任务切换和定时巡检时看一眼;SLEEPING 里的任务今天不需要关心,每天一次的检查足矣。每日检查就是 SLEEPING 列表的唤醒源。

中断四:新增任务

脑子里随时会冒出新的事要做。如果每次都立刻去处理,RUNNING 就被打断了;如果靠脑子硬记,又违背了”不占用心智资源”的原则。

这一类中断与前三类不同:它不触发任务切换,而是处理输入。

解法是引入一个轻量级的 INBOX,作为输入缓冲区:想到什么事,一句话记进去就完事,不做任何分析和分类。

然后在 READY 列表里放一个常驻任务:”处理 INBOX”。等调度到这个任务时,再把 INBOX 里的条目逐条细化——想清楚这件事具体要做什么、下一步行动是什么——然后放进 READY、WAITING 或 SLEEPING 对应的列表。

这样,”捕获”和”规划”被分离成了两个不同时刻的动作:捕获要快、要轻,规划可以等到专门的调度时刻再做。

小结

这套方法的核心可以归纳成几句话:

  • 承认自己是单线程的,任何时刻只做一件事,但把这件事做完或做到一个可以保存状态的断点。
  • 其他所有事务只以状态的形式躺在列表里,不占用后台心智。
  • 靠几类固定的”中断”来触发调度:任务结束、定时巡检、每日检查、新任务入 INBOX。
  • 任务什么时候重新进入视野,由事件和时间决定,而不是靠”别忘了”的焦虑。

把四类中断和 INBOX 也画进来,完整的图景如下:

            ┌──────────┐  完成   ┌──────┐
            │ RUNNING  │ ──────→ │ DONE │
            │ 正在处理 │         │ 归档 │
            └──────────┘         └──────┘
              ↑       ↓
      中断①②:调度    无法继续     中断③:每日检查唤醒
         ↑              ↓              ↓
  ┌──────────┐     ┌──────────┐    ┌──────────┐
  │  READY   │ ←①②→│ WAITING  │ ←→ │ SLEEPING │
  │随时可处理│     │等外部反馈│    │ 暂不处理 │
  └──────────┘     └──────────┘    └──────────┘
       ↑                ↑                ↑
     由 READY 中的常驻任务"处理 INBOX"细化、归类
        ↑
  ┌──────────┐
  │  INBOX   │ ← 中断④:随时想到的新任务,一句话记入,不打断当前任务
  │ 输入缓冲 │
  └──────────┘
                             

MCU 用几 KB 的内存就能可靠地管理一堆并发任务。人脑的内存虽然更有限,但只要把状态都写在外部的列表里,管理几十件事也绰绰有余。