应用间通信(一):详解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]句柄表示写端

运行结果如下:
image

可看出,子进程通过管道获取了父进程写入的信息,但有没有想过,为什么可以通过pipefork就可以在父子进程建立管道进行通信呢?
可以将管道想象成一个只存在于内存中的、共享的特殊文件。不过该文件有两个描述符,一个是专用于读,一个专用于写
image
上图中 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 内核会保存进程当前上下文,然后构建一个执行信号处理函数的栈帧,让进程返回到信号处理函数运行。

运行结果:
image

可以看到,程序运行起来等待 4 秒后,内核产生了 SIGALRM 信号,然后开始执行 handle_timer 函数。请注意,我们在 main 函数没有调用 handle_timer 函数,它是由内核异步调用的。在 handle_timer 函数中输出了信号码,然后就调用 exit 退出进程了。

信号码是什么?
信号码是一个整数,是一种信号的标识,代表某一种信号。SIGALRM定义为14.可以使用kill -l命令查看下Linux系统支持的全部信号。

常用的信号如下:
image
image

上面都是 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 信号,打印相应信息后退出。

运行结果如下:
image
上图输出的结果,正确地展示了两个信号的处理过程:第一个 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 内核对信号机制的实现,如下所示:
image

无论是硬件事件还是系统调用触发信号,都会演变成设置进程数据结构 task_structpending 对应的位。这其中每个位对应一个信号,设置了 pending 中的位还不够,我们还要看一看,blocked 中对应的位是不是也被设置了。

如果 blocked 中对应的位也被设置了,就不能触发信号(这是给信号提供一种阻塞策略,对于有些信号没有用,如 SIGKILL、SIGSTOP 等);否则就会触发该位对应的 action,根据其中的标志位查看是否捕获信号,进而调用其中 sa_handler 对应的函数。

image

参考:
应用间通信(一):详解Linux进程IPC

posted @ 2022-10-01 15:03  牛犁heart  阅读(419)  评论(0编辑  收藏  举报