mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
5588 字
24 分钟
Linux 服务器高阶编程(10):管道、共享映射与进程生命周期

Linux 服务器高阶编程(10):管道、共享映射与进程生命周期#

一、匿名管道:先创建通道,再让父子共享端点#

1.1 管道不是普通数组#

用队列解释管道:数据写入内核管理的缓冲区,再由读端取走。它具有传输方向、暂存能力,不是用来长期保存数据的磁盘文件。

用户程序中的 fds 只是两个文件描述符的容器,并不是管道缓冲区本身。

核心写法:

int fds[2];
pipe(fds);
数组元素对应端点操作
fds[0]读端read(fds[0], ...)
fds[1]写端write(fds[1], ...)

pipe() 成功返回 0,失败返回 -1;两个描述符通过参数数组返回。不要把函数返回值误当成读端或写端。

1.2 为什么放在 fork() 前面#

顺序是:父进程先创建管道,再创建子进程。

父进程 pipe(fds)
|
+-- fork() --> 父进程:保留一份读、写描述符
--> 子进程:继承一份读、写描述符
两边的描述符共同引用原来的管道,而不是各自创建一条新管道。

如果父、子在不同分支里分别调用 pipe(),得到的是不同管道,不能仅凭两个数组都叫 fds 就认为它们能互相通信。

主要使用父子进程继承端点的模型。这里的“匿名管道用于亲缘进程”应结合这个使用方式理解:它没有 FIFO 那样可供独立进程通过 open() 查找的路径名。

1.3 父写子读时,谁关闭哪一端#

进程保留端点立即关闭的端点完成后
父进程fds[1],负责写fds[0]关闭写端,等待子进程
子进程fds[0],负责读fds[1]关闭读端,退出

“确认单工”,在这个示例里就是通过关闭不需要的端点,明确父进程只发送、子进程只接收。

不能只把 close() 当成最后清理资源的一步。多余端点不关闭,还会改变 EOF 与 SIGPIPE 出现的条件,进而影响程序是否能够正常结束。

1.4 父写子读示例的核心片段#

父进程分支:

close(fds[0]);//确认单工
write(fds[1],msg,strlen(msg));
close(fds[1]);
zpid = wait(NULL);
printf("parent wait success\n");
exit(0);

子进程分支:

close(fds[1]); //确认单工
char buffer[1024];
bzero(buffer,sizeof(buffer));
read(fds[0],buffer,sizeof(buffer));
close(fds[0]);
printf("child id %d , read msg = %s",getpid(),buffer);
exit(0);//变成僵尸

消息为

#define msg "hi son can u hear me ?\n"

阅读这段示例时,把调用分成三组:

  1. pipe()、fork():建立通信关系。
  2. write()、read():传递数据。
  3. close()、wait()、exit():结束使用并回收进程。

父进程通过 wait(NULL) 等待并回收子进程。因此,不能只看子进程分支的“变成僵尸”注释,就判断这个程序会长期遗留僵尸进程。子进程结束与父进程取得退出状态是前后两个步骤。

二、管道的四种状态:空、满、写端全关、读端全关#

2.1 四种情形不要混淆#

下表按阻塞读写场景整理,并把“关闭”补充为关键的“全部相关端点关闭”。

情形本端操作结果或含义
管道为空,仍有写端存在读端 read()等待数据,可能阻塞
管道已满,仍有读端存在写端 write()等待读端腾出空间,可能阻塞
所有写端已关闭读端继续读取先读完剩余数据,之后返回 0,表示 EOF
所有读端已关闭写端继续写非零长度数据产生 SIGPIPE;默认处理通常终止写进程

关键不是“某一个写进程退出了”,而是“是否还存在任何写端引用”。读端关闭的判断同理。若 SIGPIPE 被忽略,则写操作会以 EPIPE 失败,不能再期待其默认终止效果。

2.2 为什么写进程退出了,读进程还一直等#

典型错误是子进程虽然只负责读,却忘了关闭自己继承到的 fds[1]。

此时即使父进程已关闭写端,内核仍能看到子进程手中还有一个写端。子进程把已有数据读完后,再读时就可能继续等待,而不是得到 EOF。

排查顺序:

  1. 列出所有经过 fork() 或描述符复制得到的端点。
  2. 标出每个进程真正使用的端点。
  3. 检查所有不需要的端点是否已关闭。
  4. 最后再看循环条件,不要先用 sleep() 掩盖问题。

同样,若子进程保留了一个根本不使用的读端,父进程的读端关闭后,子进程写入也未必会触发预期的 SIGPIPE。这直接关系到最后的练习。

2.3 描述符和资源释放#

以父子各持有一份读、写端点的方式解释“共有 4 个引用”。

真正应该落实到代码的是:每个进程只保留自己需要的描述符;用完关闭;进程结束也会释放其打开的描述符。

2.4 单工、半双工和双工#

通信方向区分这三个术语:

  • 单工:固定一个方向,例如父写子读。
  • 半双工:双方可以轮流发送,但不同时发送。
  • 双工:双方能够同时发送和接收。

2.5 字节流不自带消息边界#

匿名管道传输无格式字节流,消息格式需要程序自己约定。

三、与 Socket 联系起来理解 SIGPIPE#

客户端发送请求后退出,服务端处理完再发送响应,服务端为什么可能异常结束?

通信对端已经无法接收时,发送操作可能触发 SIGPIPE,默认信号处理会终止进程。不能一看到程序消失就只排查数组越界,也要检查发送操作和对端连接状态。

send(sockfd, buffer, len, MSG_NOSIGNAL);

MSG_NOSIGNAL 抑制的是这次 Socket 发送产生的 SIGPIPE,不是让失效连接重新可用。发送仍可能失败并返回 EPIPE,应检查返回值。另外,“客户端退出后,服务端第一次响应一定立即被杀死”并非普遍保证,还与连接关闭过程有关。

四、有名管道 FIFO:用路径连接非亲缘进程#

4.1 相比匿名管道,解决了什么问题#

匿名管道示例依赖父子进程继承已有描述符。有名管道给通信对象提供一个文件系统路径,独立启动的进程只要访问同一个 FIFO,并有相应权限,就可以建立通信。

进程 A -- open 同一路径,写入 --> FIFO 的内核缓冲区
进程 B <-- open 同一路径,读取 -- FIFO 的内核缓冲区

这里文件名的作用是“找到对象”。不要把 FIFO 想成一个不断追加内容的普通文本文件。

4.2 创建和删除#

mkfifo Name
rm Name -rf

第一条创建名为 Name 的 FIFO

删除自己练习用的单个 FIFO 不需要递归参数,确认路径后使用 rm -- Name 即可。不要将示意名称替换成不确定的目录后照抄 -rf。

mkfifo() 与 unlink()。其中 mkfifo(char * name, 0664) 是把参数说明与调用混写的示意,不是可以直接复制的完整 C 声明。

#include <sys/types.h>
#include <sys/stat.h>
int mkfifo(const char *path, mode_t mode);

例如把实际路径传入 mkfifo("Name", 0664)。0664 是八进制权限值,最终创建权限还会受到 umask 影响。成功返回 0,失败返回 -1。

4.3 为什么停在 open(),还没走到 read()#

“必须满足读写两个权限,否则 open() 阻塞”。这里应区分文件权限与通信端点。

  1. open() 前后的日志是否都有输出?
  2. 另一端是否已启动并尝试打开?
  3. 两端路径是否确实指向同一个 FIFO?
  4. 目录和文件访问权限是否满足要求?
  5. 是否无意间用 O_RDWR 保留了自己的读、写端,改变了 EOF 判断?

4.4 FIFO 文件与缓冲区不是同一种寿命#

强调 FIFO 数据不按普通文件内容那样持久化,这个理解方向很重要。但“管道文件不删除,通信数据就一直保留”的理解是错误的。

FIFO 路径是访问入口,实际通信数据由内核处理。路径可能继续存在,而所有端点关闭后,原来那轮通信的缓冲数据并不会作为磁盘文件内容保存下来。FIFO 是特殊文件类型,不是普通数据文件。

五、文件映射 mmap:从“传递字节”转向“访问共享区域”#

5.1 与管道的核心区别#

把文件映射描述为:多个进程映射同一个普通文件,通过访问映射区域交换数据。

管道中,一端写入、另一端读走;映射区域中,一端修改数据,另一端访问该区域。读取映射区域不会像读取队列那样把数据“取走”,数据会继续存在,直到被修改、覆盖或相关对象结束。

  • 管道像等待被消费的数据流。
  • 共享映射像双方都能访问的一块数据区。

共享数据区仍需要程序约定数据结构、更新时机和访问协调,不能把“能看到同一块数据”当成“所有并发问题已经自动解决”。

5.2 调用框架#

#include <sys/mman.h>
void * ptr = NULL;
if((ptr = mmap(NULL,size,prot,type,fd,offset))==MAP_FAILED)
perror("mmap call failed");
munmap(ptr , size);

这里保留 size、prot、type、fd、offset。这些参数必须由实际程序定义;该片段不是独立程序。错误分支之后不能继续把失败返回值当成有效指针使用。

5.3 六个参数分别在说明什么#

按示意参数名整理:

位置参数阅读含义
1NULL让系统选择映射地址
2size本次需要映射的字节数
3prot映射区域的访问保护,如读、写
4type映射方式,重点区分 MAP_SHARED 与 MAP_PRIVATE
5fd已打开的映射文件描述符
6offset从文件哪个字节位置开始映射

PROT_READ、PROT_WRITE、PROT_EXEC、PROT_NONE,以及 MAP_SHARED、MAP_PRIVATE。不要把访问保护标志与映射方式标志混写成同一种参数。

length 必须大于零,文件偏移需要满足页大小对齐要求;失败判断是 MAP_FAILED,而不是普通的空指针判断。映射成功后关闭 fd 不会自动解除映射,解除映射使用 munmap()。

5.4 “没有有效数据”不等于“文件长度为零”#

若把文件作为通信介质,希望开始时没有旧消息,但仍要有足够长度支持访问。

先打开,再通过 ftruncate() 调整长度:

int fd = open("file",O_RDWR);

随后调用 ftruncate(fd, 需要的字节数)。

ftruncate() 把文件调整到指定长度;缩小时会丢弃超出的内容,扩展出的区域读出为零字节。它不是“把原有文件所有字节清零”的函数,文件描述符也必须可写。

由此理解准备工作:

  1. 明确共享数据需要多少字节。
  2. 准备一个允许相应读写的普通文件。
  3. 检查文件长度足以覆盖需要访问的区域。
  4. 必要时调整长度并检查结果。
  5. 再建立映射并检查返回值。

5.5 使用结构体组织共享数据#

typedef struct
{
int id;
char date[4096];
}shared_t; //共享数据类型

后面还有:

shared_t * ptr = mmap(NULL, sizeof(shared_t), x);

这里的 x 是省略剩余参数的占位,不是实际接口允许只传三个参数。它要表达的是:把映射区域当成 shared_t 类型的数据使用。

说明:

  • 用 sizeof(shared_t) 表达结构体实际大小,不要只按 4096 计算整个结构体。
  • 写入者修改成员,读取者按同一个结构解释这些字节。
  • 两端必须对结构布局和数据含义达成一致。
  • 一个普通指针变量的值不能直接当成“另一进程可用的地址”传过去;双方使用各自 mmap() 返回的地址访问对应区域。

5.6 MAP_SHARED 与 MAP_PRIVATE#

需要共享修改或更新原文件时使用共享映射;希望私下修改、而不把修改写回原文件时使用私有映射。

映射方式对映射区域进行修改后的含义重点
MAP_SHARED修改可对映射同一区域的其他进程可见,并可写回底层文件共享修改
MAP_PRIVATE修改属于私有的写时复制结果,不作为共享修改写回原文件私下修改

私有映射不是调用时就把整个文件完整备份一遍,也不能当成保证不受外部文件修改影响的可靠快照;共享映射的内存可见性与文件何时落盘也不是同一个问题。

5.7 sync 的表述怎样理解#

对映射区域需要明确控制写回时,可了解 msync();MS_SYNC 请求更新并等待完成。但它解决的是映射内容写回的问题,不是读写双方互斥,也不能代替“这一条消息已经写完整了”的进程间协调。

5.8 三类常见问题:参数、权限、范围#

映射长度为零、映射与打开权限冲突、访问超出文件有效范围。

现象首先检查
mmap() 返回 MAP_FAILED参数、文件描述符、访问模式、映射长度、偏移
返回了地址,但访问时出现总线错误实际访问位置与文件长度是否匹配,文件是否被截短
一端修改了,另一端没得到预期内容是否同一文件、同一区域,是否使用共享映射,访问时机是否协调
munmap() 后仍访问旧指针是否继续使用已经解除的地址范围

空文件不代表 mmap() 调用必定立即失败;建立映射与随后访问是两个阶段。访问文件末尾以外的整页区域可能产生 SIGBUS。

5.9 关于“mmap 一定更快”#

映射可能减少传统读入用户缓冲区的复制开销,这是理解其用途的一个方向。

六、亲缘关系、进程组、会话#

6.1 先区分四个标识#

标识表示什么用来回答什么问题
PID当前进程编号这是哪个进程?
PPID父进程编号当前由哪个进程作为父进程?
PGID进程组编号它属于哪一组?
SID会话编号它属于哪个会话?

6.2 亲缘关系强调创建、继承和回收#

Bash
-> fork 创建子进程
-> 子进程执行 execl 一类接口
-> 运行 ./app
-> app 还可以继续 fork 创建自己的子进程

阅读时还应区分:fork() 负责创建子进程;exec 类接口用于替换当前进程运行的程序映像。 execl() 是流程占位,并没有给出可直接调用的参数列表。

七、进程组:把多个进程组织成一个管理单位#

7.1 组长与组员#

进程组组长:PID == PGID

进程组的存在不依赖组长始终活着:即使组长已经结束,只要组内仍有其他成员,不能仅凭组长不在就断言组已经消失。

“最后一个成员终止或转移时组消失”和“进程组一般随系统持续”。前一句才是判断具体进程组寿命时应抓住的条件;不能把所有进程组都理解为一直活到系统关闭。

7.2 查询进程组#

pgid = getpgrp();

它用于取得调用进程的进程组编号。

7.3 创建新组与转移进程组#

setpgid(getpid(),getpid());

含义是把当前进程放到一个以自身 PID 为组号的进程组。

转移目标组通常必须存在并属于同一会话;可操作的目标也受“自身或符合条件的子进程”等限制,不能任意移动其他进程。不能简单记成“组长一律不能转移”;会话首进程不能改变所属进程组,setsid() 对组长的限制则是另一条规则。检查返回值 0 或 -1,不要只看调用已经写上了。

7.4 分组不会取代父子回收#

一个进程成为组长,不代表它会自动成为所有组员的父进程;一个子进程换了组,也不因此免除退出后的回收需求。

所以排查时分开看:PPID 用来理解父子与回收,PGID 用来理解分组。不要只观察其中一个字段。

八、会话与控制终端:setpgid() 不等于 setsid()#

8.1 会话与进程组的层次#

可以把关系整理为:

一个会话 SID
+-- 进程组 A
| +-- 组长
| +-- 其他成员
+-- 进程组 B
+-- 组长
+-- 其他成员

这张图描述管理层次,不是父子树。父子关系需要另外观察 PPID。

8.2 查询会话与创建会话#

sid = getsid(getpid());
setsid();

第一行查询当前进程所属会话;第二行尝试创建新会话。两者不能因为名字相近而混用。

setsid() 要求调用进程不是进程组组长。成功后,它成为新会话首进程和新组组长,新会话起初没有控制终端;失败返回 -1。这也是常见流程先 fork()、再让子进程调用 setsid() 的原因。

8.3 为什么先 fork() 再在子进程里创建会话#

  1. 当前进程创建子进程。
  2. 父分支按设计退出。
  3. 子分支尝试 setsid()。
  4. 检查是否成功,再继续需要独立运行的工作。

容易写错的顺序是先在子进程里调用 setpgid(getpid(), getpid()) 把自己变成组长,再调用 setsid()。这会与 setsid() 的非组长条件冲突。

8.4 “换组”为什么不等于“脱离终端”#

  • setpgid():调整组关系,不直接创建新会话。
  • setsid():建立新会话及新进程组,并使新会话起初没有控制终端。

所以不能仅用 setpgid(getpid(), getpid()) 就断言已经彻底脱离控制终端。也不能把“从终端启动的进程”当成一种永久无法脱离终端的特殊进程类型。

8.5 终端关闭不等于无条件杀死所有后代#

具体行为涉及终端挂断、会话、前台进程组及信号处理,不能简化成“关闭 Bash 必然杀死所有子孙”。会话首进程终止时,与其控制终端相关的前台进程组会收到 SIGHUP;程序怎样处理信号仍影响最终结果。

8.6 用 ps ajx 观察#

ps ajx

重点观察 PPID、PID、PGID、SID:

  1. 先用 PID 定位自己的测试程序。
  2. 用 PPID 找到它的父进程。
  3. 比较 PGID,看是否同组及谁是组长。
  4. 比较 SID,看是否仍在原会话。
  5. 在创建子进程、调整组或创建会话前后对照,不能只凭程序的输出文字判断调用成功。

九、孤儿进程与僵尸进程:不同问题,分别处理#

9.1 孤儿进程是什么#

父进程先于子进程退出,仍存活的子进程成为孤儿进程。

这里要看退出顺序:消失的是原父进程,子进程可能还在执行任务。不能把“孤儿”理解为“这个子进程已经退出”。

9.2 与僵尸进程对照#

对比项孤儿进程僵尸进程
关注的问题原父进程已结束,子进程仍存活子进程已结束,但退出状态尚未被回收
是否还在执行原程序可以继续运行不再执行原用户程序
典型现象PPID 发生变化,由其他进程接管保留少量退出相关信息,常见状态为 Z
想解决什么不需要继续工作的子进程应随父进程结束结束后的状态应由适当父进程等待回收

不应把所有孤儿进程都说成天然有害。风险在于它是否脱离预期管理继续工作、占用资源。僵尸进程则已经停止执行,主要遗留的是等待回收的信息,两者处理思路不同。

9.3 谁接管孤儿进程#

/upstart-user 和 PID 1000,这是特定环境下的描述,不能作为通用配置值。

Linux 可以由最近的存活祖先 subreaper 接管孤儿后代,否则通常由相应 PID 命名空间的 init 接管。不要在程序中写死“孤儿的 PPID 必须是 1000”。应在自己的运行环境中观察。

接管并不代表接管者会立即终止孤儿进程。通常是当它后来结束时,由接管者负责取得退出状态;是否应该继续运行,仍是应用的生命周期设计问题。

9.4 方法一:kill(pid, 0) 检测#

kill(pid, 0);

信号编号为 0 时不会实际发送信号,而是做存在性与权限检查。失败不一定表示进程不存在:ESRCH 与 EPERM 含义不同。不要写成“返回值不是 0 就一定说明父进程死了”。它也不是无竞争的可靠父子监管协议。

9.5 方法二:利用管道的读端关闭#

  • 父进程只保留读端。
  • 子进程只保留写端。
  • 子进程定时向父进程发送少量消息。
  • 父进程退出后,它持有的读端随之关闭。
  • 当所有读端确实都消失,子进程下一次写入触发 SIGPIPE。
  • 使用默认信号处理时,子进程因此结束。

这是“以通信端点的存在性,间接感知父进程是否还存在”的办法。

两个重要限定:

  1. 不是父进程一退出,内核就无条件立刻杀死这几个子进程;还需要发生后续写操作。
  2. 不是父进程关闭自己的读端就一定够;其他进程不能仍保留同一管道的读端。

这正是前面“关闭不用端点”的实际用途,而不是一条无关紧要的代码习惯。

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

Linux 服务器高阶编程(10):管道、共享映射与进程生命周期
https://joyanblog.com/posts/linux-server-advanced-programming-10-ipc-process-lifecycle/
作者
Joyan
发布于
2026-10-25
许可协议
CC BY-NC-SA 4.0

目录