多任务、总复习与展望
Multitasking, Review and Beyond
最后一讲。先补上一块你还没见过的拼图——程序怎么同时做很多事,然后把十六周的知识串成一张图,最后说说这门课通向哪里。
本讲结束后你应当能
- 说清楚并发与并行的区别,以及 GIL 是什么
- 判断一个任务该用多线程、多进程,还是都不用
- 用一张图串起整门课的知识结构
- 知道期末考试考什么
- 说出这门课之后可以往哪几个方向走
本页目录
一、多任务
你的程序一直是一条线
到目前为止,你写的每个程序都是一条执行线:从第一行开始,一行一行往下,做完一件事再做下一件。
但现实中的程序经常需要同时做很多事:
- 浏览器一边下载图片,一边渲染页面,一边响应你的点击
- 服务器同时处理上千个用户的请求
- 科学计算把一个大任务拆给八个 CPU 核心
这一讲把这块拼图补上——只讲概念,不要求你实现。
并发与并行
这两个词经常混用,但不是一回事:
| 并发(concurrency) | 并行(parallelism) | |
|---|---|---|
| 含义 | 交替做多件事 | 同时做多件事 |
| 需要几个核 | 一个就够 | 必须多个 |
| 比喻 | 一个厨师在三个灶台间来回 | 三个厨师各守一个灶台 |
| 解决什么 | 等待时不浪费时间 | 计算量太大 |
一个人快速地在多件事之间切换,看起来像同时做——这是并发。 真的有多个人同时干活——这是并行。
两类任务,两种瓶颈
判断该不该用多任务,先看你的任务卡在哪:
| 类型 | 瓶颈 | 例子 |
|---|---|---|
| I/O 密集 | 等待外部 | 读文件、下载网页、查数据库 |
| CPU 密集 | 计算本身 | 矩阵运算、图像处理、密码破解 |
I/O 密集的任务大部分时间在等。等的时候 CPU 是闲着的——这时候切去做别的事,就赚了。
CPU 密集的任务一直在算。切来切去没用,因为一个核就那么快,你需要更多的核。
import time
# CPU 密集:一直在算,时间全花在计算上
def cpu_task() -> int:
return sum(i * i for i in range(200_000))
t = time.time()
for _ in range(5):
cpu_task()
cpu_time = time.time() - t
print(f"5 个 CPU 任务串行: {cpu_time:.3f} 秒")
print("这 5 个任务全程占着 CPU —— 想加速只能靠更多的核")
print()
print("对照:如果是 5 个各等 0.1 秒的 I/O 任务,串行要 0.5 秒,")
print("而这 0.5 秒里 CPU 几乎全程空闲 —— 这就是并发能捡到的便宜。")
Python 的 GIL
现在说一件 Python 特有的事。
GIL(Global Interpreter Lock,全局解释器锁)是 CPython 里的一把锁,它保证同一时刻只有一个线程在执行 Python 字节码。
后果很直接:在 CPython 里,多线程不能让 CPU 密集型任务变快。 你开八个线程算矩阵,它们还是排队用一个核。
三种做法
| 方案 | 模块 | 适合 | 受 GIL 影响 |
|---|---|---|---|
| 多线程 | threading | I/O 密集 | 是(但 I/O 等待时会释放) |
| 多进程 | multiprocessing | CPU 密集 | 否(每个进程有自己的解释器) |
| 异步 | asyncio | 大量 I/O 并发 | 是(但根本不用多线程) |
多线程:适合等待
线程等 I/O 的时候会释放 GIL,让别的线程跑。所以多线程对 I/O 密集任务是有效的。
import threading
import time
def download(name: str) -> None:
print(f" 开始 {name}")
time.sleep(1) # 假装在下载
print(f" 完成 {name}")
threads = [threading.Thread(target=download, args=(f"文件{i}",)) for i in range(3)]
start = time.time()
for t in threads:
t.start() # 启动
for t in threads:
t.join() # 等它们都结束
print(f"三个下载总耗时 {time.time() - start:.1f} 秒(不是 3 秒)")
三个各需 1 秒的下载,总共大约 1 秒——因为它们在同时等。
多进程:适合计算
每个进程有独立的解释器和独立的 GIL,所以能真正并行。
from multiprocessing import Pool
def heavy(n: int) -> int:
return sum(i * i for i in range(n))
if __name__ == "__main__": # 多进程必须有这一行(L10 讲过为什么)
with Pool(4) as pool:
results = pool.map(heavy, [1_000_000] * 4)
print(results)
代价是进程之间不共享内存——数据要序列化后传来传去,开销比线程大得多。任务不够重的话,光开进程的成本就超过收益了。
异步:单线程处理海量 I/O
import asyncio
async def fetch(name: str) -> str:
await asyncio.sleep(1) # 等的时候让出控制权
return f"{name} 完成"
async def main() -> None:
results = await asyncio.gather(fetch("A"), fetch("B"), fetch("C"))
print(results)
asyncio.run(main())
async / await 让一个线程在等待时主动让出控制权去做别的事。它不需要多线程,也就绕过了 GIL 和线程切换的开销——现代网络服务器几乎都用这套。
怎么选
你的任务卡在哪?
├── 等 I/O(网络、文件、数据库)
│ ├── 任务不多(几个到几十个) → threading
│ └── 任务很多(成百上千) → asyncio
├── 算得慢(CPU 密集)
│ └── multiprocessing
└── 都不是 → 别用多任务
# 竞态条件的示意:两个"线程"交替给同一个变量加一
counter = 0
def increment_unsafe() -> None:
global counter
temp = counter # 读
temp = temp + 1 # 算
counter = temp # 写
# 如果两个线程在「读」和「写」之间被打断,就会丢失一次加法
print("这三步不是原子的。在真正的多线程里,")
print("两个线程可能同时读到同一个值,各自加一,结果只加了一次。")
print("解决办法是加锁,但加锁又可能死锁。")
二、十六周的知识地图
六个 Part,一条主线
Part I 计算基础与 Python 入门 L1–L4
计算是什么 → 数据是什么 → 怎么表达 → 怎么控制流程
收口:能写出含输入、判断、循环、函数的完整程序
Part II 数据的组织 L5–L6
一个变量装不下 → 五种容器 → 该选哪一个
收口:能为问题选对容器
Part III 问题求解与 AI 协作 L7–L8
把问题变小(递归)→ 判断代码好坏(AI 协作)
收口:能拆解问题,能审查代码
Part IV 抽象与组织 L9–L10
数据和行为打包(类)→ 类之间的关系与文件组织(继承、模块)
收口:能组织中等规模的程序
Part V 健壮性与真实世界 L11–L12
太慢/会崩/悄悄错 → 外部数据是脏的
收口:能处理真实数据
Part VI Pythonic 与现代实践 L13–L16
迭代与生成器 → 函数进阶 → 项目尺度的 AI 协作 → 收尾
收口:能写地道的 Python,能完成一个真实项目
贯穿全课的几条线
有些概念不属于某一讲,而是反复出现、层层加深的:
一、引用模型。 L5 讲变量是指向对象的箭头 → L6 可变对象作参数会被改 → L9 可变类变量 → L10 == 与 is → L14 可变默认参数。同一件事的五个面孔。
二、代价意识。 L5 列表操作的直觉 → L6 容器选型 → L11 大 O 形式化 → L13 空间复杂度与生成器 → L15 AI 不会替你判断复杂度。
三、分而治之。 L3 函数 → L7 递归 → L9 类 → L10 模块 → L15 拆解需求。同一个思路在五个尺度上重复。
四、验证。 L3 print vs return → L8 用测试而非”看起来对” → L11 pytest 与断言 → L15 AI 写的测试不能验证 AI 写的代码。
五、外部世界是脏的。 L3 input 永远是字符串 → L11 异常处理 → L12 编码、坏行、路径。
高频陷阱清单
这些是十六周里反复强调、也是最容易在考试和实践中出错的:
| 陷阱 | 出处 |
|---|---|
浮点数不能用 == 比较 | L2 |
/ 永远返回 float | L2 |
input() 永远返回字符串 | L3 |
函数内部要 return 不要 print | L3 |
| 位运算和算术混用要加括号 | L4 |
| 可变默认参数 | L4、L14 |
原地修改的方法返回 None | L5 |
[[0]*n]*m 所有行是同一个列表 | L5 |
| 遍历时不要修改容器 | L5、L6 |
(1) 不是元组,(1,) 才是 | L6 |
| 字典的键必须不可变 | L6 |
| 递归缺 base case | L7 |
忘了写 self | L9 |
忘了调 super().__init__() | L10 |
is 只用来和 None 比 | L10 |
循环里 += 拼字符串是 O(n²) | L11 |
裸的 except: | L11 |
| 断言不能用来校验用户输入 | L11 |
"w" 模式会清空文件 | L12 |
不指定 encoding | L12 |
| 生成器只能遍历一次 | L13 |
| lambda 的延迟绑定 | L14 |
| 函数里有赋值 → 整个函数都算局部 | L14 |
三、期末考试
形式
闭卷,占总成绩 50%。
范围
L1 到 L16 全部内容,但权重不均:
| 内容 | 大致权重 |
|---|---|
| 基础语法与数据类型(L2–L4) | 20% |
| 容器与选型(L5–L6) | 20% |
| 递归(L7) | 10% |
| 类与模块(L9–L10) | 15% |
| 复杂度、异常、测试(L11) | 15% |
| 文件与文本处理(L12) | 10% |
| 迭代器、生成器、函数进阶(L13–L14) | 10% |
不考的: L15 的 AI 协作方法(那是项目里考的)、L16 的多任务(只考概念判断,不考实现)、正则的复杂语法(只考基本记号)。
题型
- 读代码写输出——给一段代码,写出它打印什么。高频陷阱清单里的每一条都可能出现在这里
- 改错——给一段有 bug 的代码,指出问题并改对
- 写函数——给出签名和例子,实现它。手写,注意边界
- 判断复杂度——给一段代码,说出它的时间复杂度并解释
- 概念简答——比如”
==和is的区别”、“什么是鸭子类型”
四、这门课通向哪里
你现在会什么
十六周之前,很多人没写过一行代码。现在你能:
- 把一个用中文描述的问题,变成一个能跑的程序
- 为问题选对数据结构,并估算它的代价
- 读懂别人写的代码,判断它好不好,找出它的 bug
- 处理真实的、不干净的数据
- 写测试来验证自己的判断
- 和 AI 协作,而不是被它牵着走
这不是”会用 Python”,这是会解决问题。 语言只是载体。
往下走的几个方向
数据科学与机器学习
你的团队项目做的就是 NumPy 的雏形。真正的路线是: NumPy → pandas(表格数据)→ Matplotlib(可视化)→ scikit-learn(机器学习)→ PyTorch(深度学习)
如果你对 AI 本身感兴趣,这是最直接的路。
算法与数据结构
L11 只是个开始。真正的算法课会讲:排序与搜索的完整谱系、树与图、动态规划、贪心。
这是计算机科学的核心课程,也是面试考得最多的东西。可以先从 LeetCode 的简单题开始练手。
软件工程
一个人写几百行和一个团队维护几十万行,是完全不同的问题。你会学到:版本控制(Git)、代码评审、持续集成、设计模式、架构。
你在团队项目里遇到的所有摩擦,都是这门学科要解决的问题。
系统与底层
L1 的冯诺依曼体系、L10 的字节码、L16 的 GIL——这些都是往下一层的入口。操作系统、编译原理、计算机体系结构。
学一门 C 或 Rust 会让你对这一层有完全不同的理解。
其他语言
第二门语言会学得比第一门快得多,因为语言之间共享的东西远多于差异:
| 语言 | 什么时候值得学 |
|---|---|
| C | 想理解内存和指针,想懂”Python 底下是什么” |
| JavaScript | 想做网页和前端 |
| Rust | 想要 C 的性能加上安全保证 |
| SQL | 要处理数据库(几乎所有方向都会用到) |
最后
L1 引用了 Python 之禅。现在你可以重读一遍,看看感受有没有变化:
import this
当时我说”这些话现在看是空的,学到 L13 你再回头看会有完全不同的感受”。
现在你知道 Simple is better than complex 是在说什么了——你见过代码四那样九行的东西被压成三行;
你知道 Readability counts 不是客套——你自己读过队友写的代码;
你也明白 There should be one obvious way to do it 为什么被反复强调——你在 map/filter 和推导式之间做过选择。
祝你考试顺利,也祝你之后写的代码越来越好。
小结
多任务
- 并发是交替,并行是同时
- I/O 密集用线程或异步,CPU 密集用多进程
- GIL 让 CPython 的多线程救不了 CPU 密集任务
- 多任务带来竞态、死锁、难复现的 bug——优先考虑让单线程更快
这门课
- 六个 Part,五条贯穿的线索:引用模型、代价意识、分而治之、验证、外部世界是脏的
- 复习按线索走,不要按讲次走
- 高频陷阱清单上的每一条,都要能说出为什么
练习
- 判断下面每个任务该用 threading、multiprocessing、asyncio,还是都不用,说明理由:
- 同时下载 500 个网页
- 给 10000 张图片做缩放
- 读一个 100MB 的 CSV 并统计
- 一个同时服务 1000 个用户的聊天服务器
- 解释为什么”开 8 个线程算矩阵乘法”在 CPython 里几乎没有加速。
- 从五条线索里挑一条,写一页把它从头到尾串起来,每一步给出对应的讲次和一个代码例子。
- 把高频陷阱清单里的任意 10 条,各写一个能触发它的最小代码例子。
- 找一道你做过的实验题,用今天学到的知识重写一遍——看看能不能更短、更快、或者更清楚。
- 写下你打算往哪个方向走,以及下一步准备学什么。这一题没有标准答案,但值得认真写一次。