Win和Mac的進(jìn)程機(jī)制應(yīng)用, 正確而暴力地殺死進(jìn)程

本來再做一個很簡單的進(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é)信息

  1. 運行環(huán)境是Win10, MacOS10.15
  2. 主進(jìn)程使用Qt框架, 使用QProcess的kill方法
  3. 子進(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é)論:

  1. 子進(jìn)程使用python3.8版本, 之前的版本在WIN中不響應(yīng)CTRL+C信號
  2. Windws中, 發(fā)送CTRL+C信號到目標(biāo)線程
    1. Python調(diào)用process類的send_signal(signal.CTRL_C_EVENT)函數(shù)
    2. C++調(diào)用WIN32API的GenerateConsoleCtrlEvent(CTRL_C_EVENT, pid)函數(shù)
  3. MacOS中
    1. Python使用process類的send_signal(signal.SIGINT)函數(shù)
    2. C++調(diào)用QProcess類的kill()函數(shù)

解題過程

用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)試

image.png

在MacOS中運行發(fā)現(xiàn), main進(jìn)程已經(jīng)正常退出, 但是sub進(jìn)程還是在不斷運行. 沒有捕獲任何異常, proc.kill()沒有真正把sub進(jìn)程殺死, 而proc.wait()正常執(zhí)行完成. 很不合常理

查看接口描述

Python官方文檔
Python官方文檔

查看官方文檔[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]:

  1. 收到一個信號, 該信號的默認(rèn)行為是終止進(jìn)程
  2. 從主程序返回
  3. 調(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é)果如下


image.png

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é)果如下, 看上去沒啥問題


image.png

Windows調(diào)試

文檔

  • 這次跑程序之前, 先來看看官方給出的進(jìn)程退出方法匯總[4]:
  1. 在進(jìn)程內(nèi)部調(diào)用系統(tǒng)函數(shù)ExitProcess來殺死自己
  2. 進(jìn)程隨所有線程都退出后退出
  3. 線程中調(diào)用系統(tǒng)函數(shù)TerminateProcess來退出任意進(jìn)程
  4. Console程序接收到CTRL+C或者CTRL+BREAK信號后退出.
  5. 操作系統(tǒng)被關(guān)閉或者登出
  • 再來看看權(quán)威書籍如何描述的[5]
Windows核心編程(第五版)

毫無疑問, 第發(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()只能殺死其中一個

啟動

image.png

懷疑其中一個是啟動器之類的進(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源碼

從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

image.png

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()
image.png

從CPython的源碼[7]可以看出, Python3.8以后, windows的事件循環(huán)中添加了系統(tǒng)信號的處理機(jī)制, 是基于IOCP實現(xiàn)的

結(jié)論

往上翻

引用


  1. Python官方文檔 ?

  2. 深入理解計算機(jī)系統(tǒng) ?

  3. POSIX signals ?

  4. Microsoft官方文檔 ?

  5. Windows核心編程(第五版) ?

  6. Pyinstaller文檔 ?

  7. CPython源碼 ? ?

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末肩钠,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌沙郭,老刑警劉巖秩伞,帶你破解...
    沈念sama閱讀 216,744評論 6 502
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件镀脂,死亡現(xiàn)場離奇詭異朽缎,居然都是意外死亡沃缘,警方通過查閱死者的電腦和手機(jī)舅锄,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,505評論 3 392
  • 文/潘曉璐 我一進(jìn)店門鞭达,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人皇忿,你說我怎么就攤上這事畴蹭。” “怎么了鳍烁?”我有些...
    開封第一講書人閱讀 163,105評論 0 353
  • 文/不壞的土叔 我叫張陵叨襟,是天一觀的道長。 經(jīng)常有香客問我幔荒,道長糊闽,這世上最難降的妖魔是什么梳玫? 我笑而不...
    開封第一講書人閱讀 58,242評論 1 292
  • 正文 為了忘掉前任,我火速辦了婚禮墓怀,結(jié)果婚禮上汽纠,老公的妹妹穿的比我還像新娘。我一直安慰自己傀履,他們只是感情好虱朵,可當(dāng)我...
    茶點故事閱讀 67,269評論 6 389
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著钓账,像睡著了一般碴犬。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上梆暮,一...
    開封第一講書人閱讀 51,215評論 1 299
  • 那天服协,我揣著相機(jī)與錄音,去河邊找鬼啦粹。 笑死偿荷,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的唠椭。 我是一名探鬼主播跳纳,決...
    沈念sama閱讀 40,096評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼贪嫂!你這毒婦竟也來了寺庄?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 38,939評論 0 274
  • 序言:老撾萬榮一對情侶失蹤力崇,失蹤者是張志新(化名)和其女友劉穎斗塘,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體亮靴,經(jīng)...
    沈念sama閱讀 45,354評論 1 311
  • 正文 獨居荒郊野嶺守林人離奇死亡馍盟,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,573評論 2 333
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了茧吊。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片贞岭。...
    茶點故事閱讀 39,745評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖饱狂,靈堂內(nèi)的尸體忽然破棺而出曹步,到底是詐尸還是另有隱情宪彩,我是刑警寧澤休讳,帶...
    沈念sama閱讀 35,448評論 5 344
  • 正文 年R本政府宣布,位于F島的核電站尿孔,受9級特大地震影響俊柔,放射性物質(zhì)發(fā)生泄漏筹麸。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 41,048評論 3 327
  • 文/蒙蒙 一雏婶、第九天 我趴在偏房一處隱蔽的房頂上張望物赶。 院中可真熱鬧,春花似錦留晚、人聲如沸酵紫。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,683評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽奖地。三九已至,卻和暖如春赋焕,著一層夾襖步出監(jiān)牢的瞬間参歹,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 32,838評論 1 269
  • 我被黑心中介騙來泰國打工隆判, 沒想到剛下飛機(jī)就差點兒被人妖公主榨干…… 1. 我叫王不留犬庇,地道東北人。 一個月前我還...
    沈念sama閱讀 47,776評論 2 369
  • 正文 我出身青樓,卻偏偏與公主長得像猜揪,于是被迫代替她去往敵國和親遭笋。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 44,652評論 2 354