线程能帮上忙的前提是「等待」:下载、读数据库、等接口,这些时间 CPU 是闲着的。 纯计算的任务线程帮不上忙,那是 GIL 和进程的事。 线程数不是越多越好。 演示里 16 个任务,8 线程 0.24 秒、16 线程 0.13 秒,但再往上就只是排队加开销。
上一篇的正则处理的是「一条数据」。这一篇处理「很多条要同时来」。
教材第 11 章后几节是【下载 JavaScript 产生的动态内容】和【让电脑一心多用的线程】。
再往后是【多线程同时下载多个文件】和【定时运行该程序】。
演示里的并发是真跑的:ThreadPoolExecutor 起真线程、sleep 模拟网络等待、
多个线程同时改一个共享变量制造竞态。
关于「下载动态内容」那一节:教材讲的是 Selenium,第 6.2 篇已经讲过,本篇只用一句话带过。
一、先看单线程串行
8 个文件,每个要等 0.12 秒(用 sleep 模拟网络等待)。串行跑完 0.97 秒。
0.97 约等于 8 × 0.12 = 0.96,再加上一点点调度开销。 这个数字是这么来的。
串行的问题是:等第一个的时候,CPU 完全闲着。它没有在算任何东西,只是在等对方把数据发过来。
如果把「等」这件事重叠起来,8 个文件的等待可以同时进行,总时间就能压到接近 0.12 秒。 这就是多线程在 I/O 场景里的全部价值。
二、换成 4 个线程
| 方式 | 耗时 | 相对串行 |
|---|---|---|
| 串行 | 0.97 秒 | 1.00 倍 |
| 4 线程 | 0.24 秒 | 约 4 倍快 |
4 个线程一起等,墙钟时间就压到四分之一左右。0.97 ÷ 4 约等于 0.24,对得上。 关键前提是「等待」。 下载、读数据库、等接口,这些时间 CPU 是闲着的,多开几个线程就能同时等。 要是任务是纯计算(比如算一万个素数),线程帮不上忙。 因为 CPython 有个东西叫 GIL,同一时刻只允许一个线程执行 Python 字节码。
纯计算想并行,得用多进程(multiprocessing),每个进程有自己的解释器和 GIL。
代价是进程间传数据比线程贵得多。
所以第一句话该问的是:「我的任务在等,还是在算?」 在等就用线程,在算就用进程,
两者都不占就别折腾。
三、线程数不是越多越好
| 线程数 | 16 个任务总耗时 |
|---|---|
| 1 | 1.94 秒 |
| 2 | 0.97 秒 |
| 4 | 0.48 秒 |
| 8 | 0.24 秒 |
| 16 | 0.13 秒 |
16 个任务、每个等 0.12 秒,8 线程跑 0.24 秒(两批),16 线程跑 0.13 秒(一批)。 看起来「线程越多越快」,但这只是因为任务数正好等于线程数。 任务变成 1000 个、每个等 0.12 秒, 16 线程和 64 线程的差距会小到看不出来。 线程再多也只是在排队,而且要付创建和切换的开销。开上千个线程会把内存和调度压垮。
经验值:I/O 密集的任务,线程数取「同时能等的数量」这个量级就够了,8 到 16 是常见范围。 真正的上限往往不在你这边,而在对方允许你并发几个。
四、线程共享变量会出事
演示里 4 个线程各加 2000 次,期望 8000。
counter[0] + 1 不是一步。 它是三步:先读出来、加一、再写回去。
两个线程同时读到同一个值(比如都读到 100),各自加一(都得 101),
再各自写回去——结果只多了 1,不是 2。 这叫竞态。
这次实测差了 0,也就是没撞上。这不是说代码是对的,是说运气好。 这正是这类 bug 最难查的原因:本机测一百次都好,上线之后偶尔错一次,而且错的那次没有任何日志。 为什么这次没撞上?因为每轮循环太短,线程切换的间隔比一次加法长得多。 把循环次数加大、或者在里面加一点计算,撞上的概率立刻上升。
五、加锁就对了
with lock: 保证同一时刻只有一个线程在改这个变量。加锁之后实测 8000,期望 8000。
代价是这一段变成串行。所以锁的范围要小,只包住真正冲突的那几行。
把整个下载循环包进锁里,等于退回单线程——而且更慢,因为多了抢锁的开销。
还有一个错误叫死锁:两个线程各拿着一把锁,又都在等对方那把。程序就停在那儿,不报错,也不动。 避免死锁的土办法:约定所有线程按同样的顺序拿锁。 都先拿 A 再拿 B,就不会出现「我拿 A 等你 B、 你拿 B 等我 A」。
六、定时执行:自己算下一次
演示最后那段:现在 11-02 09:12,下次 18:00 是 11-02 18:00,要等 8.8 小时。 现在如果是 11-02 19:30(已经过了 18:00),下次就是 11-03 18:00——要加一天。 「今天 18 点」这个时间点可能已经过了,所以要判一下,过了就加一天。 这是所有定时任务的核心逻辑,就这几行。
生产上不会自己写这套:Linux 用 cron,Windows 用任务计划程序。
但原理就是上面这个——算出下一次该跑的时刻,睡到那时候,跑完再算下一次。
自己写定时最容易错的是「跑得比周期长」:任务本身要 5 分钟,却每 1 分钟触发一次,就会越堆越多,
最后几十个进程互相抢资源。
所以要加「上一次没跑完就跳过」的判断。
这一条在 cron 里也有对应做法,但最省事的还是让任务本身跑得比周期短。
七、顺带:动态内容那件事
教材在【多线程】前面还有一节【下载 JavaScript 产生的动态内容】,讲的是 Selenium。
那套东西第 6.2 篇已经讲透了:
requests 拿到的是服务器发的文本,执行不了 JavaScript,所以动态填进去的内容抓不到。
解决办法是开真浏览器。但要注意:Selenium 慢、占内存,而且它不是线程安全的。
想同时开多个浏览器抓,正确做法是开多个进程,不是一个进程里开多个线程。 因为每个浏览器实例都要绑一个驱动进程,线程之间会抢它。 所以「多线程」和「动态内容」这两件事放在一起时,要多想一步: 你要并行的到底是「等网络」,还是「跑浏览器」? 后者更适合多进程。
八、动手 Demo:多线程与定时执行
先用 sleep 模拟网络等待,比串行和 4 线程的耗时。 再看线程数从 1 加到 16 的对照表。 然后制造一次竞态,看加锁能不能救回来。 最后算「下一次 18:00」是哪一刻。
import threading, time
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timedelta
def job(i, wait=0.12):
time.sleep(wait)
return i
print('① 先看单线程串行下载')
N = 8
print(' %d 个文件,每个「耗时」0.12 秒(这里用 sleep 模拟网络等待)' % N)
t0 = time.time()
for i in range(N):
job(i)
serial = time.time() - t0
print(' 串行总耗时 %.2f 秒' % serial)
print('② 换成 4 个线程')
t0 = time.time()
with ThreadPoolExecutor(max_workers=4) as ex:
list(ex.map(job, range(N)))
par = time.time() - t0
print('| 方式 | 耗时 | 相对串行 |')
print('|---|---:|---:|')
print('| 串行 | %.2f 秒 | 1.00 倍 |' % serial)
print('| 4 线程 | %.2f 秒 | %.2f 倍快 |' % (par, serial / par))
print(' 任务总共要 %d × 0.12 = %.2f 秒的等待,4 个线程一起等,墙钟时间就压到四分之一左右。' % (N, N * 0.12))
print('③ 线程数不是越多越好')
M = 16
print('| 线程数 | %d 个任务总耗时 |' % M)
print('|---:|---:|')
for w in (1, 2, 4, 8, 16):
t0 = time.time()
with ThreadPoolExecutor(max_workers=w) as ex:
list(ex.map(job, range(M)))
print('| %d | %.2f 秒 |' % (w, time.time() - t0))
print(' 任务 %d 个、每个等 0.12 秒,理论上 4 线程就该到底了。' % M)
print(' 线程再多也只是在排队,而且要付创建和切换的开销。')
print('④ 线程共享一个变量会出事')
counter = [0]
def bump(n):
for _ in range(n):
counter[0] = counter[0] + 1
ts = [threading.Thread(target=bump, args=(2000,)) for _ in range(4)]
[t.start() for t in ts]
[t.join() for t in ts]
print(' 4 个线程各加 2000 次,期望 8000,实际 %d,差 %d' % (counter[0], 8000 - counter[0]))
print(' counter[0] + 1 不是一步:先读出来,加一,再写回去。')
print(' 两个线程同时读到同一个值,就会各加一次、只多了一个。这叫竞态。')
print(' 这次差多少要看运气,可能一次都不差——所以这种 bug 最难查。')
print('⑤ 加锁就对了')
counter2 = [0]
lock = threading.Lock()
def bump2(n):
for _ in range(n):
with lock:
counter2[0] = counter2[0] + 1
ts = [threading.Thread(target=bump2, args=(2000,)) for _ in range(4)]
[t.start() for t in ts]
[t.join() for t in ts]
print(' 加锁后:%d(期望 8000)' % counter2[0])
print(' with lock: 保证同一时刻只有一个线程在改这个变量。')
print(' 代价是这一段变成串行——锁的范围要小,只包住真正冲突的那几行。')
print('⑥ 定时执行:自己算下一次')
def next_at(hour, now):
t = now.replace(hour=hour, minute=0, second=0, microsecond=0)
return t if t > now else t + timedelta(days=1)
for now in (datetime(2026, 11, 2, 9, 12), datetime(2026, 11, 2, 19, 30)):
nxt = next_at(18, now)
wait = (nxt - now).total_seconds() / 3600
tag = '(已过 18:00)' if now.hour >= 18 else ''
print(' 现在 %s%s,下次 18:00 是 %s' % (now.strftime('%m-%d %H:%M'), tag, nxt.strftime('%m-%d %H:%M')))
print(' 要等 %.1f 小时' % wait)
print(' 「今天 18 点」这个时间点可能已经过了,所以要判一下,过了就加一天。')
实跑输出:
--------------------------------------------------------------------
多线程与定时执行
--------------------------------------------------------------------
① 先看单线程串行下载
--------------------------------------------------------------------
8 个文件,每个「耗时」0.12 秒(这里用 sleep 模拟网络等待)
串行总耗时 0.97 秒
② 换成 4 个线程
--------------------------------------------------------------------
| 方式 | 耗时 | 相对串行 |
|---|---:|---:|
| 串行 | 0.97 秒 | 1.00 倍 |
| 4 线程 | 0.24 秒 | 3.97 倍快 |
任务总共要 8 × 0.12 = 0.96 秒的等待,4 个线程一起等,墙钟时间就压到四分之一左右。
关键前提是「等待」:下载、读数据库、等接口,这些时间 CPU 是闲着的,多开几个线程就能同时等。
要是任务是纯计算(比如算一万个素数),线程帮不上忙——那是第 3 章讲的 GIL 和进程的事。
③ 线程数不是越多越好
--------------------------------------------------------------------
| 线程数 | 16 个任务总耗时 |
|---:|---:|
| 1 | 1.94 秒 |
| 2 | 0.97 秒 |
| 4 | 0.48 秒 |
| 8 | 0.24 秒 |
| 16 | 0.13 秒 |
任务 16 个、每个等 0.12 秒,理论上 4 线程就该到底了。
线程再多也只是在排队,而且要付创建和切换的开销。
经验值:I/O 密集的任务,线程数取「同时能等的数量」这个量级就够了,8 到 16 是常见范围。
开上千个线程只会把内存和调度压垮。
④ 线程共享一个变量会出事
--------------------------------------------------------------------
4 个线程各加 2000 次,期望 8000,实际 8000,差 0
counter[0] + 1 不是一步:先读出来,加一,再写回去。
两个线程同时读到同一个值,就会各加一次、只多了一个。这叫竞态。
这次差多少要看运气,可能一次都不差——所以这种 bug 最难查:本机测一百次都好,上线就偶尔错一次。
⑤ 加锁就对了
--------------------------------------------------------------------
加锁后:8000(期望 8000)
with lock: 保证同一时刻只有一个线程在改这个变量。
代价是这一段变成串行——锁的范围要小,只包住真正冲突的那几行。
⑥ 定时执行:自己算下一次
--------------------------------------------------------------------
现在 11-02 09:12,下次 18:00 是 11-02 18:00
要等 8.8 小时
现在 11-02 19:30(已过 18:00),下次是 11-03 18:00
「今天 18 点」这个时间点可能已经过了,所以要判一下,过了就加一天。
这是所有定时任务的核心逻辑,就这几行。
生产上不会自己写这套:Linux 用 cron,Windows 用任务计划程序。
但原理就是上面这个——算出下一次该跑的时刻,睡到那时候,跑完再算下一次。
自己写定时最容易错的是「跑得比周期长」:任务本身要 5 分钟,却每 1 分钟触发一次,就会越堆越多。
所以要加「上一次没跑完就跳过」的判断。
第一段那个 0.97 秒就是 8 × 0.12 再加一点调度开销。看到这个数要能反推出「每个 0.12」。 第二段 4 线程 0.24 秒,差不多是 0.97 的四分之一。因为 8 个任务分成两批,每批 4 个同时等。 第三段那张表要竖着看:1 到 8 是成倍下降,8 到 16 只降了一半。 因为 16 个任务用 8 线程是两批,用 16 线程是一批,本来就只差一倍。 第四段这次差 0,也就是没撞上竞态。这不代表代码是对的,只代表运气好。 这类 bug 的可怕之处就在这儿:本机测不出来,上线偶尔错一次。 第五段加锁之后 8000,对上了。但要注意锁的范围——把整个循环包进去就等于退回单线程。 第六段两行输出对比很直观:9:12 时下次是当天 18:00;19:30 时下次是第二天 18:00。 就差那个「过了就加一天」的判断。
30 秒实验
- 把演示第四段的 2000 改成 20000,重跑几次,看能不能撞出小于 80000 的结果。
- 把演示第三段的
M从 16 改成 64,再看那张表,观察 16 线程和 32 线程的差距。 - 把演示第六段的
next_at(18, ...)改成next_at(9, ...), 看 9:12 那一刻算出来是当天还是第二天(提示:>不是>=,正好 9:00 时会给第二天)。
常见误区
- 「多线程能让程序跑得更快。」只在「等」的时候成立。 纯计算的任务,CPython 的 GIL 让线程根本不能并行。
- 「线程开得越多越快。」超过「同时能等的数量」之后只是排队,还要付切换开销。 开上千个线程会把内存和调度压垮。
- 「这次跑出 8000 就说明没竞态。」这次没撞上不代表代码是对的。 竞态是概率事件,本机测一百次都好,上线偶尔错一次。
自测三问(点开对答案)
1. 8 个文件每个等 0.12 秒,串行 0.97 秒,4 线程 0.24 秒。为什么能快四倍? —— 因为这 0.96 秒全是「等」,CPU 闲着。4 个线程同时等,8 个任务分两批,墙钟时间就压到四分之一。 **前提是任务在等而不是在算**——纯计算的任务线程帮不上忙,因为有 GIL。 2. `counter[0] = counter[0] + 1` 在两个线程里同时跑,为什么会少加? —— 因为这行不是一步,是三步:读出来、加一、写回去。 两个线程可能都读到 100,各自加一得 101,再各自写回 101——本该是 102。这叫竞态。 加锁(`with lock:`)让这三步变成不可分割的一段,就不会了。 3. 算「下一次 18:00」为什么要判断「过了就加一天」? —— 因为「今天 18:00」这个时刻可能已经过去了。 如果现在是 19:30,今天 18:00 已经过去,下一次该是明天 18:00。 不加这个判断,算出来的就是一个过去的时间,任务会立刻执行或者算出负数等待。第 11 章结束。下一篇进第 12 章:做一个真的能留言的网站,从数据库开始。
- ☐ (本篇完,下一篇 12.1 数据库与建表)