Python 的 signal 处理与 print() 的 reentrant call 运行时错误

在前文《为何 Windows 下无法用 Ctrl+C 终止 Python 进程》中,讲解了 Python 信号处理的基本原理。当时为了撰写文章而编写了一些测试代码,在运行某例测试代码时,发生了奇怪的 reentrant call 运行时错误。代码如下:

pythonimport signal

signal.signal(signal.SIGINT, lambda signum, frame: print('test'))
while True: print('test')

在程序运行中按下 Ctrl+C,程序抛出 RuntimeError 异常。完整错误信息如下:

Traceback (most recent call last):
  File "test.py", line 4, in <module>
    while True: print('test')
                ^^^^^^^^^^^^^
  File "test.py", line 3, in <lambda>
    signal.signal(signal.SIGINT, lambda signum, frame: print('test'))
                                                       ^^^^^^^^^^^^^
RuntimeError: reentrant call inside <_io.BufferedWriter name='<stdout>'>

错误信息中所提到的 reentrant call 是一个计算机术语,它的意思是程序或者子例程(subroutine)在执行过程中被中断,此时再次调用该程序或者子例程。由于 Python 的信号处理器是运行在主线程中,所以不存在多线程冲突的可能。因此,猜想错误可能是由如下的代码引起的:

pythonentrant_status = 0

def print(text):
    if (entrant_status > 0):
        raise RuntimeError(
            "reentrant call inside <_io.BufferedWriter name='<stdout>'>")

    entrant_status = 1
    
    '''
    此时因按下 Ctrl+C 而产生软中断
    Python 中止执行此处代码
    转而调用 signal_handler()
    '''
    signal_handler(signum, frame)
    
    entrant_status = 0
    return

def signal_handler(signum, frame):
    print('test')
    
print('test')

但是根据 Python 官方文档的描述,Python VM 是在两条 bytecode 指令之间调用信号处理器的。而 print() 属于 builtin function,调用 print() 只需要一条 CALL_FUNCTION 指令。那么唯一的可能就是,官方文档的描述并不准确,Python 还有其他唤起信号处理器的方式。看来只能从 Python 源代码中去寻找答案了。

根据错误提示,在 Python 的源代码中搜索「reentrant call inside」,发现 Modules/_io/bufferedio.c 文件的第 262 行高度疑似为抛出异常的代码。此处附近完整的代码如下:

cstatic int
_enter_buffered_busy(buffered *self)
{
    int relax_locking;
    PyLockStatus st;
    if (self->owner == PyThread_get_thread_ident()) {
        PyErr_Format(PyExc_RuntimeError,
                     "reentrant call inside %R", self);
        return 0;
    }
    relax_locking = _Py_IsFinalizing();
    Py_BEGIN_ALLOW_THREADS
    if (!relax_locking)
        st = PyThread_acquire_lock(self->lock, 1);
    else {
        /* When finalizing, we don't want a deadlock to happen with daemon
         * threads abruptly shut down while they owned the lock.
         * Therefore, only wait for a grace period (1 s.).
         * Note that non-daemon threads have already exited here, so this
         * shouldn't affect carefully written threaded I/O code.
         */
        st = PyThread_acquire_lock_timed(self->lock, (PY_TIMEOUT_T)1e6, 0);
    }
    Py_END_ALLOW_THREADS
    if (relax_locking && st != PY_LOCK_ACQUIRED) {
        PyObject *ascii = PyObject_ASCII((PyObject*)self);
        _Py_FatalErrorFormat(__func__,
            "could not acquire lock for %s at interpreter "
            "shutdown, possibly due to daemon threads",
            ascii ? PyUnicode_AsUTF8(ascii) : "<ascii(self) failed>");
    }
    return 1;
}

#define ENTER_BUFFERED(self) \
    ( (PyThread_acquire_lock(self->lock, 0) ? \
       1 : _enter_buffered_busy(self)) \
     && (self->owner = PyThread_get_thread_ident(), 1) )

RuntimeError 是在 _enter_buffered_busy 函数中被抛出的,而该函数被 ENTER_BUFFERED 宏所调用:当使用 PyThread_acquire_lock() 获取锁失败时便会抛出异常。进一步在当前文件中搜索ENTER_BUFFERED,发现第 1916 行处的调用可能是抛出异常的上层函数,该函数名为 io_BufferedWriter_write_impl,正好和错误信息中的 <_io.BufferedWriter name='<stdout>'> 相对应。该函数的完整代码如下:

c/*[clinic input]
_io.BufferedWriter.write
    buffer: Py_buffer
    /
[clinic start generated code]*/

static PyObject *
_io_BufferedWriter_write_impl(buffered *self, Py_buffer *buffer)
/*[clinic end generated code: output=7f8d1365759bfc6b input=dd87dd85fc7f8850]*/
{
    PyObject *res = NULL;
    Py_ssize_t written, avail, remaining;
    Py_off_t offset;

    CHECK_INITIALIZED(self)

    if (!ENTER_BUFFERED(self))
        return NULL;

    /* Issue #31976: Check for closed file after acquiring the lock. Another
       thread could be holding the lock while closing the file. */
    if (IS_CLOSED(self)) {
        PyErr_SetString(PyExc_ValueError, "write to closed file");
        goto error;
    }

    /* Fast path: the data to write can be fully buffered. */
    if (!VALID_READ_BUFFER(self) && !VALID_WRITE_BUFFER(self)) {
        self->pos = 0;
        self->raw_pos = 0;
    }
    avail = Py_SAFE_DOWNCAST(self->buffer_size - self->pos, Py_off_t, Py_ssize_t);
    if (buffer->len <= avail) {
        memcpy(self->buffer + self->pos, buffer->buf, buffer->len);
        if (!VALID_WRITE_BUFFER(self) || self->write_pos > self->pos) {
            self->write_pos = self->pos;
        }
        ADJUST_POSITION(self, self->pos + buffer->len);
        if (self->pos > self->write_end)
            self->write_end = self->pos;
        written = buffer->len;
        goto end;
    }

    /* First write the current buffer */
    res = _bufferedwriter_flush_unlocked(self);
    if (res == NULL) {
        Py_ssize_t *w = _buffered_check_blocking_error();
        if (w == NULL)
            goto error;
        if (self->readable)
            _bufferedreader_reset_buf(self);
        /* Make some place by shifting the buffer. */
        assert(VALID_WRITE_BUFFER(self));
        memmove(self->buffer, self->buffer + self->write_pos,
                Py_SAFE_DOWNCAST(self->write_end - self->write_pos,
                                 Py_off_t, Py_ssize_t));
        self->write_end -= self->write_pos;
        self->raw_pos -= self->write_pos;
        self->pos -= self->write_pos;
        self->write_pos = 0;
        avail = Py_SAFE_DOWNCAST(self->buffer_size - self->write_end,
                                 Py_off_t, Py_ssize_t);
        if (buffer->len <= avail) {
            /* Everything can be buffered */
            PyErr_Clear();
            memcpy(self->buffer + self->write_end, buffer->buf, buffer->len);
            self->write_end += buffer->len;
            self->pos += buffer->len;
            written = buffer->len;
            goto end;
        }
        /* Buffer as much as possible. */
        memcpy(self->buffer + self->write_end, buffer->buf, avail);
        self->write_end += avail;
        self->pos += avail;
        /* XXX Modifying the existing exception e using the pointer w
           will change e.characters_written but not e.args[2].
           Therefore we just replace with a new error. */
        _set_BlockingIOError("write could not complete without blocking",
                             avail);
        goto error;
    }
    Py_CLEAR(res);

    /* Adjust the raw stream position if it is away from the logical stream
       position. This happens if the read buffer has been filled but not
       modified (and therefore _bufferedwriter_flush_unlocked() didn't rewind
       the raw stream by itself).
       Fixes issue #6629.
    */
    offset = RAW_OFFSET(self);
    if (offset != 0) {
        if (_buffered_raw_seek(self, -offset, 1) < 0)
            goto error;
        self->raw_pos -= offset;
    }

    /* Then write buf itself. At this point the buffer has been emptied. */
    remaining = buffer->len;
    written = 0;
    while (remaining > self->buffer_size) {
        Py_ssize_t n = _bufferedwriter_raw_write(
            self, (char *) buffer->buf + written, buffer->len - written);
        if (n == -1) {
            goto error;
        } else if (n == -2) {
            /* Write failed because raw file is non-blocking */
            if (remaining > self->buffer_size) {
                /* Can't buffer everything, still buffer as much as possible */
                memcpy(self->buffer,
                       (char *) buffer->buf + written, self->buffer_size);
                self->raw_pos = 0;
                ADJUST_POSITION(self, self->buffer_size);
                self->write_end = self->buffer_size;
                written += self->buffer_size;
                _set_BlockingIOError("write could not complete without "
                                     "blocking", written);
                goto error;
            }
            PyErr_Clear();
            break;
        }
        written += n;
        remaining -= n;
        /* Partial writes can return successfully when interrupted by a
           signal (see write(2)).  We must run signal handlers before
           blocking another time, possibly indefinitely. */
        if (PyErr_CheckSignals() < 0)
            goto error;
    }
    if (self->readable)
        _bufferedreader_reset_buf(self);
    if (remaining > 0) {
        memcpy(self->buffer, (char *) buffer->buf + written, remaining);
        written += remaining;
    }
    self->write_pos = 0;
    /* TODO: sanity check (remaining >= 0) */
    self->write_end = remaining;
    ADJUST_POSITION(self, remaining);
    self->raw_pos = 0;

end:
    res = PyLong_FromSsize_t(written);

error:
    LEAVE_BUFFERED(self)
    return res;
}

在写入 Buffer 的 while 循环中发现如下注释和代码:

c/* Partial writes can return successfully when interrupted by a
   signal (see write(2)).  We must run signal handlers before
   blocking another time, possibly indefinitely. */
if (PyErr_CheckSignals() < 0)
    goto error;

显然,这里就是 Python 唤起信号处理器的地方。PyErr_CheckSignals() 的作用就是检查信号标志位,如果设置了标志位就立即调用信号处理器。在 Python 源代码中的搜索「PyErr_CheckSignals」,能找到20多处调用。果然,官方文档中的描述是有问题的——事实上,Python 不仅仅在两次 bytecode 执行之间会调用信号处理器,在内置函数中也可能会调用信号处理器。