多线程、生成器与协程:让你的程序学会“一心多用” 

以前你的程序像一个单线程的“独行侠”,做完一件事才做下一件。今天我们要让它变得“三头六臂”:一边下载文件、一边处理数据、一边等待用户输入……
但多线程会带来抢数据的麻烦,于是有了“锁”;而为了更高效地处理等待,又有了“协程”。


一、从“一个人干活”到“一群人干活”

想象你一个人开早餐店:

  • 你要蒸包子(CPU 计算)

  • 收钱(I/O 操作,比如等顾客扫码)

  • 打包(另一个任务)

如果只能一件事做完再做另一件,那么蒸包子时客人付钱就得等着,收钱时包子可能蒸过了。效率极低。

多任务就是让这些事“同时”进行。在 Python 里,实现多任务主要有三种方式:

  1. 多进程:开几家分店,各自独立。

  2. 多线程:一家店里雇好几个伙计,共享厨房和食材。

  3. 协程:一个伙计在等包子蒸熟的时候,先去收钱,再回来取包子——自己主动切换

今天我们先讲多线程,再讲生成器(为协程打基础),最后讲协程。


二、多线程:店里雇了好几个伙计

1. 什么是线程?

进程是操作系统分配资源的最小单位,就像一个“店面”(有一块独立的厨房、仓库)。
线程是 CPU 调度的基本单位,依附于进程,就像店里的“伙计”。一个进程至少有一个线程(主线程),你可以再雇几个子线程。

比喻

  • 进程 = 一家餐厅(有地址、有营业执照)。

  • 线程 = 餐厅里的服务员(共享餐厅的厨房、食材)。

2. 怎么雇一个伙计(创建线程)?

三个步骤:

  1. 把“伙计要干的活”写成一个函数。

  2. 用 threading.Thread 创建一个线程对象,把函数名传给它(不要加括号,加了就是立刻执行)。

  3. 调用 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,主动让出,不用锁。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐