TCPIP 网络编程-11-IPC-管道

管道是linux系统中最常用的进程间通信方式,在命令行中使用如下命令:

1
ls | wc -l

就是使用了管道在 ls进程 和 wc进程间传递数据

image-20240306223602517

实际上,这两个进程并不知道管道的存在,它们只是从标准文件描述符中读取数据和写入数据

管道的特点

  1. 一个管道是一个字节流,及消息不存在边界可以读取任意大小的数据块,从管道中读取出来的字节顺序与写入时一致,无法使用 lseek() 随机访问数据
  2. 从一个当前为空的管道中读取数据将会被阻塞直到管道有数据为止。如果管道的写入端被关闭,那么从管道中读取数据的进程在读完管道中剩余的所有数据之后将会看到文件结束(即 read()返回 0
  3. 管道是单向的
  4. 写入不超过 PIPE_BUF 字节的操作是原子的,除非写入操作被一个信号处理器中断了(返回已经成功传输到管道中的字节数)。如果多个进程写入同一个管道,如果他们在同一时刻写入的数据量不超过 PIPE_BUF 字节,那么就可以确保写入的数据不会发生相互混合。否则内核可能会将数据分割成几个较小的片段来传输(write()调用会阻塞直到所有数据就被写入到管道中)
  5. 管道的容量是有限的
image-20240302203900843
1
2
3
4
5
6
7
8
9


#include <limits.h>
#include <stdio.h>

int main() {
printf("%ld\n", (long)PIPE_BUF);
return 0;
}

创建和使用管道

1
2
3
4
5
6
7
8
9
10
#include "unistd.h"

/**
* @brief 构建管道
* @param
* filedes[0] 通过管道接收数据时使用的文件描述符,管道出口
* filedes[1] 通过管道传输数据时使用的文件描述符,管道入口
* @return 0 成功; -1 失败
*/
int pipe(int filedes[2]);

**问题 1:**有了管道之后,如何与两个进程通信?

想到了之前的 fork,会复制文件描述符号,那样一个进程使用一边就能进行通信了。

Snipaste_2024-03-02_15-49-50

示例地址:https://github.com/XBoom/network-ip.git 中的 apps/socket/15/

1
2
[root]#./server 
[15/server.c:28 main info] parent recv Who are you?

**问题 2:**既然父子进程都有管道的出入口,可否使用一个管道进行双向通信?

image-20240302155045671

示例地址:https://github.com/XBoom/network-ip.git 中的 apps/socket/15/

1
2
[root]#./client 
[15/client.c:24 main info] child recv Who are you?

答案是不行的,同一个管道,先执行的 read 会将 write 全部读出来,所以就需要创建两个管道

image-20240302155144907

最后,构建一个回声示例,服务端将收到与回复的消息都存入到文件中

示例地址:https://github.com/XBoom/network-ip.git 中的 apps/socket/16/

1
2
3
4
5
6
7
[root]#./server 5555
[16/server.c:105 main info] write hello world success
[16/server.c:77 main info] fwrite hello world 30
[16/server.c:10 child_proc info] Child 1054 exited

[root]#./client 127.0.0.1 5555
[16/client.c:37 main info] recv hello world

这里有一个注意事项,就是 必须要关闭管道的读取端和写入端的未使用 文件描述符

  1. 读取数据的进程需要关闭其持有的管道的写入描述符,这样
    • 当其他进程完成输出并关闭其写入描述符之后,读者就能够看到文件结束(在读完管道中的数据之后)。否则,其他进程关闭了写入描述符之后,读者看不到文件结束,read()将会阻塞以等待数据,因为内核知道至少还存在一个管道的写入描述符打开着,即读取进程自己打开了这个描述符
  2. 写入进程关闭其持有的管道的读取描述符。是因为
    • 当一个进程试图向一个管道中写入数据但没有任何进程拥有该管道的打开着的读取描述符时,内核会向写入进程发送 一个 SIGPIPE 信号。在默认情况下,这个信号会杀死一个进程。但进程可以捕获或忽略该信号,这样就会导致管道上的 write() 操作因 EPIPE 错误(已损坏的管道)而失败。收到 SIGPIPE 信号或得到 EPIPE 错误对于标示出管道的状态是有用的,这就是为何需要关闭管道的未使用读取描述符的原因
    • 如果写入进程没有关闭管道的读取端,那么即使在其他进程已经关闭了管道的读取端之 后写入进程仍然能够向管道写入数据,最后写入进程会将数据充满整个管道,后续的写入请求会被永远阻塞。
    • 只有当所有进程中所有引用一个管道的文件描 述符被关闭之后才会销毁该管道以及释放该管道占用的资源以供其他进程复用。此时,管道中所有未读取的数据都会丢失。

进程同步

使用管道来同步父进程和子进程的动作以防止出现竞争条件,创建多个子进程,每个子进程都完成某个动作,父进程等待直到所有子进程完成了自己的动作为止

示例地址:https://github.com/XBoom/network-ip.git 中的 apps/ipcs/01/01.c

1
2
3
4
5
6
7
[root]#./demo 
[01/01.c:26 main info] child 408 sleep 0 to close pipe
[01/01.c:26 main info] child 407 sleep 1 to close pipe
[01/01.c:26 main info] child 409 sleep 1 to close pipe
[01/01.c:26 main info] child 406 sleep 3 to close pipe
[01/01.c:26 main info] child 410 sleep 3 to close pipe
[01/01.c:36 main info] program exit 0

可以看到,read 阻塞直到所有的写端都关闭为止,而多个信号无法排队的事实使得信号不适用于这种情形。反过来其实也行,类型一个进程广播其他进程(《UNIX 系统编程手册》中说不能像信号一样广播给其他进程,其实也是可以的)

示例地址:https://github.com/XBoom/network-ip.git 中的 apps/ipcs/01/02.c

1
2
3
4
5
6
7
[root]#./demo 
[01/02.c:29 main info] program exit
[01/02.c:23 main info] child 488 read end
[01/02.c:23 main info] child 490 read end
[01/02.c:23 main info] child 489 read end
[01/02.c:23 main info] child 491 read end
[root]#[01/02.c:23 main info] child 492 read end

不能通过管道发送消息实现广播,因为管道的数据只会被一个进程读取到

过滤器

当管道被创建之后,为管道的两端分配的文件描述符是可用描述符中数值最小的两个。通常情况下,进程已经使用了描述符 0、1 和 2,因此会为管道分配一些数值更大的描述符。

使用管道连接两个过滤器(即从 stdin 读取和写入到 stdout 的程序)使得一个程序的标准输出被定向到管道中,而另一个程序的标准输入则从管道中读取

image-20240306223602517

示例地址:https://github.com/XBoom/network-ip.git 中的 apps/ipcs/01/03.c

fds[1] 重定向到标准输出 (STDOUT_FILENO),将 fds[0] 重定向到标准输入 (STDIN_FILENO)ls 命令会将当前目录的内容写入到标准输出,由于标准输出已经被重定向到管道,所以内容会被写入到管道中,wc 命令会统计从标准输入读取到的文件数量,并显示在屏幕上

为什么最后的结果会显示在屏幕上

因为 第二个子进程的 STDOUT_FILENO 并没有指向管道,记住描述符是会复制的,第二个程序的输出就显示在屏幕上了

1
2
3
4
5
// 使用 dup 函数复制文件描述符 0
int fd1 = dup(0);

// 使用 dup2 函数复制文件描述符 0 到文件描述符 3
int fd2 = dup2(0, 3);

execlp 是一个 Unix 系统函数,用于执行一个新的程序,并替换当前进程

1
int execlp(const char *file, const char *arg0, ..., const char *argN, (char *)NULL);

标准输入输出与程序输出的结果一致

1
2
3
4
[root]#./demo 
5
[root]#ls | wc -l
5

管道与 Shell

管道还能用于执行 shell 命令并读取其输出或向其发送一些输入

1
2
3
4
#include <stdio.h>

FILE *popen(const char *command, const char *mode);
int pclose(FILE *stream);
  • popen() 函数创建了一个管道,然后创建了一个子进程来执行 shell,而 shell 又创建了一个子进程来执行 command 字符串。mode 参数是一个字符串,它确定调用进程是从管道中读取数据(mode == r)还是将数据写入到管道中(mode == w)。
  • pclose()函数关闭管道并等待子进程中的 shell 终止,而 fclose() 不会等待

由于管道是单向的,因此无法在执行的 command 中进行双向通信。mode 的取值确定了所执行的命令的标准输出是连接到管 道的写入端还是将其标准输入连接到管道的读取端

image-20240307001827277

示例地址:https://github.com/XBoom/network-ip.git 中的 apps/ipcs/01/04.c,程序重复读取一个文件名通配符模式,然后使用 popen()或者这个模式传入 ls 命令之后的结果

1
2
3
4
5
6
7
8
9
10
[root]#./demo 
pattern: d
[01/04.c:35 main info] popenCmd:ls | grep d
[01/04.c:44 main info] file[0] name: README.md
[01/04.c:44 main info] file[1] name: demo
[01/04.c:44 main info] file[2] name: demo.o
pattern: de
[01/04.c:35 main info] popenCmd:ls | grep de
[01/04.c:44 main info] file[0] name: demo
[01/04.c:44 main info] file[1] name: demo.o

如果使用 popen(pathname, "w") 来写入数据,为了让子进程在管道的另一端能立即读取到数据,需要调用 fflush() 或者 使用 setbuf(fp, NULL) 来禁用 stdio 缓冲

参考文档

  1. 《TCPIP 网络编程》
  2. 《Unix 系统编程 44 章》