Python中的键盘中断无法终止Rust函数(PyO3)

7

我有一个使用PyO3和Rust编写的Python库,其中涉及一些昂贵的计算(单个函数调用最长需要10分钟)。在从Python中调用时如何终止执行?

Ctrl+C似乎只能在执行结束后处理,因此基本上是无用的。

最小可重现示例:

# Cargo.toml

[package]
name = "wait"
version = "0.0.0"
authors = []
edition = "2018"

[lib]
name = "wait"
crate-type = ["cdylib"]

[dependencies.pyo3]
version = "0.10.1"
features = ["extension-module"]

// src/lib.rs

use pyo3::wrap_pyfunction;

#[pyfunction]
pub fn sleep() {
    std::thread::sleep(std::time::Duration::from_millis(10000));
}

#[pymodule]
fn wait(_py: Python, m: &PyModule) -> PyResult<()> {
    m.add_wrapped(wrap_pyfunction!(sleep))
}

$ rustup override set nightly
$ cargo build --release
$ cp target/release/libwait.so wait.so
$ python3
>>> import wait
>>> wait.sleep()

在输入 wait.sleep() 后立即按下 Ctrl+C,屏幕上将打印出字符^C,但是直到10秒钟之后我才最终得到结果。

>>> wait.sleep()
^CTraceback (most recent call last):
  File "<stdin>", line 1, in <module>
KeyboardInterrupt
>>>
KeyboardInterrupt被检测到,但直到Rust函数调用结束之前仍未处理。有没有绕过这个问题的方法?
当将Python代码放在文件中并从REPL外执行时,行为相同。

在编译语言(如Rust)中中止线程并不是一种安全的操作:它会使进程处于未确定状态。 - mcarton
我的 Rust 线程是只读的。有没有办法绕过这个限制? - Neven V.
“只读线程”是什么意思?线程不能安全地取消,这是确定的。现在,“async” futures 被设计为在特定点(即await)可被取消,但我不知道 PyO3 是否支持它们。 - mcarton
2个回答

6

你的问题与这个问题非常相似,只不过你的代码是用Rust而不是C++编写的。

你没有说你在哪个平台上使用——我假设它是类unix的。本答案的某些方面可能对Windows不正确。

在类unix系统中,按Ctrl+C导致一个SIGINT信号被发送到你的进程。在C库的非常低的级别上,应用程序可以注册函数来处理这些信号。有关信号的更详细描述,请参见man signal(7)

由于信号处理程序可以在任何时候被调用(即使是在你通常认为是原子的操作过程中间),所以信号处理程序实际上可以做的事情有很大限制。这与编程语言或环境无关。大多数程序仅在收到信号时设置一个标志,然后返回,稍后检查该标志并采取相应措施。

Python也不例外——它为SIGINT信号设置一个信号处理程序,该处理程序设置一些标志,稍后(在安全的情况下)检查该标志并采取相应措施。

当执行Python代码时,这个方法可以很好地工作——它至少会每个代码语句检查一次标志——但是当执行用Rust(或任何其他外部语言)编写的长时间运行的函数时,情况就不同了。直到你的Rust函数返回,这个标志才会被检查。

你可以通过在你的Rust函数内检查该标志来改进这个问题。 PyO3公开了PyErr_CheckSignals函数,该函数正是做这件事的。这个函数:

检查进程是否收到信号,如果收到信号,则调用相应的信号处理程序。如果支持信号模块,则可以调用用Python编写的信号处理程序。对于SIGINT的默认效果始终是引发KeyboardInterrupt异常。如果引发异常,则会设置错误指示,并返回-1;否则该函数返回0。

因此,你可以在你的Rust函数内适当的间隔调用这个函数,并检查返回值。如果返回值是-1,则应立即从你的Rust函数返回;否则继续进行。

如果你的Rust代码是多线程的,情况就更复杂了。你只能在Python解释器调用你的同一线程中调用PyErr_CheckSignals;如果它返回-1,则必须在返回之前清理任何其他已启动的线程。如何完成这个操作超出了本回答的范畴。

我的代码碰巧是高度多线程的,但我会处理好的。 - Neven V.
抱歉取消接受你的答案,另一个答案给了我一个更简单的解决方案。 - Neven V.
https://docs.rs/pyo3/latest/pyo3/marker/struct.Python.html#method.check_signals 这里有一个安全的Rust封装函数PyErr_CheckSignals - undefined

3

一种选项是生成单独的进程来运行Rust函数。在子进程中,我们可以设置一个信号处理程序以在中断时退出进程。然后Python就能按预期地引发KeyboardInterrupt异常。以下是如何执行的示例:

// src/lib.rs
use pyo3::prelude::*;
use pyo3::wrap_pyfunction;
use ctrlc;

#[pyfunction]
pub fn sleep() {
    ctrlc::set_handler(|| std::process::exit(2)).unwrap();
    std::thread::sleep(std::time::Duration::from_millis(10000));
}

#[pymodule]
fn wait(_py: Python, m: &PyModule) -> PyResult<()> {
    m.add_wrapped(wrap_pyfunction!(sleep))
}

# wait.py
import wait
import multiprocessing as mp

def f():
    wait.sleep()

p = mp.Process(target=f)
p.start()
p.join()
print("Done")

按下CTRL-C后,我在我的电脑上得到的输出如下:

$ python3 wait.py
^CTraceback (most recent call last):
  File "wait.py", line 9, in <module>
    p.join()
  File "/home/kerby/miniconda3/lib/python3.7/multiprocessing/process.py", line 140, in join
    res = self._popen.wait(timeout)
  File "/home/kerby/miniconda3/lib/python3.7/multiprocessing/popen_fork.py", line 48, in wait
    return self.poll(os.WNOHANG if timeout == 0.0 else 0)
  File "/home/kerby/miniconda3/lib/python3.7/multiprocessing/popen_fork.py", line 28, in poll
    pid, sts = os.waitpid(self.pid, flag)
KeyboardInterrupt

可以确认它是有效的,但需要额外的Python代码有点让人望而却步:我正在为其他人编写一个库,而不是内部脚本。然而,我认为这个要求超出了这个问题的范围。 - Neven V.
是否有可能将 PyO3 生成的 Python 模块视为内部模块,并将其包装在另一个 Python 模块中,该模块将成为库公开的模块,并包含创建子进程的代码?那么最终用户就不必处理它。 - Brent Kerby
那可能是一个解决方案。我会研究一下将.so库与导入它的Python脚本捆绑在一起成为单个模块的可能性。这样可以避免最终用户处理多个文件。我的PyO3库已经是另一个crate的包装器,所以会有相当多的层次,但这并不是太大的问题。 - Neven V.
我刚刚随机发现,额外的Python代码甚至不需要。我在我的 Cargo.toml 中添加了一个依赖项,一行 use ctrlc; 以及在调用昂贵函数之前,在我的Rust库中调用了 ctrlc::set_handler。问题解决了。整个Python样板代码都不需要了。 - Neven V.
需要注意的唯一一件事是,如果从REPL工作,那么按下Ctrl+C不仅会中止函数,而且会中止整个会话。这对于我的使用情况来说很好,但是将来发现这个答案的任何人都应该受到警告。 - Neven V.
是的,如果您删除了额外的Python代码,则Rust函数将不会在子进程中运行,因此ctrlc将导致主进程退出。额外Python代码的目的是使恢复变得可能(即,通过在对.join()的调用周围放置try-except来捕获KeyboardInterrupt),通过确保ctrlc仅退出子进程来实现。但是,如果没有必要,那么当然,没有它更简单。 - Brent Kerby

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接