单核CPU也能“同时”做很多事

开篇除恐

看到“多线程”“并发”这些词,很多人第一反应是:“这得要多核CPU才能真正同时跑吧?我的老笔记本只有单核,是不是用线程就是白折腾?”其实,即使只有一颗 CPU 核心,操作系统也能让多个线程看起来是在“同时”工作——它就像一个熟练的厨师助理,在极短的时间里轮流让每个线程干一点活儿,快到人眼根本察觉不到切换。只要理解这种“时间片轮转”的思想,你就会发现,单核情况下使用线程照样能提升响应性和吞吐量,而不是纯粹的开销。

白话化

  • 时间片(Time Slice):操作系统为每个线程分配一个非常短的执行时长(典型值几毫秒甚至更少)。在这段时间里,线程独占 CPU;时间用完后,操作系统把控制权交给下一个线程。
  • 上下文切换(Context Switch):在切换线程之前,操作系统需要保存当前线程的寄存器、程序计数器等状态(以便以后恢复),并加载下一个线程的状态。这个过程很快,但不是零开销——这就是为什么线程太多时会出现“上下文切换开销大”的说法。
  • 调度器(Scheduler):决定哪个线程接下来获得时间片的策略。常见的有轮转(Round Robin)、优先级调度、完全公平调度(CFS)等等。

总之,单核 CPU 通过高速的时间片轮转,制造出多个线程“交替执行”的假象,从宏观上看就像是并行。

直觉先行

想象你只有一只手,但要同时照顾三个正在煮的锅子:锅 A 要每 30 秒翻一次,锅 B 要每 45 秒加盐,锅 C 要每 60 秒尝一下味道。你不可能真的用一只手同时做三件事,但你可以:

  1. 看表,先在锅 A 上翻一下(花 5 秒);
  2. 再去锅 B 加盐(花 5 秒);
  3. 再去锅 C 尝味(花 5 秒);
  4. 然后再回到锅 A,……如此循环。

只要你在每个锅上停留的时间足够短,并且循环得足够快,从外面看就好像三个锅都在“同时”被照顾——虽然实际上你只是在快速轮流。线程在单核 CPU 上的工作原理正是如此:操作系统就是那个只有一只手但反应极快的厨师。

例子贴身

例子 1(☼ 热身):用两个线程交替打印数字

任务:启动两个线程,一个只打印奇数,另一个只打印偶数,看到它们的输出交错出现(尽管实际上是时间片轮转导致的)。

怎么想到的:我们让每个线程在一个循环里打印自己的数字,然后短暂 sleep(或让出 CPU),以增加被调度的机会。即使不 sleep,由于时间片很短,也会看到交替。

示例代码(Python)

import threading
import time

def print_odd():
    for i in range(1, 21, 2):  # 1,3,5,...,19
        print(f"Odd: {i}")
        time.sleep(0.001)  # 主动让出剩余时间片(模拟 yield)

def print_even():
    for i in range(2, 21, 2):  # 2,4,6,...,20
        print(f"Even: {i}")
        time.sleep(0.001)

def main():
    t1 = threading.Thread(target=print_odd)
    t2 = threading.Thread(target=print_even)
    t1.start()
    t2.start()
    t1.join()
    t2.join()

if __name__ == "__main__":
    main()

可能的输出片段(实际会因调度而有所不同)

Odd: 1
Even: 2
Odd: 3
Even: 4
...
Odd: 19
Even: 20

要点:即使只有一个核心,两条线程也能够通过短暂 sleep 相互让出 CPU,从而产生交替打印的效果。这正是时间片轮转在起作用。

例子 2(☼☼ 正经):测量上下文切换开销

任务:创建大量线程(比如 1000 条),每条线程只是做一点微小工作(比如递增一个局部变量),然后 join;观察程序运行时间随线程数的变化,从而感受上下文切换的代价。

怎么想到的:线程越多,调度器需要在更多线程间分配时间片,每次切换都要保存/恢复寄存器等状态;当线程数远超过 CPU 核心数时,大部分时间会花在切换而不是真正做事上,导致吞吐量下降。

示例代码(Python)

import threading
import time

def worker():
    # 微小工作:递增一个局部变量 1000 次(纯 CPU,但会因 GIL 在线程间切换)
    x = 0
    for _ in range(1000):
        x += 1

def main():
    for N in [10, 100, 1000, 5000]:
        threads = []
        start = time.perf_counter()
        for _ in range(N):
            t = threading.Thread(target=worker)
            t.start()
            threads.append(t)
        for t in threads:
            t.join()
        elapsed = time.perf_counter() - start
        print(f"N={N:5d} -> {elapsed:.3f} 秒")

if __name__ == "__main__":
    main()

典型观察(在现代笔记本上)
- N=10 :几毫秒,基本是纯计算时间
- N=100 :几十毫秒,开始看到轻微的调度开销
- N=1000 :几百毫秒到一秒,上下文切换占比显著
- N=5000 :几秒甚至更多,系统花费大量时间在线程间切换

要点:单核情况下,线程数适度(比如和 CPU 核心数相当或稍多)时能够提升响应性(比如 I/O 等待时其他线程仍能运行);但线程过多会导致频繁上下文切换,反而降低整体效率。

收尾

这一章要带走的东西
- 单核 CPU 通过时间片轮转上下文切换,让多个线程看起来是同时运行的。
- 时间片很短(毫秒级),切换很快但不是零开销;因此线程数过大会导致调度开销变大
- 即使只有一个核心,线程仍能在 I/O 阻塞时让其他线程得到 CPU,从而提升程序的响应性和吞吐量。
- 因此,判断是否使用多线程不仅看核心数,还要看工作负载是否包含大量等待(如网络、磁盘 I/O)以及是否能容忍一定的调度开销。

就这样。 下一章我们将进入实战:如何用最基本的 API 创建、启动和等待一个线程——从“Hello, thread”开始。