ThreadPoolExecutor(max_workers) vs threading.Semaphore
为什么有了ThreadPoolExecutor(max_workers)设置了最大数量,还需要threading.Semaphore,两者什么时候选哪个?
两者都能做「最多N个同时执行」,但是能力范围不一样,不是完全等价。
1、ThreadPoolExecutor(max_workers=N)
with ThreadPoolExecutor(max_workers=2) as pool:
for i in range(10):
pool.submit(worker, i)
它控制的是:这个线程池内部,最多同时有 N 个 worker OS 线程在运行任务。
特点:
- 管控范围:只对提交到这个线程池里面的任务生效。外面自己手动开的
Thread()完全不受它约束。 - 队列在线程池内部:任务提交多了,在池的内部队列排队。
- 职责:线程复用、生命周期管理、异常捕获,一站式。
👉 局限:只能管控交给自己的任务。别的地方新开线程,它管不到。
举例子:
你代码里有两处并发逻辑:A模块用线程池,B模块直接Thread(…).start()。max_workers 只能限制A模块,B模块疯狂开线程它拦不住。
2、threading.Semaphore(N)
sem = Semaphore(2)
def worker(i):
sem.acquire()
# 业务
sem.release()
# 不管你怎么开线程都受限制
for i in range(10):
Thread(target=worker,args=(i,)).start()
信号量是一个独立的计数器对象。
- 不管这个任务是来自
ThreadPoolExecutor,还是手动Thread(),只要代码里执行sem.acquire(),就会受限流。 - 管控的不是线程,而是业务逻辑的临界区代码。
👉 重点区别:
max_workers:限制最多多少个worker线程。threading.Semaphore:限制最多多少个任务可以进入某一段代码。
什么时候两者效果一样?
所有任务全部丢进同一个线程池,每个任务一进来就立刻访问受信号量保护的代码。
此时 max_workers=3 和 Semaphore(3) 看起来效果几乎一样。
什么时候必须用 Semaphore,不能用 max_workers?
场景①:多处来源的任务,要全局统一限流
比如:
- 一处是 ThreadPoolExecutor 的任务
- 另一处是别的地方手动创建的 Thread
- 两处都要调用同一个外部API,希望全局最多同时2次网络请求。
线程池A的max_workers只管自己池子里的任务,管不到另一组手动开的线程。
这时就搞一个全局 sem = threading.Semaphore(2),所有要调用API的地方,先acquire,不管线程从哪来,统一限流。
类比iOS:多处地方往不同GCD队列丢任务,但是希望全局最多3个网络并发,就用一个全局
DispatchSemaphore,各个队列的任务进来先wait。GCD队列本身只能管控自己队列,做不到跨队列全局限流。
场景②:同一个线程内部,多次进入临界区(重入)
threading.Semaphore支持重入;还有专门threading.BoundedSemaphore。
注意:不是RLock,Semaphore可以一次拿多个许可
acquire(blocking=True,timeout=None)。
比如一个任务进来,可以占用2个许可,代表这个任务“消耗两份并发额度”。
ThreadPool做不到:线程就代表1份,没有“一份任务占多份额度”的概念。
举个例子:有的大文件任务权重高,一个任务相当于2个普通请求,希望占用2个信号量许可。这只能Semaphore实现。
场景③:部分逻辑要限流,部分逻辑不限流
一个线程内部:
- 跑一些轻量计算,不需要限流
- 调用外部MinerU API,这里才要限流
def worker():
# 这里随便跑,不限流
cpu_work()
sem.acquire()
call_mineru_api() # 仅仅这一段受并发控制
sem.release()
如果你用max_workers,只要任务提交进线程池,整个任务的生命周期就占用一条线程。哪怕大部分时间在跑无关代码,线程资源也被占住。
信号量可以做到:只有真正调用API那一小段逻辑做闸门,前面后面的代码不受限。
那什么时候直接用 ThreadPoolExecutor(max_workers),不需要再加Semaphore?
绝大多数普通业务场景:
所有并发任务全部交给这一个线程池,整个任务生命周期都差不多是IO,不需要精细控制局部代码。
就直接用max_workers=N,足够,代码更简洁,不需要额外信号量。
容易踩坑:两者叠加使用
# 线程池最多5条线程,信号量只允许2个进入API调用
with ThreadPoolExecutor(max_workers=5) as pool:
pool.submit(task)
会出现:5条OS线程都启动了,其中3条线程卡在sem.acquire()原地阻塞休眠。
5个操作系统线程被创建出来,其中3个就干等休眠,白白占用OS线程资源。
👉 不推荐这么写。
正确做法:如果用线程池做IO限流,尽量把max_workers直接设置成并发上限,不要依靠信号量去二次闸门。
总结一张表
| 方案 | 管控对象 | 管控范围 | 适合场景 |
|---|---|---|---|
| ThreadPoolExecutor(max_workers) | worker操作系统线程 | 仅本线程池提交的任务 | 全部任务交给本池,简单IO并发,代码简洁 |
| threading.Semaphore | 临界区代码段 | 所有调用acquire的线程,不管来自哪里 | 跨多处并发源全局限流;部分代码段限流;任务消耗多份额度 |
映射回iOS GCD:
- ThreadPoolExecutor ≈ 一个GCD自定义并发队列,设置
DispatchQueue.init(attributes: .concurrent, maxConcurrentOperationCount = 3),只管控这个队列内部。- threading.Semaphore ≈
DispatchSemaphore,全局对象,所有队列的任务都可以来wait,跨队列做并发控制。
回到MinerU那个业务
- 如果用同步
requests + ThreadPoolExecutor,直接设置max_workers=3就够,一般不需要再加threading.Semaphore。 - 如果你的项目里面:多处不同模块,不同线程来源,都调用MinerU API,想要全局控制总并发,那就搞一个全局
threading.Semaphore(3)。
而异步aiohttp那套,就完全是另一套:用
asyncio.Semaphore,它不碰OS线程。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐
所有评论(0)