核心前提:GIL全局解释器锁,CPython中同一时刻只有1个线程执行Python字节码。
- 多线程:适合 IO密集型(网络请求、文件读写、数据库)
- 多进程:适合 CPU密集型(图像运算、模型推理、数值计算),每个进程独立GIL
一、基础概念题
1. 多线程和多进程区别?
- 内存空间
- 线程:同进程内,共享内存,全局变量共享;线程之间通信简单,但存在竞态条件,需要锁。
- 进程:独立内存空间,互不共享;通信成本高(管道、队列、共享内存)。
- GIL影响
- 多线程:CPU密集任务,GIL限制,无法多核并行,只能并发;IO阻塞时GIL释放,所以IO场景好用。
- 多进程:每个进程独立GIL,可以多核真正并行,但创建销毁开销大。
- 开销
- 线程轻量,创建成本低;进程重量级,fork/创建开销大。
- 崩溃影响
- 一个线程崩溃,整个进程挂掉;一个进程崩溃,不影响其他进程。
一句话总结:IO密集用threading;CPU密集用multiprocessing
2. GIL是什么?为什么有GIL?
GIL(Global Interpreter Lock)全局解释器锁,CPython的一把互斥锁,保证同一时间只有一个线程执行Python字节码。
- 目的:简化CPython内存管理(引用计数非线程安全)
- 释放时机:
- 线程遇到IO阻塞(sleep、网络请求、读文件)→ 主动释放GIL
- 执行一定字节码计数,强制切换
注意:GIL不是线程安全锁,不能替代lock!多个线程修改共享变量依然会数据错乱。
3. 并发、并行区别
- 并发(concurrent):单核CPU快速切换任务,宏观看起来同时跑;多线程属于并发。
- 并行(parallel):多核CPU,同一时刻多个任务真正同时执行;多进程可以并行。
4. 什么是线程安全?竞态条件?
竞态条件:多个线程同时读写同一个共享变量,执行时序不确定,结果错乱。
python
# 非线程安全示例
count = 0
def add():
global count
for _ in range(100000):
count +=1
count +=1 不是原子操作(读→+1→写回),多线程不加锁会丢更新。
解决:threading.Lock()。
5. 死锁产生条件、怎么避免
死锁4个必要条件:
- 互斥:资源独占
- 持有并等待:线程拿着资源,同时申请新资源
- 不可剥夺:资源不能被强制抢走
- 循环等待:线程互相等待对方资源
避免方案:统一资源申请顺序、设置超时、减少嵌套锁。
二、模块 & API 相关考题
6. threading 常用组件
threading.Thread:创建线程threading.Lock:互斥锁RLock:可重入锁(同一线程可以多次acquire)Semaphore:信号量,控制并发数量(爬虫限制并发请求)Event:线程间事件通知(一个线程发信号,另一个等待)Condition:条件变量,锁+等待通知,生产者消费者
7. multiprocessing 常用组件
Process:进程Queue:进程安全队列(推荐用来进程通信)Pipe:双向管道,两个进程通信Manager:创建共享对象 list/dict(底层是单独管理进程,性能一般)Pool:进程池,复用进程,避免频繁创建销毁(最常用)
⚠️ 进程之间不能直接用全局变量共享,必须用Queue/Pipe/共享内存。
8. 进程池 Pool 和手动创建Process区别
- Pool:预先创建一批进程,任务投递复用进程,适合大量短任务,减少fork开销
- Process:每次新建进程,适合长驻任务;任务量大时开销很高
9. 线程池 concurrent.futures.ThreadPoolExecutor / ProcessPoolExecutor
高层封装,面试高频,简洁易用,支持submit+result()、map。
工程推荐优先用
concurrent.futures,少手写底层threading。
10. 守护线程 daemon
- daemon=True:守护线程,主线程退出时,守护线程直接被杀死,不等待执行完毕。
- daemon=False(默认):主线程会等待所有非守护线程全部结束,才退出。
注意:守护线程不要执行文件/数据库写操作,可能中途被打断导致数据损坏。
三、通信与同步题
- 线程间通信方式
共享全局变量(配合Lock)、Queue(threading.Queue线程安全队列)、Event、Condition
threading.Queue 内部自带锁,线程安全,推荐优先使用,不要裸写全局变量。
- 进程间通信IPC
multiprocessing.Queue、Pipe、共享内存Value/Array、Manager、Socket
不要用普通全局变量,子进程是拷贝一份,修改互不影响。
四、异常、坑点高频题
-
多线程中
time.sleep()会不会释放GIL?会,IO/睡眠阻塞时释放GIL,其他线程可以跑,所以爬虫多线程有效。
-
为什么CPU密集任务,多线程比单线程还慢?
GIL频繁切换,线程切换带来额外开销,没有多核加速,切换损耗反而拖累速度。
-
multiprocessing在Windows下必须
if __name__ == "__main__":?Windows创建进程是spawn方式,不是fork,会重新导入模块,不加判断会递归无限创建进程。Linux/macOS fork模式可以省略,但代码规范建议统一加上。
-
进程池里的函数能不能用lambda?
不推荐,pickle序列化问题,部分环境会报错;尽量用普通函数。
五、应用场景 & 工程选型
多线程适用场景(IO密集)
- 爬虫:并发请求http接口
- 文件批量读写、日志采集
- 数据库批量查询(等待DB返回)
- API网关并发接收请求
限制:CPU计算 heavy 的任务不要用
多进程适用场景(CPU密集)
- 图像批量处理:OpenCV、PIL图片解码、缺陷检测(你之前做的表面检测)
- 大模型预处理、向量批量编码(Embedding批量计算)
- 数值运算、信号处理
- 离线批量推理任务
不适合场景
- 多进程:短小IO任务,进程创建开销远大于任务本身,得不偿失
- 多线程:大量矩阵运算、模型推理等纯CPU计算
六、极简代码样例(面试手写)
1. ThreadPoolExecutor 线程池(IO密集)
python
from concurrent.futures import ThreadPoolExecutor
import time
def task(x):
time.sleep(0.5) # 模拟IO等待
return x * 2
if __name__ == "__main__":
with ThreadPoolExecutor(max_workers=4) as pool:
res = pool.map(task, range(10))
print(list(res))
2. ProcessPoolExecutor 进程池(CPU密集)
python
from concurrent.futures import ProcessPoolExecutor
def heavy_task(x):
s = 0
for i in range(1000000):
s += i
return s + x
if __name__ == "__main__":
with ProcessPoolExecutor(max_workers=4) as pool:
ret = pool.map(heavy_task, range(8))
print(list(ret))
3. 线程安全,Lock 保护共享变量
python
import threading
count = 0
lock = threading.Lock()
def add():
global count
for _ in range(100000):
with lock:
count +=1
t1 = threading.Thread(target=add)
t2 = threading.Thread(target=add)
t1.start()
t2.start()
t1.join()
t2.join()
print(count)
七、面试对比速记表
| 项目 | 多线程 threading | 多进程 multiprocessing |
|---|---|---|
| 资源 | 共享同进程内存 | 独立内存空间 |
| GIL | 受GIL限制,CPU密集无法多核并行 | 每个进程独立GIL,可以多核并行 |
| 创建开销 | 小 | 大 |
| 通信 | 简单(Queue/共享变量+锁) | IPC复杂,开销高 |
| 适用 | IO密集:爬虫、DB、网络请求 | CPU密集:图像、数值计算、批量推理 |
| 崩溃影响 | 线程崩,整个进程挂 | 单个进程崩溃隔离 |
八、延伸高频追问
- 协程asyncio和多线程对比?(协程用户态轻量,无线程切换开销,单线程内IO多路复用)
- 多进程的共享内存
multiprocessing.Value/Array和Manager区别? - 任务队列 Celery底层是多进程/多线程?
- 多线程写文件怎么保证不混乱?
九、协程 asyncio
协程:用户态轻量级任务,由程序自身调度,不是操作系统内核线程;单线程内并发,无内核线程切换开销。
- 关键字:
async / await,事件循环event loop - 适用:超高并发IO场景(上万HTTP请求、大量数据库查询)
- 限制:遇到同步阻塞代码(
time.sleep、requests、普通pymysql)会卡住整个事件循环,必须使用异步库(aiohttp、aiomysql)
面试题
- asyncio 和 threading 多线程的区别?
- 协程:单线程,用户态调度,切换成本极低;没有锁竞争问题,但不能跑CPU密集任务;一旦有同步阻塞,整个循环卡死。
- 多线程:OS内核调度,切换开销更大;可同时处理同步IO;受GIL约束。
-
await 后面能跟什么?
必须是可等待对象:coroutine、Task、Future。普通函数不能直接await。
-
Task作用?
asyncio.create_task():把协程提交到事件循环后台并发执行。
极简示例
python
import asyncio
async def io_task(x):
await asyncio.sleep(0.5) # 异步sleep,不阻塞事件循环
return x*2
async def main():
tasks = [asyncio.create_task(io_task(i)) for i in range(5)]
res = await asyncio.gather(*tasks)
print(res)
asyncio.run(main())
选型口诀:
少量IO并发 → ThreadPoolExecutor
海量IO并发(几千上万连接)→ asyncio
CPU密集图像/模型推理 → ProcessPool
十、进阶高频坑点
1. 多进程 Pickle 序列化问题
multiprocessing / ProcessPoolExecutor 在spawn模式下,任务函数、入参需要pickle序列化传递到子进程。
- ❌ 不能用lambda、嵌套函数、部分动态类,容易报pickle error
- ❌ 闭包捕获大对象,序列化会拷贝大量数据,内存暴涨
- ✅ 方案:顶层普通函数;参数尽量传基础类型,不要传大对象
2. 线程池 max_workers 怎么设置?
- ThreadPoolExecutor:IO密集,可设较大,一般
10~50;受下游服务QPS限制,不是越大越好,避免打爆DB/接口 - ProcessPoolExecutor:CPU密集,一般等于CPU核心数,开太多会CPU抢占、上下文切换飙升,性能下降
3. 多进程共享内存:Value / Array vs Manager
Value/Array:真正共享内存,性能高,底层共享一块内存;适合简单数值、数组,需要手动加锁。Manager().list/dict:单独启动一个管理进程,子进程通过IPC访问,性能差,方便,适合简单业务,大批量数据不要用。
4. 为什么多线程中,Queue 推荐优先使用?
threading.Queue内部已经封装锁,线程安全,生产者消费者模型首选,避免自己手写Lock造成死锁。
multiprocessing.Queue 是进程安全队列,底层管道实现。
5. 多进程下全局变量修改为什么不生效?
Linux fork:子进程启动时复制父进程内存(写时复制);修改变量会触发内存拷贝,父子变量互相独立 。
Windows spawn:完全重新导入模块,变量从头初始化。
👉 结论:多进程不要依赖全局变量传数据。
6. 锁:Lock vs RLock
- Lock:互斥锁,同一线程不能acquire两次,会死锁。
- RLock(可重入锁):同一个线程可以多次acquire,必须对应相同次数release;适合嵌套加锁场景。
十一、生产者消费者模型(工程最常用,手写代码题)
多线程版本
python
import threading
from queue import Queue
import time
def producer(q:Queue):
for i in range(5):
q.put(i)
print(f"生产:{i}")
time.sleep(0.2)
def consumer(q:Queue):
while True:
item = q.get()
print(f"消费:{item}")
time.sleep(0.5)
q.task_done()
if __name__ == "__main__":
q = Queue(maxsize=3)
t1 = threading.Thread(target=producer, args=(q,))
t2 = threading.Thread(target=consumer, args=(q,))
t2.daemon = True
t1.start()
t2.start()
t1.join()
q.join()
print("全部任务完成")
面试考点:
q.task_done()和q.join()的配合。
十二、生产工程落地问题(业务侧)
- 多进程+GPU推理
重点!如果你批量跑图像推理
- 每个子进程独立CUDA上下文;进程开多了显存占用成倍上涨
- 推荐方案:单进程内多线程预处理 + 主进程GPU推理,不要开大量进程去创建CUDA上下文
- 或者用进程池控制进程数量等于GPU卡数
- 异常捕获问题
- 线程/进程内部异常,如果不捕获,会静默消失,主线程感知不到!
ThreadPoolExecutor的future.result()会把内部异常抛出来;手动Thread需要内部try-except。
- 优雅退出
- 不要暴力kill;用Event做退出信号,通知子线程/子进程收尾、刷写文件
- 守护线程不能用来做持久化写盘任务
十三、综合大题(手撕问答)
Q1:现在要写一个批量图片缺陷检测程序:读取图片(IO)+ 模型推理(CPU heavy),怎么设计并发架构?
参考答案:
- IO读取:多线程/asyncio 批量读取、解码图片
- 推理计算:进程池,分发图像张量到多个进程并行推理
- 中间用队列解耦:生产者(读图线程)→ 队列 → 消费者(推理进程池)
- 限制队列长度,防止图片堆积内存OOM
Q2:线上爬虫,目标网站限流,如何控制并发?
用 threading.Semaphore 信号量限制最大并发数,或者 ThreadPoolExecutor 的max_workers。
python
# 信号量示例
import threading
sem = threading.Semaphore(3) # 最多3个并发
def fetch(url):
with sem:
print("请求", url)
# http request
Q3:什么是孤儿进程、僵尸进程?
- 僵尸进程:子进程执行结束,父进程没有调用wait回收退出状态,进程残留PCB,占用PID。
- 孤儿进程:父进程提前退出,子进程被init进程收养,会自动回收。
Python multiprocessing,记得join或者及时回收,大量短任务容易堆积僵尸进程。
十四、思维导图式总结
- IO密集:优先 asyncio(高并发) / ThreadPoolExecutor(简单同步代码)
- CPU密集:ProcessPoolExecutor,避开GIL,利用多核
- 多线程:共享内存,注意锁、竞态;IO场景收益高
- 多进程:内存隔离,IPC成本高;适合图像、批量推理;小心pickle、显存拷贝
- 协程:单线程高并发IO,不能执行同步阻塞/CPU heavy代码
十五、延伸追问清单(继续深挖的问题)
- concurrent.futures 的 cancel() 能取消正在运行的任务吗?
- 信号量Semaphore和BoundedSemaphore区别?
- 进程间共享内存,多进程读写需要加锁吗?
- Celery 任务是多进程还是多线程?如何避免任务死锁?
- 多线程写同一个日志文件,怎么保证日志不混乱?