pipe

进程间通信

在计算机科学中,进程间通信(IPC)特指操作系统提供的允许进程共享数据的机制。进程间通信有多种方式,比如文件、信号、通道、共享内存和Socket等。这篇文章涉及到的进程间通信方式主要有信号,管道和Socket三种。

信号(Signal)

信号就是向一个进程或同一进程中的特定线程发送的异步通知,以通知它一个事件。大多数信号可以被忽略、阻止或处理(通过指定的代码),SIGSTOP(暂停)和SIGKILL(立即终止)是两个例外。符号常量是有整数值的,比如SIGKILL的整数值为9。

用户的交互就可以产生信号。例如,用户可以在命令行中按Ctrl+C来终止一个从命令行启动的程序;Ctrl+C会产生一个SIGTERM信号,与SIGKILL不同,SIGTERM可以被阻止或处理。

再比如我们经常用kill命令来杀掉一个进程:

1
int kill(pid_t pid, int signum); /* declaration */

kill命令的第二个参数就是一个标准的信号,如SIGTERMSIGKILL。当然也可以指定其他信号,比如SIGUSR1SIGUSR2,这两个信号被用来告诉进程有一个用户定义的事件发生了,具体做什么取决于进程如何处理发送过来的信号,不一定是杀掉进程,比如在MongoDB中,运行下边的命令就是告诉MongoDB该进行log rotation了。

1
kill -SIGUSR1 MONGODPID

当信号发出时,操作系统会中断目标进程的正常执行流程进而完成信号传递。进程可以在任何非原子指令期间被中断,如果进程之前已经注册了一个对这个信号的处理程序,则执行该程序,否则就执行默认的信号处理程序。

信号处理程序可以通过signal(2)sigaction(2) 这两个系统调用来设置,现在sigaction()函数取代了signal()函数,应优先使用。另外要注意这两个系统调用不应该在同一个进程中控制同一个信号。在设置信号处理程序的时候还可以用这两个特殊的值:

  1. SIG_IGN: 忽略信号(ignore)
  2. SIG_DFL: 和使用默认信号处理程序(default)

简单来说,如果想忽略一个信号,可以这样:

1
2
3
signal(sig, SIG_DFL);
// or
sigaction(sig, &(struct sigaction){SIG_IGN}, NULL);

其中sigaction的第二个参数是一个sigaction struct

1
2
3
4
5
6
7
8
9
struct sigaction
{
    void        (*sa_handler)(int);    /* address of signal handler */
    sigset_t    sa_mask;               /* additional signals to block */
    int         sa_flags;              /* signal options */

    /* alternate signal handler */
    void        (*sa_sigaction)(int, siginfo_t *, void*);
};

详细的用法就不赘述了,可以看这里

管道(pipe)

管道是基于消息传递实现的,将一组进程的标准流链在一起,这样每个进程的输出(stdout)直接作为输入(stdin)传递给下一个进程。它们是并发执行的,也就是说后边的进程可以在前一个进程running的时候被启动,直观来说:

1
process1 | process2 | process3
Pipeline

我们经常用到管道,比如要列出当前目录下的文件(ls),只保留ls输出中包含字符串 “key “的行(grep),并在滚动页面中查看结果(less)

1
ls -l | grep key | less

注意,这是匿名管道,命名管道还不太一样,关于命名管道可以看这里 ,它不是本文的重点。

套接字(Socket)

Socket是网络上运行的两个程序之间双向通信链接的一个端点,Socket机制提供了一种进程间通信的方式。管道是由pipe()系统调用创建的,Socket则是使用socket()系统调用来创建的。

Socket在网络上提供双向FIFO通信设施,在通信的两端各创建一个连接到网络的Socket,每个Socket都有一个特定的地址,这个地址由一个IP地址和一个端口号组成。

Pipeline

举个不恰当的例子,可以把Socket当作两个插座,中间用一根电线连接起来。

SIGPIPE

说了这么多前置知识,我们现在来看看本文的主角–SIGPIPE信号:

The SIGPIPE signal is sent to a process when it attempts to write to a pipe without a process connected to the other end.

当一个进程试图向一个管道写入时,如果没有一个进程连接到另一端,则会向该尝试写入的进程发送SIGPIPE信号。也就是说当一个进程试图向一个读端已经关闭的管道写入时,就会收到这个信号,而收到这个信号的默认操作是终止进程。

这很合理,比如有这样一个管道:

1
process1 | process2

如果process2已经死掉了,理应通知process1一下,不能让他在那一直做无用功,至于收到信号process1怎么处理就是它自己的事了。

举个例子

1
yes | head -n 1

yes命令的作用是将无限的"y"序列写入STDOUT,而head则将其从管道的另一端读为STDIN。head读取第一行,然后退出,只要head终止,管道的接收端就关闭了。因此,Linux内核会向yes进程发送SIGPIPE信号,表示没有reader了,然后yes进程就会终止。

不只是对于管道,Socket也有这个机制:

When a process writes to a socket that has received an RST, the SIGPIPE signal is sent to the process. The default action of this signal is to terminate the process, so the process must catch the signal to avoid being involuntarily terminated.

如果向一个收到RST的Socket继续发送数据,则会收到SIGPIPE信号。

先来回忆一下RST数据包,当一个意外的TCP数据包到达主机时,该主机通常会通过在同一连接上发送一个RST数据包来进行响应。RST数据包就是一个没有有效载荷,并且在TCP头标志中设置了RST位的数据包。常见的意外的TCP数据包有:

  • SYN数据包,试图建立连接到一个没有进程监听的服务器端口
  • 数据包到达之前建立的TCP连接上,但本地应用程序已经关闭了它的套接字或退出,操作系统关闭了套接字

我们可以用一个例子说明Socket上的SIGPIPE

Server:

1
2
3
4
5
6
7
8
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(('localhost', 12345))
s.listen(1)
conn, addr = s.accept()  # blocks
conn.shutdown(socket.SHUT_RDWR)
conn.close()
s.close()

Client:

1
2
3
4
5
6
import time
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(('localhost', 12345))
time.sleep(1)
s.sendall(bytes('Hello' * 100000, 'utf-8'))

运行上边的代码,你应该可以看到下边的错误

1
BrokenPipeError: [Errno 32] Broken pipe

如果你没看到,就把100000改的再大一点。

在TCP中,当Server端调用close时, Client端会收到FIN数据包,从Client的角度来看,Server端不会再发送数据,但是仍可以接收数据。此时对Client端的Socket调用write方法时, 如果发送缓冲没问题, 会返回正确写入,但发送的报文会导致Server端的TCP协议栈返回RST数据包。此时再调用write方法(假设在收到RST之后), 会生成SIGPIPE信号, 导致进程退出。

如果不想进程退出,可以捕捉SIGPIPE信号:

1
2
signal(SIGPIPE, SIG_IGN);
sigaction(SIGPIPE, &(struct sigaction){SIG_IGN}, NULL);

在很多网络工具中都会用到这种方法,比如openssl ,在网络编程中这是需要注意的。