AI大模型学习05:多线程、生成器与协程
多线程、生成器与协程:让你的程序学会“一心多用”
以前你的程序像一个单线程的“独行侠”,做完一件事才做下一件。今天我们要让它变得“三头六臂”:一边下载文件、一边处理数据、一边等待用户输入……
但多线程会带来抢数据的麻烦,于是有了“锁”;而为了更高效地处理等待,又有了“协程”。
一、从“一个人干活”到“一群人干活”
想象你一个人开早餐店:
-
你要蒸包子(CPU 计算)
-
要收钱(I/O 操作,比如等顾客扫码)
-
要打包(另一个任务)
如果只能一件事做完再做另一件,那么蒸包子时客人付钱就得等着,收钱时包子可能蒸过了。效率极低。
多任务就是让这些事“同时”进行。在 Python 里,实现多任务主要有三种方式:
-
多进程:开几家分店,各自独立。
-
多线程:一家店里雇好几个伙计,共享厨房和食材。
-
协程:一个伙计在等包子蒸熟的时候,先去收钱,再回来取包子——自己主动切换。
今天我们先讲多线程,再讲生成器(为协程打基础),最后讲协程。
二、多线程:店里雇了好几个伙计
1. 什么是线程?
进程是操作系统分配资源的最小单位,就像一个“店面”(有一块独立的厨房、仓库)。
线程是 CPU 调度的基本单位,依附于进程,就像店里的“伙计”。一个进程至少有一个线程(主线程),你可以再雇几个子线程。
比喻:
-
进程 = 一家餐厅(有地址、有营业执照)。
-
线程 = 餐厅里的服务员(共享餐厅的厨房、食材)。
2. 怎么雇一个伙计(创建线程)?
三个步骤:
-
把“伙计要干的活”写成一个函数。
-
用
threading.Thread创建一个线程对象,把函数名传给它(不要加括号,加了就是立刻执行)。 -
调用
start()让伙计开始干活。
注意:如果伙计需要工具(参数),可以用
args=(工具1, 工具2)元组按顺序给,或者kwargs={'名字':'小明'}字典按名字给。
3. 多个伙计谁先谁后?
操作系统是“调度员”,它决定哪个伙计先动。你写代码的顺序是 start() 了 A 再 start() B,但实际执行时可能 B 先跑。这叫无序性——正常现象,别指望谁先创建谁先执行完。
4. 主线程和伙计的关系
默认情况下,老板(主线程)会等所有伙计干完活才关门(程序结束)。
如果老板想提前关门,不管伙计了,可以把某个伙计设为“守护线程”(daemon=True)。这样老板一走,守护伙计立刻被撵走,活没干完也不管了。
三、共享全局变量:伙计们用同一个碗
线程最大的特点(和进程的区别)是:同一进程内的线程共享全局变量。
就像店里的伙计共用同一个碗盛菜——你往碗里加个鸡蛋,另一个伙计能看到。
例子:你有一个全局列表 orders = [],线程 A 往里加“包子”,线程 B 直接读这个列表,能读到。这在进程之间是做不到的(进程的内存是隔离的)。
1. 麻烦来了:抢碗导致数据错乱
如果两个伙计同时往碗里放东西,可能会出乱子。比如:
-
线程 A 看到碗里有 10 个包子,想再加 1 个,变成 11。
-
但就在它“看到10”和“改成11”的间隙,线程 B 也看到了 10,也加了 1,也改成 11。
-
结果加了两次,碗里却只有 11 个(应该是 12 个)——丢失更新。
这就是资源竞争。因为“读-改-写”不是一步完成的,中间可能被其他线程插队。
2. 解决办法:互斥锁(一个人用碗的时候锁住)
给碗加一把锁:谁想用碗,必须先拿到锁;用完了再还回去。这样同一时间只有一个人操作。
操作步骤:
-
创建一把锁:
my_lock = threading.Lock() -
用的时候:
my_lock.acquire()拿锁(如果已经被别人拿了,就等着) -
用完:
my_lock.release()还锁 -
更安全的是用
with my_lock:,自动还锁,不容易忘。
注意:所有伙计必须共用同一把锁。如果每人自己造一把新锁,等于没锁。
3. 死锁:互相等对方还钥匙
两个伙计:A 拿着碗的锁,B 拿着锅的锁。A 想用锅(需要 B 的锁),B 想用碗(需要 A 的锁),两个人都不肯放手,就死锁了。程序卡死。
避免死锁的方法:尽量按固定顺序拿锁,或者用超时机制。
四、GIL:Python 解释器自己的一把“大锁”
这是一个让很多初学者困惑的概念。
GIL(全局解释器锁) 是 CPython 解释器内部的一把锁。它的作用是:同一时刻,只有一个线程在执行 Python 字节码。
即使你的电脑有 8 个 CPU 核心,纯 Python 代码的多线程也无法真正做到同时计算——它们会快速切换,但每次只有一个在跑。
那多线程还有什么用?
当线程在做 I/O 操作(比如等待网络、读写文件、sleep)时,会主动释放 GIL,让其他线程跑。所以 I/O 密集型 任务用多线程很合适(比如爬虫、服务器处理请求)。
如果是 CPU 密集型(比如大量数学计算),多线程反而因为切换开销而变慢,这时应该用多进程。
那为什么我们还要自己加 Lock?
因为 GIL 保证的是“字节码级别的安全”,但一行 Python 代码(比如 count += 1)可能对应多条字节码。在两条字节码之间,线程仍可能被切换,导致你业务上的“读-改-写”被破坏。所以 GIL 不能代替你自己的 Lock。
五、进程 vs 线程:
| 对比项 | 进程 | 线程 |
|---|---|---|
| 内存 | 独立,不共享全局变量 | 共享进程内的全局变量 |
| 创建开销 | 大(开分店) | 小(雇伙计) |
| 多核利用 | 可以真正并行(多进程) | 受 GIL 影响,CPU 密集型难并行 |
| 稳定性 | 一个进程挂了,不影响其他进程 | 一个线程挂了,整个进程可能崩 |
| 数据安全 | 天然隔离,不用锁 | 需要锁保护共享数据 |
选谁?
-
计算密集型 + 要多核:用多进程。
-
I/O 密集型 + 要省资源:用多线程或协程。
六、生成器:一边吃一边做,不浪费
在讲协程之前,必须先理解生成器。它是 Python 里一种“懒加载”的机制。
1. 普通函数 vs 生成器函数
-
普通函数:你调用它,它一口气算完所有结果,返回给你。比如你要 100 万个数字,它先全部算好放在列表里,占大量内存。
-
生成器函数:你调用它,它不会立刻执行,而是返回一个“生成器对象”。你可以每次问它要一个值,它算一个给你,然后暂停,等你下次再要。
比喻:
-
普通函数 = 你去食堂打饭,师傅一下子把所有菜打给你,盘子很大。
-
生成器 = 你去自助餐,每次只夹一筷子,吃完了再回去夹。省盘子(内存),也省得菜凉了。
2. 怎么造一个生成器?
两种方法:
方法一:生成器推导式
用圆括号代替方括号:(x*2 for x in range(10))。
它不会立刻生成列表,而是产生一个生成器对象。用 for 循环或者 next() 取值。
方法二:yield 关键字
在函数里面写 yield,这个函数就变成了生成器函数。
-
当函数执行到
yield时,会返回yield后面的值,然后函数暂停,保存当前所有状态。 -
下次你再要值(比如用
next()或for),它会从上次暂停的地方继续执行,直到遇到下一个yield或函数结束。
3. yield vs return
-
return:函数结束,返回一个值。 -
yield:函数暂停,返回一个值,下次还能继续。
生成器函数里也可以写return,但它会结束生成器(触发StopIteration)。
生成器是协程的前身。从 yield 到 yield from,再到 async/await,是一脉相承的“可暂停函数”思想。
七、协程:单线程里的“超级服务员”
协程是比线程更轻量的并发方式。它运行在一个线程里,由程序员(或者事件循环)主动控制切换,而不是由操作系统抢占。
1. 协程的核心三要素
-
async def:定义一个协程函数。调用它不会立即执行,而是返回一个协程对象。 -
await:在协程内部,遇到需要等待的操作(比如睡眠、网络请求)时,用await把控制权交还给事件循环,让事件循环去执行其他协程。等那个操作完成了,事件循环再回来继续执行这个协程。 -
asyncio.run(main()):启动事件循环,并把main协程作为入口。
2. 事件循环是什么?
你可以把事件循环想象成一个调度中心。它手里有很多任务(协程),每个任务遇到 await 就会主动说:“我要等一会儿,你先去跑别的任务”。
事件循环就在这些任务之间快速切换,实现“并发”。因为切换是由任务主动让出的,所以不需要锁,也不会有线程切换的额外开销。
3. 如何让多个协程“同时”跑?
如果你在 main 里直接 await 两个协程,它们是串行的:先跑完第一个,再跑第二个。
要让它们并发,需要用 asyncio.create_task() 把协程包装成“任务”,然后 await 这些任务。
比喻:
-
串行:你先给第一个客人做饭,做好端上桌,再给第二个客人做饭。
-
并发:你同时给两个客人点单,然后利用煤气灶的等待时间(比如蒸包子需要 2 分钟)去切菜、收银。蒸箱响了再回来。
4. 协程 vs 线程 vs 进程
| 维度 | 进程 | 线程 | 协程 |
|---|---|---|---|
| 资源开销 | 最大 | 中等 | 极小(几千个协程小意思) |
| 切换方式 | 操作系统抢占 | 操作系统抢占 | 程序员主动 await 让出 |
| 数据共享 | 需要 IPC | 共享,需要锁 | 单线程内天然无竞争(不用锁) |
| 适用场景 | CPU 密集型 | I/O 密集型(传统) | 超高并发 I/O(如 websocket、爬虫) |
一句话:
-
要真正并行计算 → 多进程。
-
要简单并发 I/O → 多线程。
-
要海量 I/O 连接(成千上万) → 协程。
八、总结:
-
多线程:多个伙计共享厨房,省资源,但要注意抢菜(加锁)和死锁。
-
GIL:Python 解释器自己的大锁,导致计算密集型多线程没法用满多核。
-
生成器:懒加载,随用随算,省内存;
yield让函数可以暂停。 -
协程:在单线程里用
async/await实现超高并发 I/O,主动让出,不用锁。
更多推荐

所有评论(0)