Win和Mac的进程机制应用, 正确而暴力地杀死进程

本来再做一个很简单的进程调用功能, 按照过往经验随便倒腾一下就可以解决. 但还是再深入一下底层原理探探究竟吧.

问题

在C++中调用Console程序, 发现不能强制杀死子进程, 但是子进程可以随主进程关闭.

ps: 优先想尽办法让进程自然退出, 但本文不讨论这种方案.

细节信息

  1. 运行环境是Win10, MacOS10.15
  2. 主进程使用Qt框架, 使用QProcess的kill方法
  3. 子进程使用asyncio框架, 使用pyinstaller打包

结论

问题主要涉及以下内容:

  • 两个系统内核的进程调运行原理
  • Pyinstaller的运行原理
  • Qt框架的进程实现原理
  • CPython解析器的进程实现原理

解决过程繁琐, 直接给结论:

  1. 子进程使用python3.8版本, 之前的版本在WIN中不响应CTRL+C信号
  2. Windws中, 发送CTRL+C信号到目标线程
    1. Python调用process类的send_signal(signal.CTRL_C_EVENT)函数
    2. C++调用WIN32API的GenerateConsoleCtrlEvent(CTRL_C_EVENT, pid)函数
  3. MacOS中
    1. Python使用process类的send_signal(signal.SIGINT)函数
    2. C++调用QProcess类的kill()函数

解题过程

用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调试

image.png

在MacOS中运行发现, main进程已经正常退出, 但是sub进程还是在不断运行. 没有捕获任何异常, proc.kill()没有真正把sub进程杀死, 而proc.wait()正常执行完成. 很不合常理

查看接口描述

Python官方文档
Python官方文档

查看官方文档[1], 发现底层是用过给子进程发送SIGKILL信号实现杀死其他进程的. 接着去复习一下UNIX系统的进程和信号机制.

MacOS中进程退出机制

进程退出主要是三种原因[2]:

  1. 收到一个信号, 该信号的默认行为是终止进程
  2. 从主程序返回
  3. 调用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())

运行结果如下


image.png

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();
}

运行结果如下, 看上去没啥问题


image.png

Windows调试

文档

  • 这次跑程序之前, 先来看看官方给出的进程退出方法汇总[4]:
  1. 在进程内部调用系统函数ExitProcess来杀死自己
  2. 进程随所有线程都退出后退出
  3. 线程中调用系统函数TerminateProcess来退出任意进程
  4. Console程序接收到CTRL+C或者CTRL+BREAK信号后退出.
  5. 操作系统被关闭或者登出
  • 再来看看权威书籍如何描述的[5]
Windows核心编程(第五版)

毫无疑问, 第发送信号和调用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()只能杀死其中一个

启动

image.png

怀疑其中一个是启动器之类的进程, 毕竟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源码

从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

image.png

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

从CPython的源码[7]可以看出, Python3.8以后, windows的事件循环中添加了系统信号的处理机制, 是基于IOCP实现的

结论

往上翻

引用


  1. Python官方文档 ↩

  2. 深入理解计算机系统 ↩

  3. POSIX signals ↩

  4. Microsoft官方文档 ↩

  5. Windows核心编程(第五版) ↩

  6. Pyinstaller文档 ↩

  7. CPython源码 ↩ ↩

你可能感兴趣的:(Win和Mac的进程机制应用, 正确而暴力地杀死进程)