一、进程基础与IPC概述
大家好,我是你们的嵌入式老兵。今天咱们聊聊进程间通信,但别急着上手代码——先得把地基打牢。进程是什么?程序又是什么?这两个问题,我当年刚入行时也傻傻分不清。
1.1 进程与程序的区别
程序,说白了就是硬盘上躺着的一堆指令。你写好的C代码,编译成ELF文件,它就静静待在那里,啥也不干。
进程呢?是程序跑起来之后的“活物”。它有生命,有状态,会生会死。
核心区别一句话:程序是静态的,进程是动态的。程序是菜谱,进程是炒菜的过程。
我在项目中遇到过一件事:同事把程序拷贝了好几份,以为这样就能多任务处理。结果发现所有“副本”其实指向同一个可执行文件,跑起来却是多个进程。嗯,这就是程序与进程的区别——一个程序可以产生多个进程。
1.2 进程的状态转换
进程不是一直跑着的。它有三种基本状态:就绪、运行、阻塞。你想想看,CPU就那么几个核,进程却成百上千,总得排队吧?
- 就绪态:万事俱备,只欠CPU。进程已经准备好,就等调度器翻牌子。
- 运行态:正在占用CPU执行指令。注意,单核CPU上同一时刻只能有一个进程在运行。
- 阻塞态:进程在等某个事件,比如等磁盘I/O、等网络数据、等用户输入。这时候它不消耗CPU。
状态之间怎么转换?就绪→运行(调度器选中它),运行→就绪(时间片用完或被更高优先级进程抢占),运行→阻塞(发起I/O请求),阻塞→就绪(I/O完成)。
我的经验:调试多进程程序时,最头疼的就是死锁。两个进程互相等对方释放资源,双双进入阻塞态,谁也动不了。我曾经花了一整个下午才找到问题——就是一个信号量没释放。
1.3 进程控制块PCB
操作系统怎么管理这么多进程?靠的就是PCB——进程控制块。每个进程都有一个PCB,里面装着它的“户口本”。
PCB里有什么?我列几个关键的:
| 字段 | 说明 |
|---|---|
| PID | 进程标识符,唯一的数字ID |
| 状态 | 当前处于就绪、运行还是阻塞 |
| 程序计数器 | 下一条要执行的指令地址 |
| CPU寄存器 | 上下文切换时保存的寄存器值 |
| 内存限制 | 进程能访问的内存范围 |
| 打开文件列表 | 进程打开了哪些文件 |
在Linux内核里,PCB就是task_struct结构体。这玩意儿很大,几百个字段。你想想看,操作系统为了管理一个进程,得记多少东西?
1.4 进程标识符PID
每个进程都有一个唯一的PID。你可以用ps命令查看:
$ ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 10:00 ? 00:00:01 init
root 100 1 0 10:01 ? 00:00:00 sshd
myuser 200 100 0 10:02 pts/0 00:00:00 bash
myuser 300 200 0 10:03 pts/0 00:00:00 ps -ef
PID 0是空闲进程,PID 1是init进程(现在很多系统是systemd)。PID是有限的,最大值可以看/proc/sys/kernel/pid_max。我遇到过PID耗尽的情况——进程创建太多,系统再也fork不出新进程了。那次是在一个压力测试场景下,没控制好并发数。
1.5 进程的创建——fork()
创建进程最核心的系统调用就是fork()。它的行为很特别:调用一次,返回两次。
#include <unistd.h>
#include <stdio.h>
int main() {
pid_t pid = fork();
if (pid == 0) {
// 子进程
printf("我是子进程,PID=%d\n", getpid());
} else if (pid > 0) {
// 父进程
printf("我是父进程,PID=%d,子进程PID=%d\n", getpid(), pid);
} else {
// fork失败
perror("fork");
return 1;
}
return 0;
}
fork之后,子进程是父进程的副本。它有自己的地址空间,但内容完全一样。你想想看,这就像细胞分裂——两个一模一样的个体,但从此各自独立。
注意:fork之后,父子进程的执行顺序是不确定的。谁先跑,取决于调度器。别指望父进程一定先执行。
1.6 进程的终止——exit()与_exit()
进程终止有两种方式:正常终止和异常终止。正常终止就是调用exit()或_exit(),或者在main函数里return。
exit()和_exit()有什么区别?
_exit():直接进入内核,立即终止进程。不会清理stdio缓冲区。exit():先调用atexit注册的清理函数,刷新stdio缓冲区,然后再调用_exit()。
我建议在子进程里用_exit(),父进程里用exit()。为什么?因为子进程通常不需要清理父进程的资源。我曾经见过一个bug:子进程用exit(),结果把父进程的缓冲区也刷了,导致输出乱掉。
1.7 孤儿进程与僵尸进程
这两个概念,面试常考,实际开发也常踩坑。
孤儿进程:父进程先挂了,子进程还在跑。这时候子进程会被init进程(PID=1)收养。init会负责回收它。说白了,孤儿不可怕,有人管。
僵尸进程:子进程先终止了,但父进程没有调用wait()或waitpid()来获取子进程的退出状态。子进程的PCB还留在内核里,成了一个“僵尸”。
避坑指南:我曾经在服务器程序里忘了处理SIGCHLD信号,结果跑了一天后,系统里堆了几千个僵尸进程。最后只能重启。记住:父进程一定要wait()子进程,或者设置SIGCHLD处理函数为SIG_IGN。
// 正确的做法:捕获SIGCHLD信号
#include <signal.h>
#include <sys/wait.h>
void sigchld_handler(int sig) {
while (waitpid(-1, NULL, WNOHANG) > 0);
}
int main() {
signal(SIGCHLD, sigchld_handler);
// ... 创建子进程 ...
}
1.8 进程间通信的概念与分类
进程之间是相互隔离的。每个进程有自己的地址空间,谁也看不见谁的数据。但实际应用中,进程之间需要交换数据、同步动作。这就引出了IPC——进程间通信。
Linux下有哪些IPC方式?我按发展顺序给你捋一捋:
- 管道(Pipe):最古老的IPC方式。有亲缘关系的进程才能用。无名管道用于父子进程,有名管道(FIFO)可用于任意进程。
- 信号(Signal):异步通信方式。进程收到信号后,会中断当前执行,去处理信号。简单但携带信息少。
- System V IPC:包括消息队列、共享内存、信号量。这是AT&T System V引入的,功能强大,但接口有点古老。
- POSIX IPC:对System V的改进。接口更简洁,支持更好的实时性。包括POSIX消息队列、POSIX共享内存、POSIX信号量。
- 套接字(Socket):不仅能用于同一台机器上的进程通信,还能跨网络。最灵活,但开销也最大。
- D-Bus:桌面应用常用的IPC机制,基于消息总线。Qt、GNOME都在用。
你可能会问:这么多方式,我该选哪个?我的建议是:
- 简单场景用管道
- 需要大量数据交换用共享内存
- 需要同步用信号量
- 跨网络用Socket
1.9 Linux下IPC的发展历程
Linux的IPC机制是逐步完善的。早期Unix只有管道和信号。System V引入了消息队列、共享内存、信号量。POSIX标准又对这些做了改进。
我记得Linux 2.4内核之前,System V IPC的性能还不错。后来随着硬件发展,共享内存成了高性能场景的首选。现在很多数据库、中间件都用共享内存做数据交换。
近几年,Linux还引入了memfd_create()和eventfd()等新机制。但说实话,最常用的还是那几样:管道、信号、共享内存、Socket。
我的建议:初学者先掌握管道和信号,这两个最简单。然后学共享内存和信号量,这是高性能场景的基石。Socket最后学,因为它涉及网络编程,内容最多。
知识体系总览
下面这张图,是我自己整理的本章知识结构。你看一眼,心里就有谱了。
好了,第一章的内容就到这里。进程是操作系统的核心概念,IPC是进程间协作的桥梁。后面的章节,我们会逐一深入每种IPC机制。到时候,我会带着实际项目中的案例,跟你一起踩坑、填坑。
公众号:蓝海资料掘金营,微信deep3321