在文章subprocess模块用法中介绍了Python中的threading.Thread.join()
时不能响应信号的问题。这个问题被Python官方标记为Bug。
Python官方的Issue指出这个Bug与Python的signal、基础线程库thread(C实现)和高级线程库threading(Python封装)都有关,下面首先概览这三个模块的实现,接着通过编译调试的方式来观赏这个Bug的具体过程。
一个简单的测试程序
这里提供一个可以在Ubuntu 16.04上的Python2.7上重现的代码片段。主程序调用threading_timeout_test
时能够正常响应SIGINT
等信号,调用threading_test
时不能正常响应信号。
1 | import sys, os, signal, subprocess, multiprocessing, time |
ps一下发现此时进程处于Sl
状态。
Python中的signal机制
在signalmodule的开头注释中我们看到Python的信号只能为主线程所设置和捕获,这和POSIX原生signal不同了。在POSIX中,信号是传递给整个进程中的随机线程的。我们偏向于通过设置信号屏蔽字的方式,或者借助sigwait
,让一个线程专门等待信号,这样将一个单线程完全异步的逻辑变为了同步的逻辑。
1 | // signalmodule.c |
我个人理解上面这一段话的意思是Python为了简化在不同OS上的处理,选择为信号处理增加了限制。
Python在signal_signal
中负责注册信号。
1 | // thread_pthread.h |
Python在signal_handler
中负责处理信号。
1 | // signalmodule.c |
出于方便说明的考虑,我们将在后面查看Py_AddPendingCall
的定义。
Python的threading模块
threading.Thread.join()方法
Python的低级线程模块thread
并没有提供join
的原语,threading.Thread.join()
在Python层面进行了封装实现。
1 | // threading.py |
我们注意if timeout is None:
这个条件,两个分支的代码相差无几,但在我的Ubuntu 16.04上的Python2.7.12中分别使用或不使用timeout进行join,却一个不能响应SIG,一个可以。显而易见,原因在self.__block.wait()
这个方法中。而self.__block.wait()
实际上在一个条件变量上进行等待。
Condition条件变量
从上面的代码我们能看到self.__block
实际上是个条件变量_Condition
,这个_Condition
实现地比较简陋,它并不会像C++中的std::condition_variable
一样隔段时间解锁看看条件是否满足,事实上它根本就不接受一个condition参数,共享同一个_Condition
的线程通过wait
这个_Condition
上的事件,或者notify
系列向这个_Condition
通告这个事件。此外,_Condition
将互斥锁也整合了进去,我们不要在外面挂一个mutex之类的东西了。
1 | # threading.py |
从上面的代码我们可以看到一旦调用waiter.acquire()
,程序就不能响应信号了,我们接下来到thread
模块中看这个函数的实现。
thread模块
Pythread锁
承接上文,我们看到thread._allocate_lock
实际调用了thread_PyThread_allocate_lock()
创建了一个lockobject
对象。lockobject
对象中包含了一个lock_lock
,这个实际上是一个PyThread_type_lock
对象,由PyThread_allocate_lock()
创建,也就是锁基于操作系统API的具体实现,我们将在稍后看到。
1 | // pythread.h |
PyThread_allocate_lock
系列函数的实现因系统而异。以Linux为例有两种方式,分别基于sem_t
和基于pthread_mutex
/pthread_cond
的。
我们首先查看基于信号量的机制
1 | // thread_pthread.h |
另一种方式是使用mutex和CV的经典实现,由于实际上没有用到,所以单独讨论。
观赏Bug形成过程
在先前的讨论中我们已经确定了问题的所在是waiter.acquire()
这个方法,对应到CPython内部就是PyThread_allocate_lock
这个函数。PyThread_allocate_lock
函数根据宏的不同选项有两种实现方式,在我的Ubuntu上提供了sem_t
,所以默认使用sem_t
的实现。我们跟踪这个PyThread_allocate_lock
,发现这个函数能够正常加解锁,但是发送SIGINT信号却不能打断程序。
那么究竟是Python直接屏蔽了Native POSIX signal,还是出于其他的原因?为此重新编译了Python 2.7.6并进行按照下图打了Log进行调试。
得到结果如下图。
注意其中的^CSIG 2
,这是我在signalmodule中的signal_handler
函数的开头设置打印语句,此时Ctrl+C能够输出SIG 2
,并且通过了if (getpid() == main_pid)
的检测,到达了trip_signal
。我们在这个函数中输出了Add pending 2 callback
,和我们之前注册的时候相同。接着trip_signal
调用了Py_AddPendingCall
。
我们接下来查看Py_AddPendingCall
的代码,他在ceval.c里面,这个文件也是Python的main loop的所在地。
经过调试,Py_AddPendingCall
中记录了这个信号已经被成功加到了pendingcalls[0]
。截止目前已发现Python在POSIX层面是收到了SIG 2
,并且挂载下半部程序。因此可以初步断定异常原因是信号处理下半部并没有能够被运行。
1 | // ceval.c |
使用Py_AddPendingCall
加入队列pendingcalls
中的信号将会在Py_MakePendingCalls
中被真正处理,这有点类似Linux中断下半部的机制。这里的func
实际上是trip_signal
调用Py_AddPendingCall
的第一个参数checksignals_witharg
,也是一个函数。checksignals_witharg
这个函数很短,只调用了PyErr_CheckSignals
这个函数。我们下面查看具体代码,需要注意Handlers[i].func
和pendingcalls[j].func
不一样。
1 | // signalmodule.c |
刚才我们已经知道Python使用Py_MakePendingCalls
往下的调用链Py_MakePendingCalls -> checksignals_witharg(即pendingcalls[j].func) -> PyErr_CheckSignals -> Handlers[i].func
,而Py_MakePendingCalls
这个函数在当前栈帧的主循环PyEval_EvalFrameEx
中被以一定的Tick的时间间隔被调用。
1 | /* for manipulating the thread switch and periodic "stuff" - used to be |
下面的图来自dabeaz,形象地展示了上面源码所描述的Python线程切换的过程。
现在我们发现一个问题,在上面的代码中Py_MakePendingCalls Failed
输出了,这意味着我们的信号处理函数没有成功。为什么没有成功呢?我们回看Py_MakePendingCalls
的Log输出在Py_MakePendingCalls: before checking main thread
戛然而止,说明此时的线程并不是主线程!我们进一步查看线程调度情况,发现在主线程调用join()
之后就一直睡眠了,其中唯一一次唤醒就是收到了SIGINT,主线程将这个信号放到pendingcalls
之后又回去睡觉了,之后虽然子线程屡次调用Py_MakePendingCalls
检查到了有待处理的信号,但由于自己不是主线程所以也是爱莫能助。
下面我们着手解决这个问题,一个简单的方法就是让子线程也可以处理由主线程添加到pendingcalls
中的信号,于是我们对代码中进行两处修改:
- 注释掉
PyErr_CheckSignals
中的if (PyThread_get_thread_ident() != main_thread)
- 注释掉
Py_MakePendingCalls
中的if (main_thread && PyThread_get_thread_ident() != main_thread)
再次编译调试,发现可以正常退出了
杂项函数的实现
线程创建
1 | // thread.c |
线程状态转移
1 | // pystate.c |
CV+Mutex实现锁
1 | // thread_pthread.h |