本來再做一個很簡單的進(jìn)程調(diào)用功能, 按照過往經(jīng)驗隨便倒騰一下就可以解決. 但還是再深入一下底層原理探探究竟吧.
問題
在C++中調(diào)用Console程序, 發(fā)現(xiàn)不能強(qiáng)制殺死子進(jìn)程, 但是子進(jìn)程可以隨主進(jìn)程關(guān)閉.
ps: 優(yōu)先想盡辦法讓進(jìn)程自然退出, 但本文不討論這種方案.
細(xì)節(jié)信息
- 運行環(huán)境是Win10, MacOS10.15
- 主進(jìn)程使用Qt框架, 使用QProcess的kill方法
- 子進(jìn)程使用asyncio框架, 使用pyinstaller打包
結(jié)論
問題主要涉及以下內(nèi)容:
- 兩個系統(tǒng)內(nèi)核的進(jìn)程調(diào)運行原理
- Pyinstaller的運行原理
- Qt框架的進(jìn)程實現(xiàn)原理
- CPython解析器的進(jìn)程實現(xiàn)原理
解決過程繁瑣, 直接給結(jié)論:
- 子進(jìn)程使用python3.8版本, 之前的版本在WIN中不響應(yīng)CTRL+C信號
- Windws中, 發(fā)送CTRL+C信號到目標(biāo)線程
- Python調(diào)用process類的
send_signal(signal.CTRL_C_EVENT)
函數(shù) - C++調(diào)用WIN32API的
GenerateConsoleCtrlEvent(CTRL_C_EVENT, pid)
函數(shù)
- Python調(diào)用process類的
- MacOS中
- Python使用process類的
send_signal(signal.SIGINT)
函數(shù) - C++調(diào)用QProcess類的
kill()
函數(shù)
- 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
編譯成可執(zhí)行程序
# 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調(diào)試
在MacOS中運行發(fā)現(xiàn), main進(jìn)程已經(jīng)正常退出, 但是sub進(jìn)程還是在不斷運行. 沒有捕獲任何異常, proc.kill()
沒有真正把sub進(jìn)程殺死, 而proc.wait()
正常執(zhí)行完成. 很不合常理
查看接口描述
查看官方文檔[1], 發(fā)現(xiàn)底層是用過給子進(jìn)程發(fā)送SIGKILL信號實現(xiàn)殺死其他進(jìn)程的. 接著去復(fù)習(xí)一下UNIX系統(tǒng)的進(jìn)程和信號機(jī)制.
MacOS中進(jìn)程退出機(jī)制
進(jìn)程退出主要是三種原因[2]:
- 收到一個信號, 該信號的默認(rèn)行為是終止進(jìn)程
- 從主程序返回
- 調(diào)用exit函數(shù)
修改main進(jìn)程代碼
在UNIX系統(tǒng)中, 只可以通過給進(jìn)程發(fā)送適當(dāng)信號[3]來殺死其他進(jìn)程. 原理上kill函數(shù)發(fā)送SIGKILL信號應(yīng)該是可以達(dá)到目的, 但現(xiàn)實卻不行. 那我試試其它信號, 發(fā)現(xiàn)SIGTERM, SIGINT都可以達(dá)到目的.main進(jìn)程代碼改寫成以下樣子:
# 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() # 不行, 發(fā)送SIGKILL信號能終止sub進(jìn)程
# proc.terminate() # 可以, 發(fā)送SIGTERM信號能終止sub進(jìn)程
proc.send_signal(signal.SIGINT) # 發(fā)送SIGINT信號也能終止sub進(jìn)程
returncode = await proc.wait()
print(f"returncode: {returncode}")
except Exception as e:
print(e)
if __name__ == "__main__":
asyncio.run(main())
運行結(jié)果如下
C++調(diào)用起sub
// C++11
// Qt5.12
#include <QCoreApplication>
#include <QProcess>
#include <QTimer>
#include <QDebug>
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();
}
運行結(jié)果如下, 看上去沒啥問題
Windows調(diào)試
文檔
- 這次跑程序之前, 先來看看官方給出的進(jìn)程退出方法匯總[4]:
- 在進(jìn)程內(nèi)部調(diào)用系統(tǒng)函數(shù)ExitProcess來殺死自己
- 進(jìn)程隨所有線程都退出后退出
- 線程中調(diào)用系統(tǒng)函數(shù)TerminateProcess來退出任意進(jìn)程
- Console程序接收到CTRL+C或者CTRL+BREAK信號后退出.
- 操作系統(tǒng)被關(guān)閉或者登出
- 再來看看權(quán)威書籍如何描述的[5]
毫無疑問, 第發(fā)送信號和調(diào)用TerminateProcess是可以滿足我們強(qiáng)制殺死其他進(jìn)程的需求的.
運行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() # 不行, 發(fā)送SIGKILL信號能終止sub進(jìn)程
# proc.terminate() # 在MacOS中可以, 因為是發(fā)送SIGTERM信號; Windows中不行, 跟kill的實現(xiàn)一樣
# proc.send_signal(signal.SIGINT) # 在MacOS中可以, 發(fā)送SIGINT信號也能終止sub進(jìn)程
proc.send_signal(signal.CTRL_C_EVENT) # 在Windows中可以, 發(fā)送CTRL_C_EVENT信號能終止sub進(jìn)程
returncode = await proc.wait()
print(f"returncode: {returncode}")
except Exception as e:
print(e)
if __name__ == "__main__":
asyncio.run(main())
運行發(fā)現(xiàn)在Win中, process的terminate和kill函數(shù)都不起作用, 需要發(fā)送信號. 而又因為Win不遵從POSIX標(biāo)準(zhǔn), 所以不能使用SIGINT信號, 要發(fā)送CTRL_C_EVENT信號才能殺死進(jìn)程.
繼續(xù)用C++調(diào)用sub進(jìn)程
#include <QCoreApplication>
#include <QProcess>
#include <QTimer>
#include <QDebug>
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();
}
運行發(fā)現(xiàn), 這一次的情況有些復(fù)雜. 啟動后任務(wù)管理器中有兩個sub.exe進(jìn)程, process->kill()
只能殺死其中一個
懷疑其中一個是啟動器之類的進(jìn)程, 畢竟pyinstaller打包出來的單文件進(jìn)程是要先解壓到系統(tǒng)臨時文件夾的; 而另外一個才是真正的sub進(jìn)程. 至于為什么有一個關(guān)不掉, 應(yīng)該是啟動器沒有將信號發(fā)送給sub進(jìn)程.
繼續(xù)爬文檔
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]
如文檔所說, 一個進(jìn)程是Bootloader, 另一個才是真正的sub. 那接下來在sub.py中和main.cpp中都加上pid的打印, 加上任務(wù)控制器的pid對比發(fā)現(xiàn), process->kill()
只能殺死bootloader進(jìn)程. 回顧kill
的實現(xiàn)方式, 那可以推測調(diào)用TerminateProcess系統(tǒng)函數(shù)是不能關(guān)閉進(jìn)程及其對應(yīng)子進(jìn)程的.
ps: 關(guān)閉Qt程序窗口的時候可以完美殺死sub進(jìn)程, 證明關(guān)閉窗口時, Qt框架有完整的清理機(jī)制.
接下來將注意力返回到python的main.py中, 我們調(diào)用send_signal(signal.CTRL_C_EVENT)
完美殺死進(jìn)程, 奈何QProcess不提供類似函數(shù). 那就啃源碼吧.
源碼
從CPython源碼[7]可以看到, Python解析器最后通過GenerateConsoleCtrlEvent
發(fā)出信號, 最后代碼修改如下解決
#include <QCoreApplication>
#include <QProcess>
#include <QTimer>
#include <QDebug>
#include <windows.h>
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的官方文檔說, 創(chuàng)建Console進(jìn)程的時候會創(chuàng)建進(jìn)程組. 根據(jù)系統(tǒng)原理, 默認(rèn)進(jìn)程組的ID和Root進(jìn)程ID一樣的. 用GenerateConsoleCtrlEvent是輸入Root進(jìn)程的PID, 就相當(dāng)于輸入進(jìn)程組的PGID
相對而言, TerminateProcess只能殺死單個進(jìn)程; 而父子進(jìn)程之間的生命周期是相互獨立的, 所有TerminateProcess不能殺死Console進(jìn)程組
Python版本的坑
發(fā)現(xiàn)生產(chǎn)代碼在3.8版本以下的版本中還是不能被殺死
我再對比了一下測試代碼和生產(chǎn)代碼, 發(fā)現(xiàn)生產(chǎn)代碼中使用了loop.fun_forever()
函數(shù), 這個函數(shù)在python3.7和3.8中的表現(xiàn)是不一樣的. 好, 測試代碼改為:
import asyncio
loop = asyncio.get_event_loop()
loop.run_forever()
從CPython的源碼[7]可以看出, Python3.8以后, windows的事件循環(huán)中添加了系統(tǒng)信號的處理機(jī)制, 是基于IOCP實現(xiàn)的
結(jié)論
往上翻