管道——花园软水管

一、小谈管道历史

关于管道,有这么一句话:

如果说Unix是计算机文明中最伟大的发明,那么,Unix下的Pipe管道就是跟随Unix所带来的另
一个伟大的发明。

管道的出现就是为了使软件开发更加“高内聚,低耦合”。管道的发明者,Malcolm Douglas McIlroy,同时也是Unix创建者及Unix文化缔造者,他的Unix哲学:

程序应该只关注一个目标,并尽可能把它做好。让程序能够互相协同工作。应该让程序处理文本
数据流,因为这是一个通用的接口。

也就是说,管道存在的意义就是让程序能够专注自己的目标,而不同进程之间可以相互通信协作,完成一个更大的目标。比如:在主机上,客户进程可以通过管道给文件服务器进程发送文件名,文件服务器打开文件然后将文件数据通过管道传给客户进程,如此,客户进程和文件服务器相互独立,同时相互协作。这其实不仅仅是管道的特点,同时也是其他进程间通信(IPC)手段的特点。不同的IPC实现各有不同,但是目的、哲学道理都是差不多的。

IPC技术:管道,共享内存,消息队列,信号量,本地套接字等。

管道一般是单向的,半双工的,像花园的软水管,一边进一边出。要想实现全双工,一般需要两条管道。

二、管道基本实现

每个进程都有自己的独立的地址空间,要想实现进程之间的相互通信,必须采取必要的手段: 共享内存或者借助内核。管道的实现就是借助内核。

管道的本质就是内核维护的一段内存。因为linux“一切皆文件”的思想,管道自然而然就被实现为“管道文件”(向普通文件一样管理),隶属管道文件系统pipefs。因此,和普通文件一样,内核负责维护文件的细节,返回给用户进程的只是一个个“文件描述符”,通过文件描述符,进程可以执行打开管道、读写管道的操作。

管道分为两种:无名管道pipe和有名管道FIFO

无名管道pipe

何为无名管道?无名管道就是用户进程只能通过文件描述符fd才能找到的管道,内核没有给其他方式告诉管道在哪里。即没有名字,只有句柄(文件描述符)。一般来讲,文件描述符只会在有亲缘关系的进程间继承,这就限制了无名管道一般只用在有亲缘关系进程间通信。

有名管道FIFO

何为有名管道?有名管道,对比无名管道,它跟实体文件名绑定。任何进程只要知道跟管道绑定的文件名,就可以尝试打开管道并操作。这意味着,有名管道可以使用在没有亲缘关系的进程间通信使用。

三、无名管道pipe

创建无名管道pipe

1
2
3
4
5
6
7
8
//原型
int pipe(int fd[2]);

//例子

int pipefd[2];
int error = pipe(pipefd); //调用成功,返回两个文件描述符,
管道只读端pipefd[0]和管道只写端pipefd[1];

无名管道使用(搭配进程fork)

无名管道因其只以文件描述符索引,所以一般的使用方式:

某个祖先进程(父进程)先创建管道,然后fork出若干子进程,父子进程或兄弟进程之间通过继承得到
的管道文件描述符来读写管道,从而达到通信的目的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//Example:

int pipefd[2];
pid_t childpid;

pipe(pipefd);

if((childpid = fork()) == 0) {
//子进程
//关闭管道读端或写端描述符
//write or read

exit(0);
} else if(childpid > 0){
//父进程
//关闭管道写端或读端描述符
// read or write

// waitpid()
} else {
//fork error
}

注意点管道的双方要关闭不需要使用到的文件描述符,为什么?这涉及管道读取端如何判断对方已经不再写了?管道写端进程如何判断管道另一头已经没有进程在读了?

  • 当读取端已无进程等待(即fd[0]的引用计数为0 (close)), 此时若有进程对写端继续写,返回EPIPE错误。并产生信号SIGPIPE。
  • 当写端已无进程再继续写(即fd[1]的引用计数为0 (close)), 若管道的数据读完,将返回文件结束符EOF。

因此,为了能够正常判断结束条件,进程要关闭其没有使用的管道文件描述符。

无名管道是不是只能用在用亲缘关系的进程中?

答案是否定的。因为借助本地套接字,可以在进程间传递文件描述符。

popen/pclose

1
2
3
#include <stdio.h>
FILE *popen(const char* command, const char *type);
int pclose(FILE *stream);

popen行为:fork并执行shell进程,shell进程fork并执行command进程,并返回文件描述符。该文件描述符fd或为读端(管道写端关联到command进程的标准输出),或为写端(管道读端关联到command进程的标准输入)。这取决与popen是以读模式还是以写模式打开。

四、命名管道FIFO

创建管道

1
2
3
int mkfifo(const char* path, mode_t mode);
// 成功,创建一个新的FIFO
// 返回EEXIST错误(指定文件名的FIFO已经存在)

参数:

  • path: 文件路径名
  • mode: 文件权限位

创建管道用mkfifo,打开管道用open

调用mkfifo成功创建管道后,其他进程可以通过open相应的“文件名”从而获取管道,进行读或写。

五、管道属性

管道的打开行为、读写行为

管道open和O_NONBLOCK

管道默认是阻塞的。和普通文件描述符一样,任何时候可以通过fcntl(瑞士军刀)设置成非阻塞。

  • 管道阻塞, open + O_RDONLY : 此时没有进程为写打开,则阻塞。
  • 管道阻塞, open + O_WRONLY :此时没有进程为读打开,则阻塞。
  • 管道非阻塞, open + O_RDONLY : 无论有没有进程为写打开,立即返回。
  • 管道非阻塞, open + O_WRONLY : 没有读进程,则返回-1,errno = ENXIO。

管道读写

  • 阻塞、空管道或FIFO,read:有写进程,阻塞到有数据;无写进程,返回EOF;
  • 非阻塞、空管道或FIFO,read: 有写进程,返回EAGAIN;无写进程,返回EOF;
  • 阻塞、管道或FIFO,write: 有读进程,见下文;无读进程,返回SIGPIPE;
  • 非阻塞、管道或FIFO,write: 有读进程,见下文;无读进程,返回SIGPIPE;

管道写行为、PIPEBUF、原子性

管道有一特点,尽力保证写入的原子性:

如果请求写入的数据字节数小于等于PIPE_BUF,那么write操作保证是原子的。大于PIPE_BUF,则不保证。

如此,在非阻塞情况下:

  • 请求写入字节数小于等于PIPE_BUF:
    • 当前管道空间足够,则直接写入;
    • 当前管道空间不够,为保证原子性,先返回EAGAIN,告诉进程稍后尝试;
  • 请求写入字节数大于PIPE_BUF:
    • 当前管道空间至少还有1字节,直接写相应字节数,返回;
    • 当前管道满,返回EAGAIN。

PIPE_BUF的大小可以通过fcntl来设置。

从上面的讨论可以看出,管道的读写需要一些技巧。所谓“管道有大小,写入需谨慎”。

  • 一次写入数据量不超过PIPE_BUF,以保证写入是原子的。即使存在多个进程同时读,也不会被打断。
  • 写端不要大量输入,会造成阻塞。
  • 读端,要及时读取,避免造成写入阻塞。