为什么有了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 线程在运行任务。

特点:

  1. 管控范围:只对提交到这个线程池里面的任务生效。外面自己手动开的 Thread() 完全不受它约束。
  2. 队列在线程池内部:任务提交多了,在池的内部队列排队。
  3. 职责:线程复用、生命周期管理、异常捕获,一站式。

👉 局限:只能管控交给自己的任务。别的地方新开线程,它管不到。

举例子:
你代码里有两处并发逻辑: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=3Semaphore(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实现。

场景③:部分逻辑要限流,部分逻辑不限流

一个线程内部:

  1. 跑一些轻量计算,不需要限流
  2. 调用外部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线程。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐