应用间通信(一):详解Linux进程IPC
进程之间是独立的、隔离的,使得应用程序之间绝对不可以互相"侵犯"各自的领地。
但,应用程序之间有时是需要互相通信,相互写作,才能完成相关的功能,这就不得不由操作系统介入,实现一种通信机制。在这种通信机制的监管下,让应用程序之间实现通信。
Linux实现了诸如管道、信号、消息队列、共享内存,这就是Linux进程IPC.
管道
在Linux中管道作为最古老的通信方式,它能把一个进程生产的数据输送到另一个进程。
比如,在shell中输入ls -al /|wc -l
命令来统计根目录下有多少文件和目录。该命令中的"|"
就是让shell创建ls进程后建立一个管道,连接到wc
进程,使用ls的输出经由管道输入给wc.由于ls
输出的是文本行,一个目录或者一个文件就占用一行,wc
通过统计文本行数就能知道有多少目录和文件。
手动建立一个管道,代码如下:
int main()
{
pid_t pid;
int rets;
int fd[2];
char r_buf[1024] = {0};
char w_buf[1024] = {0};
// 把字符串格式化写入w_buf数组中
sprintf(w_buf, "这是父进程 id = %d\n", getpid());
// 建立管道
if(pipe(fd) < 0)
{
perror("建立管道失败\n");
}
// 建立子进程
pid = fork();
if(pid > 0)
{
// 写入管道
write(fd[1], w_buf, strlen(w_buf));
// 等待子进程退出
wait(&rets);
}
else if(pid == 0)
{
// 新进程
printf("这是子进程 id = %d\n", getpid());
// 读取管道
read(fd[0], r_buf, strlen(w_buf));
printf("管道输出:%s\n", r_buf);
}
return 0;
}
上面的代码是一份代码,两个进程,父进程经过 fork 产生了子进程,子进程从 pid==0
开始运行。其中非常重要的是调用 pipe 函数
,作用是建立一个管道。函数参数 fd 是文件句柄数组,其中 fd[0]的句柄表示读端,而 fd[1]句柄表示写端。
运行结果如下:
可看出,子进程通过管道获取了父进程写入的信息,但有没有想过,为什么可以通过pipe
和fork
就可以在父子进程建立管道进行通信呢?
可以将管道想象成一个只存在于内存中的、共享的特殊文件。不过该文件有两个描述符,一个是专用于读,一个专用于写。
上图中 pipe 函数会使 Linux 在父进程中建立一个文件和两个 file 结构,分别用于读取和写入。调用 fork 之后,由于复制了父进程的数据结构,所以子进程也具备了这两个 file 结构,并且都指向同一个 inode 结构。inode 结构在 Linux 中代表一个文件,这个 inode 会分配一些内存页面来缓存数据。但对于管道文件来说,这些页面中的数据不会写入到磁盘。
这也是为什么在应用程序中管道是用文件句柄索引,并使用文件读写函数来读写管道,因为管道本质上就是一个内存中的文件。
和读写文件一样,读写管道也有相应的规则:当管道中没有数据可读时,进程调用 read 时会阻塞,即进程暂停执行,一直等到管道有数据写入为止;当管道中的数据被写满的时候,进程调用 write 时阻塞,直到有其它进程从管道中读走数据。
如果所有管道写入端对应的文件句柄被关闭,则进程调用 read 时将返回 0;如果所有管道的读取端对应的文件句柄被关闭,则会调用 write,从而产生 SIGPIPE 信号,这可能导致调用 write 进程退出。这些规则由 Linux 内核维护,应用开发人员不用操心。
如果要写入的数据量小于管道内部缓冲时,Linux 内核将保证这次写入操作的原子性。但是当要写入的数据量大于管道内部缓冲时,Linux 内核将不再保证此次写入操作的原子性,可能会分批次写入。
这些读写规则,都是基于管道读写端是阻塞状态下的情况,你可以调用 fcntl 调用
,把管道的读写端设置非阻塞状态。这样调用 write 和 read 不满足条件时,将直接返回相应的错误码,而不是阻塞进程。
管道是一种非常简单的通信机制,由于数据在其中像水一样,从水管的一端流动到另一端,故而得名管道。注意,管道只能从一端流向另一端,不能同时对流。之所以说管道简单,正是因为它是一种基于两个进程间的共享内存文件实现的,可以继承文件操作的 api 接口,这也符合 Linux 系统一切皆文件的设计思想。
信号
Linux 信号,也是种古老的进程间通信方式,不过,这里的信号我们不能按照字面意思来理解。Linux 信号是一种异步事件通知机制,类似于计算机底层的硬件中断。
简单来说,信号是 Linux 操作系统为进程设计的一种软件中断机制,用来通知进程发生了异步事件
。事件来源可以是另一个进程,这使得进程与进程之间可以互相发送信号;事件来源也可以是 Linux 内核本身,因为某些内部事件而给进程发送信号,通知进程发生了某个事件。
从进程执行的行为来说,信号能打断进程当前正在运行的代码,转而执行另一段代码。信号来临的时间和信号会不会来临,对于进程而言是不可预知的,这说明了信号的异步特性。
举例:
用定时器在既定的时间点产生信号,发送给当前运行的进程,使进程结束运行。代码如下所示:
void handle_timer(int signum, siginfo_t *info, void *ucontext)
{
printf("handle_timer 信号码:%d\n", signum);
printf("进程:%d 退出!\n", getpid());
// 正常退出进程
exit(0);
return;
}
int main()
{
struct sigaction sig;
// 设置信号处理回调函数
sig.sa_sigaction = handle_timer;
sig.sa_flags = SA_SIGINFO;
// 安装定时器信号
sigaction(SIGALRM, &sig, NULL);
// 设置4秒后产生信号SIGALRM信号
alarm(4);
while(1)
{
;// 死循环防止进程退出
}
return 0;
}
第一步,main 函数中通过 sigaction 结构
设置相关信号,例如信号处理回调函数和一个信号标志。
第二步,安装信号,通过 sigaction
函数把信号信息传递给 Linux 内核,Linux 内核会在这个进程上,根据信号信息安装好信号。
第三步,产生信号,alarm 函数会让 Linux 内核设置一个定时器,到了特定的时间点后,内核发现时间过期了就会给进程发出一个 SIGALRM 信号,由 Linux 内核查看该进程是否安装了信号处理函数,以及是否屏蔽了该信号。确定之后,Linux 内核会保存进程当前上下文,然后构建一个执行信号处理函数的栈帧,让进程返回到信号处理函数运行。
运行结果:
可以看到,程序运行起来等待 4 秒后,内核产生了 SIGALRM 信号,然后开始执行 handle_timer 函数。请注意,我们在 main 函数没有调用 handle_timer 函数,它是由内核异步调用的。在 handle_timer 函数中输出了信号码,然后就调用 exit 退出进程了。
信号码是什么?
信号码是一个整数,是一种信号的标识,代表某一种信号。SIGALRM
定义为14.可以使用kill -l
命令查看下Linux系统支持的全部信号。
常用的信号如下:
上面都是 Linux 的标准信号,它们大多数来源于键盘输入、硬件故障、系统调用、应用程序自身的非法运算。一旦信号产生了,进程就会有三种选择:忽略、捕捉、执行默认操作。其实大多数应用开发者都采用忽略信号或者执行信号默认动作,这是一种“信号来了,我不管”的姿态。
一般信号的默认动作就是忽略
把前面那个“闹钟”程序升一下级。代码如下所示:
static pid_t subid;
void handle_sigusr1(int signum, siginfo_t *info, void *ucontext)
{
printf("handle_sigusr1 信号码:%d\n", signum);
//判断是否有数据
if (ucontext != NULL)
{
//保存发送过来的信息
printf("传递过来的子进程ID:%d\n", info->si_int);
printf("发送信号的父进程ID:%d\n", info->si_pid);
// 接收数据
printf("对比传递过来的子进程ID:%d == Getpid:%d\n", info->si_value.sival_int, getpid());
}
// 退出进程
exit(0);
return;
}
int subprocmain()
{
struct sigaction sig;
// 设置信号处理函数
sig.sa_sigaction = handle_sigusr1;
sig.sa_flags = SA_SIGINFO;
// 安装信号
sigaction(SIGUSR1, &sig, NULL);
// 防止子进程退出
while (1)
{
pause(); // 进程输入睡眠,等待任一信号到来并唤醒进程
}
return 0;
}
void handle_timer(int signum, siginfo_t *info, void *ucontext)
{
printf("handle_timer 信号码:%d\n", signum);
union sigval value;
// 发送数据,也可以发送指针
value.sival_int = subid; // 子进程的id
// 调用sigqueue,向子进程发出SIGUSR1信号
sigqueue(value.sival_int, SIGUSR1, value);
return;
}
int main()
{
pid_t pid;
// 建立子进程
pid = fork();
if (pid > 0)
{
// 记录新建子进程的id
subid = pid;
struct sigaction sig;
// 设置信号处理函数
sig.sa_sigaction = handle_timer;
sig.sa_flags = SA_SIGINFO;
// 安装信号
sigaction(SIGALRM, &sig, NULL);
alarm(4);// 4秒后发出SIGALRM信号
while (1)
{
pause(); // 进程输入睡眠,等待任一信号到来并唤醒进程
}
}
else if (pid == 0)
{
// 新进程
subprocmain();
}
return 0;
}
上面的代码逻辑很简单:首先我们在主进程中调用 fork 建立一个子进程。接着子进程开始执行 subprocmain 函数,并在其中安装了 SIGUSR1 信号处理函数,让子进程进入睡眠。4 秒钟后主进程产生了 SIGALRM 信号,并执行了其处理函数 handle_timer,在该函数中调用 sigqueue 函数,向子进程发出 SIGUSR1 信号,同时传递了相关信息。最后,子进程执行 handle_sigusr1 函数处理了 SIGUSR1 信号,打印相应信息后退出。
运行结果如下:
上图输出的结果,正确地展示了两个信号的处理过程:第一个 SIGALRM 信号是 Linux 内核中的定时器产生;而第二个 SIGUSR1 信号是我们调用 sigqueue 函数手动产生的。
sigqueue 的函数原型如下所示:
typedef union sigval {
int sival_int;
void *sival_ptr;
} sigval_t;
// pid 发送信号给哪个进程,就是哪个进程id
// sig 发送信号的信号码
// 附加value值(整数或指针)
// 函数成功返回0,失败返回-1
int sigqueue(pid_t pid, int sig, const union sigval value);
总结一下
信号是 Linux 内核基于一些特定的事件,并且这些事件要让进程感知到,从而实现的一种内核与进程之间、进程与进程之间的异步通信机制。
一幅图来简单了解一下 Linux 内核对信号机制的实现,如下所示:
无论是硬件事件还是系统调用触发信号,都会演变成设置进程数据结构 task_struct
中 pending
对应的位。这其中每个位对应一个信号,设置了 pending 中的位还不够,我们还要看一看,blocked 中对应的位是不是也被设置了。
如果 blocked 中对应的位也被设置了,就不能触发信号(这是给信号提供一种阻塞策略,对于有些信号没有用,如 SIGKILL、SIGSTOP 等);否则就会触发该位对应的 action,根据其中的标志位查看是否捕获信号,进而调用其中 sa_handler 对应的函数。