python 中全局锁理解
def make_async( func: Callable[P, T], executor: Optional[concurrent.futures.Executor] = None ) -> Callable[P, Awaitable[T]]: """Take a blocking function, and run it on in an executor thread. This function prevents the blocking function from blocking the asyncio event loop. The code in this function needs to be thread safe. """ def _async_wrapper(*args: P.args, **kwargs: P.kwargs) -> asyncio.Future: loop = asyncio.get_event_loop() p_func = partial(func, *args, **kwargs) return loop.run_in_executor(executor=executor, func=p_func) return _async_wrapper self._tokenizer_executor = ThreadPoolExecutor(max_workers=1) self._tokenize_prompt_input_async = make_async( self._tokenize_prompt_input, executor=self._tokenizer_executor) 上述代码中,如果并发调用_tokenize_prompt_input_async,实际上会进行排队吗? 为什么增加线程池的个数后,不会阻塞,不是还有全局锁吗?
很好,这是一个关于Python并发和异步编程的深入问题。我们来逐步分析:
1. 并发调用 _tokenize_prompt_input_async 会排队吗?
是的,会排队。
原因如下:
-
你创建了一个
max_workers=1的ThreadPoolExecutor -
这个执行器只有一个工作线程
-
make_async函数将同步函数包装为异步函数,但实际执行仍在指定的线程池中 -
当多个协程同时调用
_tokenize_prompt_input_async时,它们都会将任务提交到同一个单线程执行器 -
由于只有一个工作线程,任务会按提交顺序依次执行,形成队列
2. 为什么增加线程池的线程数后不会阻塞?
增加线程数确实可以减少阻塞,但原因需要澄清:
关于GIL(全局解释器锁)的误解:
-
GIL确实存在,它确保同一时刻只有一个线程执行Python字节码
-
但是:GIL不会阻止I/O操作、系统调用或某些C扩展中的线程并行
-
当线程执行I/O操作(如文件读写、网络请求)时,会释放GIL,允许其他线程运行
在你的场景中:
-
单线程情况:所有
_tokenize_prompt_input调用必须排队,一个接一个执行 -
多线程情况:
-
如果
_tokenize_prompt_input是CPU密集型操作:由于GIL,多个线程可能不会真正并行执行,但仍然可以并发执行(通过GIL的切换) -
如果
_tokenize_prompt_input包含I/O操作:线程在等待I/O时会释放GIL,其他线程可以继续执行,从而实现更好的并发性
-
3. 关键点总结:
| 情况 | 效果 | 原因 |
|---|---|---|
max_workers=1 | 严格排队 | 单工作线程,任务顺序执行 |
max_workers>1 + CPU密集型 | 有限并发 | GIL限制真正并行,但线程切换提供并发 |
max_workers>1 + I/O密集型 | 良好并发 | I/O操作释放GIL,线程可真正并行处理 |
4. 建议:
-
如果你的
_tokenize_prompt_input是纯CPU操作,增加线程数可能效果有限 -
如果是I/O密集型,增加线程数会显著提高吞吐量
-
考虑使用
ProcessPoolExecutor来完全避开GIL(适用于CPU密集型任务)
这样设计确实可以防止阻塞事件循环,因为所有同步操作都在单独的线程中执行。
如果当线程池个数为1的时候,如果遇到io等操作时,该线程也会让其他线程运行吗?
这是一个非常好的问题,它触及了Python并发模型的核心。答案是:
不会。在单个线程池线程中遇到I/O操作时,它不会让同一个线程池中的“其他线程”运行,因为它根本没有“其他线程”(max_workers=1)。但是,它会释放GIL,让主线程(asyncio事件循环)和其他Python线程运行。
让我们详细分解一下:
关键概念澄清
-
你的线程池:只有1个工作线程,我们称它为 "Worker-1"
-
asyncio事件循环:运行在主线程,我们称它为 "Main-Thread"
-
GIL(全局解释器锁):控制谁可以执行Python代码的系统
执行流程(当Worker-1遇到I/O时)
-
提交任务:多个协程调用
_tokenize_prompt_input_async,任务在Worker-1后排队。 -
Worker-1执行:Worker-1从队列中取出第一个任务开始执行。
-
遇到I/O操作:当Worker-1执行到代码中的I/O操作时(例如
time.sleep()、读取文件、网络请求):-
关键动作:Worker-1线程会立即释放GIL。
-
操作系统接管:实际的I/O等待由操作系统内核处理,Worker-1线程被挂起,不消耗CPU。
-
-
GIL的归属:此时GIL被释放,变得“可用”。
-
谁可以获取GIL:
-
asyncio事件循环(Main-Thread):这是最可能获取到GIL的线程。因为它一直在运行(处理事件、调度协程),所以它会立刻拿到GIL并继续执行其他已经就绪的协程任务。这就是为什么事件循环不会被阻塞的原因。
-
其他Python线程:如果你的程序还有其他线程(例如另一个后台线程),它们也有机会竞争到GIL并运行。
-
Worker-1自己:当它的I/O操作完成后,它会重新加入GIL的竞争,尝试抢回锁以继续执行后续代码。
-
总结与类比
| 场景 | 效果 | 解释 |
|---|---|---|
| 单线程Worker遇到CPU操作 | 阻塞Worker和事件循环 | Worker-1持有GIL进行计算,事件循环抢不到GIL,被阻塞。 |
| 单线程Worker遇到I/O操作 | 不阻塞事件循环 | Worker-1主动释放GIL,事件循环可以立即获取GIL并继续工作。 |
| 多线程Workers遇到I/O | 更好地利用等待时间 | Worker-1在等I/O时释放GIL,线程池的Worker-2可以立即获取GIL并执行下一个排队的任务。 |
所以,增加线程池的大小最大的好处是:当一个线程在等待I/O时,另一个线程可以立刻拿到GIL去执行其他排队的任务,从而更充分地利用CPU时间,提高整体的吞吐量。
在你的例子中,即使只有一个线程池线程,asyncio事件循环也不会被该线程的I/O操作所阻塞,这已经达到了主要目的。增加线程数是为了让线程池本身能并发处理多个排队任务。
魔乐社区(Modelers.cn) 是一个中立、公益的人工智能社区,提供人工智能工具、模型、数据的托管、展示与应用协同服务,为人工智能开发及爱好者搭建开放的学习交流平台。社区通过理事会方式运作,由全产业链共同建设、共同运营、共同享有,推动国产AI生态繁荣发展。
更多推荐


所有评论(0)