1.前言

         困惑  相信大家 在做大项目的时候,或者自己负责一个小模块的时候,担心自己的算法接上去(尤其是算法是移植的开源的复杂函数时,我们把算法内部的核心计算当成了黑盒)会使得整个项目崩溃,自己不太敢搞。这个时候就可以使用这种父进程和子进程的方案。因为他们不共享栈和堆和全部变量区。即使这个黑盒的算法真的崩了,我们的核心主程序没有影响。最多就是这个 算法我们不要了

作者的实际情况:

    是一台需要持续工作的嵌入式设备。主程序不停地采集数据、累计、保存文件,还要处理设备通信。每隔一段时间,它就会得到一份完整数据,需要交给算法进行识别。采集是设备的基础工作,识别也是必须完成的功能,不能因为担心算法出问题,就把算法拿掉。

麻烦在于,算法不是一个简单的加减运算。它要处理数组、内存和迭代计算。正常输入下能够算出结果,并不意味着遇到所有数据都能正常返回:一次越界访问可能导致进程崩溃,一次异常的计算过程也可能迟迟不结束。这些是设计时必须考虑的风险,并不是说算法每次都会出错。

如果直接在主程序里调用算法,两者就绑在了同一个进程中。算法发生致命内存错误时,受影响的可能不只是一份识别结果,连正在工作的采集和通信也会一起停下来。单独开一个线程可以把计算安排到后台,却仍然没有隔开它们的地址空间。

2. 什么是多进程,什么是多线程

进程是操作系统资源分配的基本单位。 一个进程,就是一个独立运行的程序实例,拥有自己完整独立的虚拟地址空间、堆、栈、文件描述符、信号上下文。

多进程:操作系统同时运行多个独立进程。

  • 进程之间天然隔离:一个进程崩溃,不会影响别的进程;
  • 默认内存互相看不见,想要交换数据必须用 IPC(管道、UDS、共享内存等);
  • 切换进程需要切换页表、刷新 TLB,开销更大;
  • 你前面的场景:父进程 + fork/exec 危险算法子进程,就是多进程方案,靠进程隔离故障。

举例:你电脑上同时打开微信、浏览器、剪映,这就是多个进程。


多线程

线程是 CPU 调度的基本单位(轻量级进程)。 线程不能单独存在,必须属于某一个进程。同一个进程内所有线程共享这个进程的地址空间、堆、全局变量、文件描述符。

多线程:在同一个进程内部,同时跑多个执行流。

  • 共享堆、全局变量、代码;每个线程只有自己独立的栈;
  • 线程之间可以直接传指针访问共享内存,通信简单,但要加锁防竞争;
  • 致命特点:任意一个线程崩溃(段错误),整个进程直接退出;无法隔离故障;
  • 线程切换不用换页表,开销远小于进程切换。

举例:一个视频软件里,一条线程解码画面、一条线程播放音频、一条线程读取网络流,属于同一个进程里的多线程。

3. 实现进程间通信的最好理解的两种方案

一个最常见的例子:

父进程负责接收任务、准备输入、启动子进程、取得结果,以及处理失败。子进程只负责一件事:执行计算。


父进程                                                        子进程

接收任务
准备输入 ─────────────────────    → 读取输入
继续处理其他工作                                            执行算法
                                                                          生成结果
处理结果 ←───────────────────── 返回结果

由父进程负责 子进程的通信

那我们怎么让子进程知道父进程的输入呢?

那我们父进程是怎么拿到子进程的结果呢?

我们怎么知道子进程的结果对不对呢?

下面我说两个最好实现的方案 ,最让人可以看懂的。

3.1 使用管道

第一种直接的通信方式是管道

最容易理解的做法,是通过子进程的标准输入和标准输出交换数据。例如,父进程发送一行 JSON,子进程计算后返回一行 JSON。


父进程:输入数据 → JSON → 管道
子进程:读取 JSON → 计算 → 输出 JSON
父进程:读取响应 → 解析结果

我们可以把父进程的输出直接接到子进程的输入  

把子进程的输出又接到父进程的输入,这样就建立了连接。

它的优点很直观:数据是文本,容易查看,也方便单独测试子进程。对于数据量不大的任务,这可能已经足够但管道传递的是字节流,

但是一次读取不一定刚好得到一条完整消息。因此,需要约定消息边界,例如用换行符分隔,或者在正文前增加长度。子进程的普通日志也应与协议输出分开,避免把日志误当成响应。

这个需要我们在父进程和子进程写好协议 ,要解包,代码量还是挺多的。不适合小白去看。

而且除了解析协议以外,还要写很多代码去维护子进程的状态,要一直去监听子进程的状态、什么时候关闭和什么时候 开启。

3.2 使用共享内存

3.2.1 共享内存里到底放什么

可以把共享内存理解成父子进程共同使用的一张表:

共享内存
┌──────────────────────────┐
│ 输入:父进程填写          │
├──────────────────────────┤
│ 输出:子进程填写          │
├──────────────────────────┤
│ 完成标志:子进程最后设置   │
└──────────────────────────┘

例如,算法接收一组数字,返回一个计算结果,共享结构可以这样定义:

// 父子进程使用同一份头文件。
struct TaskInput
{
    int count;
    double values[1024];
};

struct TaskResult
{
    int success;       // 1:计算成功;0:业务失败。
    double value;      // 计算结果。
};

struct SharedBlock
{
    TaskInput input;
    TaskResult output;
    int completed;     // 0:尚未完成;1:结果已完整写入。
};

3.2.2 子进程常驻

子进程启动一次后一直运行,有任务就计算,没有任务就等待。父子进程通过共享内存中的状态交接数据。

共享内存的想法很简单:父子进程访问同一块共享区域,父进程写输入,子进程写结果。

但共享内存只解决数据放在哪里,并没有回答这些问题:

父进程什么时候可以写下一份输入?
子进程怎么知道有新任务?
结果是否已经写完?
子进程写到一半崩溃,父进程怎么处理?

先定义三个状态:

enum State
{
    Idle,          // 空闲,父进程可以放入任务。
    RequestReady,  // 输入已准备好,等待子进程处理。
    ResultReady    // 结果已准备好,等待父进程取走。
};

struct SharedBlock
{
    State state;
    TaskInput input;
    TaskResult output;
};

状态按这个顺序变化:

Idle
  ↓ 父进程写入输入
RequestReady
  ↓ 子进程计算并写入结果
ResultReady
  ↓ 父进程取走结果
Idle

父进程伪代码:

创建共享内存和进程间锁;
shared->state = Idle;
启动子进程;

while (程序运行)
{
    等待并取得一个任务;

    // ==================== 一、交出任务 ====================
    加锁;
    shared->input = 本次输入;
    shared->state = RequestReady;
    解锁;

    开始计时;
    bool received = false;

    // ==================== 二、等待本次结果 ====================
    while (!received)
    {
        if (子进程已退出 || 计算超时) // 不能一直等待失效的子进程。
        {
            结束并回收旧子进程;
            记录本次任务失败;
            重建必要的共享状态和锁;
            重新启动子进程;
            break;
        }

        加锁;

        if (shared->state == ResultReady) // 结果完整写出后才能读取。
        {
            本地结果 = shared->output;
            shared->state = Idle;
            received = true;
        }

        解锁;

        if (!received) // 暂无结果,短暂休眠,避免空转占满 CPU。
        {
            短暂休眠;
        }
    }

    if (received) // 业务处理放在锁外,不占用共享数据锁。
    {
        处理结果(本地结果);
    }
}

子进程伪代码:

连接共享内存和进程间锁;

while (程序运行)
{
    bool hasTask = false;

    // ==================== 一、取出输入 ====================
    加锁;

    if (shared->state == RequestReady) // 父进程已经交来一份任务。
    {
        本地输入 = shared->input;
        hasTask = true;
    }

    解锁;

    if (!hasTask) // 没有任务时等待,不反复空转。
    {
        短暂休眠;
        continue;
    }

    // ==================== 二、执行计算 ====================
    // 计算期间不持锁,输入已经复制到本地。
    本地结果 = 执行算法(本地输入);

    // ==================== 三、交回结果 ====================
    加锁;
    shared->output = 本地结果;
    shared->state = ResultReady;
    解锁;
}

如果子进程长期运行,反复接收任务,双方就需要约定空闲、请求就绪、正在计算、结果就绪等阶段。只要存在并发读写,还需要相应的同步机制。会有复杂的同步逻辑,

3.2.3 子进程不常驻

代码量还是很长,后面我们想到了一个最好的方案, 让一个子进程只完成一次计算,然后让子进程自己结束

(如果任务不是高频到来,每份输入又可以独立计算,就可以采用更直接的顺序)

父进程准备输入
       ↓
启动子进程
       ↓
子进程计算并写回结果
       ↓
子进程退出(自己return0)
       ↓
父进程确认退出情况,读取结果
       ↓
下一份任务到来,再启动新的子进程

这样,进程的生命周期就对应一次任务的生命周期。子进程不需要在内部长期循环,等待下一次请求;父进程也不用持续维护它的空闲、忙碌和重新就绪状态。

共享内存可以由父进程创建一次,在不同任务之间复用。重复启动的是子进程,不一定是重复创建共享内存。

  这不是性能上永远更好的方案。每次启动都有进程创建、库加载和算法初始化的成本。如果任务非常密集,或者初始化特别昂贵,常驻进程可能更合适。这里用启动开销换取的是更直接的任务边界和更少的长期状态。

3.2.4 父进程的流程

下面是伪代码,重点看顺序,省略具体 API 和日志。

// ==================== 一、初始化一次 ====================
创建共享内存;
取得共享块地址 shared;

// ==================== 二、有任务才启动子进程 ====================
while (程序运行)
{
    等待并取得一个任务;

    // 只有旧子进程已经退出,才能覆盖共享块。
    shared->input = 本次输入;
    shared->output = {};
    shared->completed = 0;

    启动子进程,并告诉它共享内存名称;

    if (启动失败) // 子进程没有运行,本次任务失败。
    {
        记录失败;
        continue;
    }

    if (等待结束超时) // 算法迟迟不返回,不能一直等。
    {
        终止子进程;
        确认子进程已经退出; // 未退出就不能复用共享块。
        记录失败;
        continue;
    }

    if (异常退出 || shared->completed != 1) // 崩溃或结果不完整,不使用输出。
    {
        记录失败;
        continue;
    }

    // 子进程已经正常退出,不会再修改这份结果。
    处理计算结果(shared->output);
}

// ==================== 三、程序退出 ====================
释放共享内存;

这里的等待应放在适合阻塞的工作线程中,避免把主线程一起堵住。父程序的其他线程仍然可以接收数据、处理通信。

3.2.5 子进程的流程

子进程不需要一直循环。它启动以后,完成这一份任务就结束:

int main()
{
    连接父进程指定的共享内存;
    取得共享块地址 shared;

    if (连接失败) // 没有输入可读,直接以失败状态退出。
    {
        return 1;
    }

    // 算法读取输入,将成功结果或业务错误写入输出。
    执行算法(shared->input, shared->output);

    // 必须最后设置,表示本次结果已经完整写出。
    shared->completed = 1;

    return 0;
}

如果算法中途崩溃,可能根本执行不到 completed = 1。父进程会通过异常退出或结果未完成,发现这次计算失败。

即使完成标志已经设置,父进程也要等子进程正常退出后再采用结果。

3.2.6 为什么这里可以不加共享数据锁

因为双方按阶段访问,没有同时读写:

父进程写输入时:子进程尚未启动。

子进程计算时:父进程等待,不碰共享块。

父进程读结果时:子进程已经退出。

不是共享内存天然不需要锁,而是这个流程避开了并发访问。

4.结论

我们可以发现,如果让子进程每次只完成一个任务,计算结束后就直接 return 0 退出,整个流程会简单很多。

父进程先把输入写入共享内存,再启动子进程。子进程读取输入、执行算法,将结果完整写回共享内存,然后退出。父进程收到结束信息后,检查退出状态和结果完成标志,确认没有问题,再取走结果。

这样,子进程的生命周期就对应一次任务的生命周期。不需要让它一直运行,也不用反复维护“空闲、收到请求、正在计算、等待取走结果”等状态。任务从哪里开始、在哪里结束,都能沿着代码顺序看清楚。

同时,父进程设置一个等待期限。例如允许算法最多运行 60 秒:如果子进程提前崩溃,父进程可以通过异常退出发现;如果超过期限还没有结束,就按超时处理,终止并回收子进程。超时不一定说明程序已经卡死,也可能只是计算过慢,但它已经超过了本次任务允许的等待时间。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐