learn-tech/专栏/计算机基础实战课/29应用间通信(一):详解Linux进程IPC.md
2024-10-16 10:18:29 +08:00

278 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

因收到Google相关通知网站将会择期关闭。相关通知内容
29 应用间通信详解Linux进程IPC
你好我是LMOS。
通过前面的学习,我们对进程有了一定的认知,进程之间是独立的、隔离的,这种安排,使得应用程序之间绝对不可以互相“侵犯”各自的领地。
但是应用程序之间有时需要互相通信互相协作才能完成相关的功能。这就不得不由操作系统介入实现一种通信机制。在这种通信机制的监管之下让应用程序之间实现通信。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产生了子进程子进程从25行代码开始运行。其中非常重要的是调用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函数中发生了很多事情我们一步一步来梳理。-
第一步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的标准信号它们大多数来源于键盘输入、硬件故障、系统调用、应用程序自身的非法运算。一旦信号产生了进程就会有三种选择忽略、捕捉、执行默认操作。其实大多数应用开发者都采用忽略信号或者执行信号默认动作这是一种“信号来了我不管”的姿态。
一般信号的默认动作就是忽略有一些信号的默认动作可能是终止进程、终止进程并保存内存信息、停止进程、恢复进程你可以自己对照上表看看具体是哪些信号。还有一些信号比如SIGKILL、SIGSTOP它是不能由应用自己捕捉处理的也不能被忽略只能执行操作系统的默认操作。为什么要这么规定呢
我们想一想如果SIGKILL、SIGSTOP信号能被捕捉和忽略那么超级用户和系统自己就没有可靠的手段使进程终止或停止了。
好,现在我们已经了解了信号的基本知识,知道了信号来源、如何发出信号、以及捕获处理信号。可是我们还不知道要如何给其它进程发送信号,以及如何在信号中传送信息。
下面我们就把前面那个“闹钟”程序升一下级。代码如下所示:
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对应的函数。
那怎么判断信号最终是不是抵达了呢这会表现为异步调用了进程某个函数。到这里Linux提供的进程间异步通信——信号我们就讲完了。
进程间的通信方法还有消息队列和共享内存,我们下节课再展开。
重点回顾
进程之间要协作就要有进程间通信机制Linux实现了多种通信机制今天我们重点研究了管道和信号这两种机制。
管道能连接两个进程一个进程的数据从管道的一端流向管道另一端的进程。如果管道空了则读进程休眠管道满了则写进程休眠。这些同步手段由操作系统来完成对用户是透明的。shell中常使用“|”在两个进程之间建立管道,让一个进程的输出数据,成为另一个进程的输入数据。
除了管道信号也是Linux下经典的通信方式。信号比较特殊它总是异步地打断进程使得正在运行的进程转而去处理信号。信号来源硬件、系统和其它进程。发送信号时也能携带一些数据。
这节课的要点,我梳理了导图,供你参考。
思考题
请概述一下管道和信号这两种通信机制的不同。
期待你在留言区跟我交流互动,也希望你可以把这节课分享给更多朋友。