python的多线程,这是个老生常谈的话题了,网上资料也一大把。python默认的threading
模块对多线程提供了支持,但实际多个threading.Thread
实例无法并行运行(不是无法并发哦!)。
一句话概括答案:python的线程实质是操作系统原生的线程,而每个线程要执行python代码的话,需要获得对应代码解释器的锁GIL。一般我们运行python程序都只有一个解释器,这样不同线程需要获得同一个锁才能执行各自的代码,互斥了,于是代码就不能同时运行了。
好的,接下来我们细细讲解这句话背后的故事:
多线程并行测试
首先我们通过一些代码来测试多线程是否真的并行:
1 | import threading |
这是个测试代码,总共执行COUNT次+=1的操作,一个是单线程,一个是多线程。出来的结果,两种方式没有明显的时间上的差异:
1 | [SINGLE_THREAD] start by count pair: (0, 100000000) |
这也侧面证明了基于threading.Thread
的多线程并非完全并行运行的。为了深入确认这个问题,我们需要看下python内部的线程运行的机制
线程是如何启动的
我们首先看下线程启动的过程,直接从Thread
类的start
方法开始:
1 | class Thread: |
start
方法中,从行为上来看是这么一个逻辑:
- 首先会检查
Thread
实例是否已经初始化,是否没有启动过 - 然后会把自己加入到
_limbo
中 - 调用启动线程的方法
_start_new_thread
,把自己的_bootstrap
方法也带进去_bootstrap
是python端最终开始线程任务所调用的逻辑,是在新线程里运行的!后面会慢慢看到
- 等待
self._started
(一个Event实例)的信号
首先来看_limbo
的作用。玩游戏玩多的同学都知道,limbo
叫做“地狱边境”,如果再查下字典的话,我们可以将之简单理解为“准备态”。先记录线程为“准备态”,然后才会开始真正执行线程启动的过程。线程启动的过程是在C层进行的,我们点进_start_new_thread
的定义就能看到python层是没有对应的代码的。
1 | // _threadmodule.c |
python中的_start_new_thread
,对应了C层的thread_PyThread_start_new_thread
,而thread_PyThread_start_new_thread
传入的两个参数self
跟fargs
,则对应python代码里的self._bootstrap
跟空的tuple。_start_new_thread
的大致步骤如下:
- 解包
fargs
,并检查合法性。这里由于进了空的tuple,所以暂时不需要过多分析。 - 设置
bootstate
实例boot
- 一个
bootstate
是fargs
以及对应的thread state
、intepreter state
以及runtime state
的打包,囊括了启动新线程需要有的信息
- 一个
- 调用
PyThread_start_new_thread
函数,把bootstate
实例以及一个回调函数t_bootstrap
传进去- 其返回值是线程的实例ID,在python端,我们也可以通过线程实例的
ident
属性得到。 t_bootstrap
回调函数,是需要在新启动的子线程里运行的!
- 其返回值是线程的实例ID,在python端,我们也可以通过线程实例的
PyThread_start_new_thread
函数,根据不同操作系统环境有不同的定义。以windows环境为例,其定义如下:
1 | // thread_nt.h |
参数func
和arg
,对应的是t_bootstrap
回调跟bootstate
实例。为了适配windows
下的_beginthreadex
接口定义,t_bootstrap
跟bootstate
实例又打包成callobj
,作为bootstrap
函数(适配用)的参数,随bootstrap
一起入参_beginthreadex
。
这时候我们已经可以确定,python启动的新线程是操作系统的原生线程。
新线程诞生时,调用了bootstrap
,在bootstrap
里拆包callobj
,调用func(arg)
,也就是t_bootstrap(boot)
1 | // _threadmodule.c |
回到t_bootstrap
中,我们发现,最终t_bootstrap
会取出来boot
的func
&args
,然后调用PyObject_Call
调用func(args)
。回到前面去看,这个func(args)
就是python端的self._bootstrap()
1 | class Thread: |
在self._bootstrap_inner()
中,大致有以下步骤:
- notify
self._started
,这样先前python端的start
函数流程就完成了 - 把自己从准备态
_limbo
中移除,并把自己加到active
态里 - 执行
self.run
,开始线程逻辑
这样,python中新线程启动的全貌就展现在我们面前了。除了线程的来源外,很多关于线程相关的基础问题(比如为啥不能直接执行self.run
),答案也都一目了然
线程执行代码的过程
在先前一小节我们知晓了python新的线程从何而来,然而,只有通过剖析线程执行代码的过程,我们才可以明确为什么python线程不能并行运行。
一个线程执行其任务,最终还是要落实到run
方法上来。首先我们通过python自带的反编译库dis
来看下Thread的run函数对应的操作码(opcode),这样就通过python内部对应opcode的执行逻辑来进一步分析:
1 | class Thread: |
其中真正执行函数的一行self._target(*self._args, **self._kwargs)
,对应的opcodes是:
1 | 910 8 LOAD_FAST 0 (self) |
很明显,CALL_FUNCTION_EX
——调用函数,就是我们需要找到的opcode。
1 | // ceval.c |
在ceval.c
中,超大函数_PyEval_EvalFrameDefault
就是用来解析opcode的方法,在这个函数里可以检索opcode研究对应的逻辑。找到CALL_FUNCTION_EX
对应的逻辑,我们可以分析函数调用的过程,顺藤摸瓜,最终会落实到这里:
1 | // call.c |
在_PyObject_Call
中,调用函数的方式最后都以通用的形式(vectorcall
以及Py_TYPE(callable)->tp_call
)呈现,这说明入参不同的callable
,可能需要不同的caller方法来handle。基于此,我们可以通过直接debug线程类Thread的run
方法(在主线程直接跑就行了),来观察线程run函数调用的过程。测试代码如下:
1 | from threading import Thread |
t.run
中的self._target(*self._args, **self._kwargs)
一行触发了_PyObject_Call
中PyVectorcall_Call
分支。一路step into下去,最终来到了_PyEval_EvalFrame
函数:
1 | static inline PyObject* |
frame
就是python函数调用栈上面的单位实例(类似于lua的callinfo
),包含了一个函数调用的相关信息。eval_frame
就是对frame
保存的code
(代码)实例解析并执行。解释器用的是tstate->interp
,从先前线程启动的逻辑来看,在thread_PyThread_start_new_thread
里,主线程就把自己的interp
给到子线程了,所以不管创建多少个线程,所有线程都共用一套解释器。那解释器的eval_frame
是什么呢?兜兜转转,又回到了超大函数_PyEval_EvalFrameDefault
。
从_PyEval_EvalFrameDefault
的main_loop
这个goto记号往下,就是无限循环处理opcode了。但在switch opcode之前,有一个判断逻辑:
1 | // ceval.c |
这段代码首先会判断代码解释是否达到中断条件eval_breaker
,如果达到了的话,可能会走到eval_frame_handle_pending
处理中断。
1 | // ceval.c |
eval_frame_handle_pending
处理了多种opcode解析中断的场景。在这里我们可以看到,不论是哪个线程跑到这里,如果遇到了gil_drop_request
,就得drop_gil
给到其他线程,之后再尝试take_gil
,重新竞争解释器锁。
在先前讲解线程启动逻辑的时候,新线程调用的t_bootstrap
函数里,有一句PyEval_AcquireThread(tstate)
,这里面就包含了take_gil
的逻辑。我们可以看一下take_gil
到底干了什么事情:
1 | // ceval_gil.h |
我们看到这段逻辑:当gil
一直被占时,就会进入while循环的COND_TIMED_WAIT
,等待gil->cond
的信号。这个信号的通知逻辑是在drop_gil
的里面的,也就是说如果另一个线程执行了drop_gil
就会触发这个信号,而由于python的线程是操作系统原生线程,因此我们如果深挖COND_TIMED_WAIT
内部的实现也可以看到实质上是操作系统在调度信号触发后线程的唤醒。COND_TIMED_WAIT
的时长是gil->interval
(也就是sys.getswitchinterval()
,线程切换时间),过了这段时间还是原来线程hold住gil
的话,就强制触发SET_GIL_DROP_REQUEST
逻辑
1 | static inline void |
我们看到SET_GIL_DROP_REQUEST
强制激活gil_drop_request
跟eval_breaker
,这样持有GIL的线程在EvalFrame
的时候发现满足eval_breaker
,就会走eval_frame_handle_pending
的逻辑,里面再判断有gil_drop_request
之后,就调用drop_gil
把解释器锁释放出来。这样,另一个线程在执行SET_GIL_DROP_REQUEST
之后的某次COND_TIMED_WAIT
时候,就有可能提前被signal到,之后又发现gil
没有被locked,于是就能够继续下面的逻辑,持有GIL了。最后,另一个线程拿到了代码的执行权,而原先丢掉GIL的线程,在eval_frame_handle_pending
再次调用take_gil
,反过来走在了竞争代码执行权的路上。循环往复,如是而已。
总结
通过对线程启动机制、代码运行机制以及基于GIL的线程调度机制的剖析,我们可以“一图流”,解释“python多线程为什么不能并行”这个问题: