本来再做一个很简单的进程调用功能, 按照过往经验随便倒腾一下就可以解决. 但还是再深入一下底层原理探探究竟吧.
问题
在C++中调用Console程序, 发现不能强制杀死子进程, 但是子进程可以随主进程关闭.
ps: 优先想尽办法让进程自然退出, 但本文不讨论这种方案.
细节信息
- 运行环境是Win10, MacOS10.15
- 主进程使用Qt框架, 使用QProcess的kill方法
- 子进程使用asyncio框架, 使用pyinstaller打包
结论
问题主要涉及以下内容:
- 两个系统内核的进程调运行原理
- Pyinstaller的运行原理
- Qt框架的进程实现原理
- CPython解析器的进程实现原理
解决过程繁琐, 直接给结论:
- 子进程使用python3.8版本, 之前的版本在WIN中不响应CTRL+C信号
- Windws中, 发送CTRL+C信号到目标线程
- Python调用process类的
send_signal(signal.CTRL_C_EVENT)
函数 - C++调用WIN32API的
GenerateConsoleCtrlEvent(CTRL_C_EVENT, pid)
函数
- Python调用process类的
- MacOS中
- Python使用process类的
send_signal(signal.SIGINT)
函数 - C++调用QProcess类的
kill()
函数
- Python使用process类的
解题过程
用Python写最小程序
# python 3.8
# sub.py
import asyncio
async def main():
while 1:
print(1111)
await asyncio.sleep(1)
if __name__ == "__main__":
asyncio.run(main())
用pyinstaller -F sub.py
编译成可执行程序
# python 3.8
# main.py
import asyncio
import platform
async def main():
try:
cmd = "dist/sub.exe" if platform.system() == "Windows" else "dist/sub"
proc = await asyncio.create_subprocess_shell(cmd)
await asyncio.sleep(10)
proc.kill()
returncode = await proc.wait()
print(f"returncode: {returncode}")
except Exception as e:
print(e)
if __name__ == "__main__":
asyncio.run(main())
MacOS调试
在MacOS中运行发现, main进程已经正常退出, 但是sub进程还是在不断运行. 没有捕获任何异常, proc.kill()
没有真正把sub进程杀死, 而proc.wait()
正常执行完成. 很不合常理
查看接口描述
查看官方文档[1], 发现底层是用过给子进程发送SIGKILL信号实现杀死其他进程的. 接着去复习一下UNIX系统的进程和信号机制.
MacOS中进程退出机制
进程退出主要是三种原因[2]:
- 收到一个信号, 该信号的默认行为是终止进程
- 从主程序返回
- 调用exit函数
修改main进程代码
在UNIX系统中, 只可以通过给进程发送适当信号[3]来杀死其他进程. 原理上kill函数发送SIGKILL信号应该是可以达到目的, 但现实却不行. 那我试试其它信号, 发现SIGTERM, SIGINT都可以达到目的.main进程代码改写成以下样子:
# python 3.8
# main.py
import asyncio
import platform
import signal
async def main():
try:
cmd = "dist/sub.exe" if platform.system() == "Windows" else "dist/sub"
proc = await asyncio.create_subprocess_shell(cmd)
await asyncio.sleep(10)
# proc.kill() # 不行, 发送SIGKILL信号能终止sub进程
# proc.terminate() # 可以, 发送SIGTERM信号能终止sub进程
proc.send_signal(signal.SIGINT) # 发送SIGINT信号也能终止sub进程
returncode = await proc.wait()
print(f"returncode: {returncode}")
except Exception as e:
print(e)
if __name__ == "__main__":
asyncio.run(main())
运行结果如下
C++调用起sub
// C++11
// Qt5.12
#include
#include
#include
#include
int main(int argc, char *argv[])
{
QCoreApplication a(argc, argv);
QString path("/Users/jomar/Desktop/ProcessTest/dist/sub");
QProcess *process = new QProcess(&a);
QTimer *timer = new QTimer(&a);
QObject::connect(process, &QProcess::errorOccurred, [&](QProcess::ProcessError error){
qDebug() << "error" << error;
});
QObject::connect(process, &QProcess::started, [&](){
qDebug() << "started";
});
QObject::connect(timer, &QTimer::timeout, [&](){
qDebug() << "timeout";
process->kill();
});
process->start(path);
timer->setSingleShot(true);
timer->start(5000);
return a.exec();
}
运行结果如下, 看上去没啥问题
Windows调试
文档
- 这次跑程序之前, 先来看看官方给出的进程退出方法汇总[4]:
- 在进程内部调用系统函数ExitProcess来杀死自己
- 进程随所有线程都退出后退出
- 线程中调用系统函数TerminateProcess来退出任意进程
- Console程序接收到CTRL+C或者CTRL+BREAK信号后退出.
- 操作系统被关闭或者登出
- 再来看看权威书籍如何描述的[5]
毫无疑问, 第发送信号和调用TerminateProcess是可以满足我们强制杀死其他进程的需求的.
运行main.py
# python 3.8
# main.py
import asyncio
import platform
import signal
async def main():
try:
cmd = "dist/sub.exe" if platform.system() == "Windows" else "dist/sub"
proc = await asyncio.create_subprocess_shell(cmd)
await asyncio.sleep(10)
proc.kill() # 不行, 发送SIGKILL信号能终止sub进程
# proc.terminate() # 在MacOS中可以, 因为是发送SIGTERM信号; Windows中不行, 跟kill的实现一样
# proc.send_signal(signal.SIGINT) # 在MacOS中可以, 发送SIGINT信号也能终止sub进程
proc.send_signal(signal.CTRL_C_EVENT) # 在Windows中可以, 发送CTRL_C_EVENT信号能终止sub进程
returncode = await proc.wait()
print(f"returncode: {returncode}")
except Exception as e:
print(e)
if __name__ == "__main__":
asyncio.run(main())
运行发现在Win中, process的terminate和kill函数都不起作用, 需要发送信号. 而又因为Win不遵从POSIX标准, 所以不能使用SIGINT信号, 要发送CTRL_C_EVENT信号才能杀死进程.
继续用C++调用sub进程
#include
#include
#include
#include
int main(int argc, char *argv[])
{
QCoreApplication a(argc, argv);
QString path("\\\\mac\\Home\\Desktop\\ProcessTest\\dist\\sub.exe");
QProcess *process = new QProcess(&a);
QTimer *timer = new QTimer(&a);
QObject::connect(process, &QProcess::errorOccurred, [&](QProcess::ProcessError error){
qDebug() << "error" << error;
});
QObject::connect(process, &QProcess::started, [&](){
qDebug() << "started";
});
QObject::connect(timer, &QTimer::timeout, [&](){
qDebug() << "timeout";
process->kill();
});
process->start(path);
timer->setSingleShot(true);
timer->start(5000);
return a.exec();
}
运行发现, 这一次的情况有些复杂. 启动后任务管理器中有两个sub.exe进程, process->kill()
只能杀死其中一个
怀疑其中一个是启动器之类的进程, 毕竟pyinstaller打包出来的单文件进程是要先解压到系统临时文件夹的; 而另外一个才是真正的sub进程. 至于为什么有一个关不掉, 应该是启动器没有将信号发送给sub进程.
继续爬文档
The bootloader prepares everything for running Python code. It begins the setup and then returns itself in another process. This approach of using two processes allows a lot of flexibility and is used in all bundles except one-folder mode in Windows. So do not be surprised if you will see your bundled app as two processes in your system task manager.
-- Pyinstaller文档[6]
如文档所说, 一个进程是Bootloader, 另一个才是真正的sub. 那接下来在sub.py中和main.cpp中都加上pid的打印, 加上任务控制器的pid对比发现, process->kill()
只能杀死bootloader进程. 回顾kill
的实现方式, 那可以推测调用TerminateProcess系统函数是不能关闭进程及其对应子进程的.
ps: 关闭Qt程序窗口的时候可以完美杀死sub进程, 证明关闭窗口时, Qt框架有完整的清理机制.
接下来将注意力返回到python的main.py中, 我们调用send_signal(signal.CTRL_C_EVENT)
完美杀死进程, 奈何QProcess不提供类似函数. 那就啃源码吧.
源码
从CPython源码[7]可以看到, Python解析器最后通过GenerateConsoleCtrlEvent
发出信号, 最后代码修改如下解决
#include
#include
#include
#include
#include
int main(int argc, char *argv[])
{
QCoreApplication a(argc, argv);
QString path("\\\\mac\\Home\\Desktop\\ProcessTest\\dist\\sub.exe");
QProcess *process = new QProcess(&a);
QTimer *timer = new QTimer(&a);
QObject::connect(process, &QProcess::errorOccurred, [&](QProcess::ProcessError error){
qDebug() << "error" << error;
});
QObject::connect(process, &QProcess::started, [&](){
qDebug() << "started" << process->processId() << process->pid();
});
QObject::connect(timer, &QTimer::timeout, [&](){
qDebug() << "timeout";
// process->kill();
if (GenerateConsoleCtrlEvent(CTRL_C_EVENT, (DWORD)process->processId()) == 0) {
DWORD err = GetLastError();
qDebug() << "err" << err;
}
});
process->start(path);
timer->setSingleShot(true);
timer->start(5000);
return a.exec();
}
GenerateConsoleCtrlEvent
MS的官方文档说, 创建Console进程的时候会创建进程组. 根据系统原理, 默认进程组的ID和Root进程ID一样的. 用GenerateConsoleCtrlEvent是输入Root进程的PID, 就相当于输入进程组的PGID
相对而言, TerminateProcess只能杀死单个进程; 而父子进程之间的生命周期是相互独立的, 所有TerminateProcess不能杀死Console进程组
Python版本的坑
发现生产代码在3.8版本以下的版本中还是不能被杀死
我再对比了一下测试代码和生产代码, 发现生产代码中使用了loop.fun_forever()
函数, 这个函数在python3.7和3.8中的表现是不一样的. 好, 测试代码改为:
import asyncio
loop = asyncio.get_event_loop()
loop.run_forever()
从CPython的源码[7]可以看出, Python3.8以后, windows的事件循环中添加了系统信号的处理机制, 是基于IOCP实现的
结论
往上翻
引用
-
Python官方文档 ↩
-
深入理解计算机系统 ↩
-
POSIX signals ↩
-
Microsoft官方文档 ↩
-
Windows核心编程(第五版) ↩
-
Pyinstaller文档 ↩
-
CPython源码 ↩ ↩