主进程必须持续运行,但是一个危险的算法必须执行,保证危险操作不会伤害父进程,采用子进程和父进程的解决方案方案 (linux应用)
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 秒:如果子进程提前崩溃,父进程可以通过异常退出发现;如果超过期限还没有结束,就按超时处理,终止并回收子进程。超时不一定说明程序已经卡死,也可能只是计算过慢,但它已经超过了本次任务允许的等待时间。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐



所有评论(0)