CS1602计算导论
第 16 讲Part 6 Pythonic 与现代实践AI Level 2

多任务、总复习与展望

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 影响
多线程threadingI/O 密集是(但 I/O 等待时会释放)
多进程multiprocessingCPU 密集否(每个进程有自己的解释器)
异步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
/ 永远返回 floatL2
input() 永远返回字符串L3
函数内部要 return 不要 printL3
位运算和算术混用要加括号L4
可变默认参数L4、L14
原地修改的方法返回 NoneL5
[[0]*n]*m 所有行是同一个列表L5
遍历时不要修改容器L5、L6
(1) 不是元组,(1,) 才是L6
字典的键必须不可变L6
递归缺 base caseL7
忘了写 selfL9
忘了调 super().__init__()L10
is 只用来和 None 比L10
循环里 += 拼字符串是 O(n²)L11
裸的 except:L11
断言不能用来校验用户输入L11
"w" 模式会清空文件L12
不指定 encodingL12
生成器只能遍历一次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,五条贯穿的线索:引用模型、代价意识、分而治之、验证、外部世界是脏的
  • 复习按线索走,不要按讲次走
  • 高频陷阱清单上的每一条,都要能说出为什么

练习

  1. 判断下面每个任务该用 threading、multiprocessing、asyncio,还是都不用,说明理由:
    • 同时下载 500 个网页
    • 给 10000 张图片做缩放
    • 读一个 100MB 的 CSV 并统计
    • 一个同时服务 1000 个用户的聊天服务器
  2. 解释为什么”开 8 个线程算矩阵乘法”在 CPython 里几乎没有加速。
  3. 从五条线索里挑一条,写一页把它从头到尾串起来,每一步给出对应的讲次和一个代码例子。
  4. 把高频陷阱清单里的任意 10 条,各写一个能触发它的最小代码例子。
  5. 找一道你做过的实验题,用今天学到的知识重写一遍——看看能不能更短、更快、或者更清楚。
  6. 写下你打算往哪个方向走,以及下一步准备学什么。这一题没有标准答案,但值得认真写一次。