Unix/Linux编程:进程与进程间通信

本文深入讲解Linux系统进程间通信(IPC)的多种机制,包括管道、消息队列、共享内存、信号量及信号,探讨各自的特点、应用场景及编程示例。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

进程间通信

Linux系统上可以同时运行多个进程,一般来讲,进程和进程之前必须是隔离的,各个进程都有自己的虚拟进程空间。然而,需要进程间合作才能完成一个工程,这个时候就需要通信和同步机制。

什么是进程间通信

不同进程中进行数据交换、信号通知等行为。

通信目的

  • 数据传输:一个进程需要将它的数据发送给另外一个进程
  • 共享数据:多个进程想要操作共享数据,一个进程对共享数据的修改,别的进程应该立刻看到
  • 通知事件:一个进程需要向另一个或者一组线程发送消息,通知它们发生了某种事件
  • 资源共享:多个线程之间共享资源 。 【需要通信、同步、锁】
  • 进程控制:有些进程希望完全控制另一个进程的执行(比如debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时直到它的状态改变。

通信方式

读写磁盘文件中的信息是进程间通信的方法之一,但是对很多程序来说,这种方法又慢又缺乏灵活性。因此,Unix/Linux提供了很富的进车间通信(PIC)机制

  • 信号:用来表示事情的发生
  • 管道(也就是shell用户所熟悉的"|"操作符)和FIFO:用于进程间传递数据
  • 套接字:供同一台主机或是联网的不同主机上所运行的进程之间传递数据
  • 文件锁定:为防止其他进程读取或者更新文件内容,允许某进程对文件的部分区域加以锁定
  • 消息队列:用于进程间交换消息(数据包)
  • 信号量:用于同步进程动作
  • 共享内存:允许两个以及以上的进程共享一块内存。当某进程改变了共享内存的内容时,其他所有进行会立即了解这一变化
    在这里插入图片描述
    在这里插入图片描述
    Unix/Linux系统的IPC机制种类繁多,有些功能还彼此重叠,部分原因是由于各种IPC机制是在不同的Unix实现上演变而来的,需要遵循的标准也各不相同。比如 ,就本质而言,FIFO和Unix套接字功能相同,允许同一系统上并无特殊关联的进程彼此间交换数据。二者之所以并存在Unix/Linux系统中,是由于FIFO来自SystemV,而套接字来自BSD

管道

管道可以分为两种:

(1)匿名管道:

  • “|”表示匿名管道,意思是这个类型的管道没有名字,用完就销毁了。就像上面那个命令里面的一样,竖线代表的管道随着命令的执行自动创建、自动销毁。
  • 用户甚至都不知道自己在用管道这种技术,就已经解决了问题。
  • 比如下面:
ps -ef | grep 关键字 | awk '{print $2}' | xargs kill -9
  • 这里面的“|”就是一个管道。它会将前一个命令的输出,作为后一个命令的输入。

(2) 命名管道

  • 这个类型的管道需要mkfifo命令显示的创建
mkfifo hello
  • hello就是这个管道的名称。管道以文件的形式存在,这也符合linux里面一切皆文件的原则。ls可以看到p,也就是pipe的意思
# ls -l
prw-r--r--  1 root root         0 May 21 23:29 hello
  • 接下来,我们可以往管道里面写入东西。比如,写入一个字符串
# echo "hello world" > hello
  • 这个时候,管道里面的内容没有被读出,这个命令就是停在这里的,这说明当一个进程要把它的输出交接到另一个进程做输入,当没有交接完毕的时候,前一个进程是不能撒手不管的。
  • 这个时候,我们就需要重新连接一个终端。在终端中,用下面的命令读取管道里面的内容:
# cat < hello 
hello world
  • 一方面,我们能够看到,管道里面的内容被读取出来,打印到了终端上;另一方面,echo哪个命令正常退出了,也就是交接完毕,前一个进程完成了使命,于是退出了

从管道这个名称可以看出来:

  • 管道是一种单向传输数据的机制,它其实是一个缓存,里面的数据只能从一段写入,从另一端读出。
  • 如果想相互通信,我们需要创建两个管道才行。
  • 这个类似瀑布模型。 所谓的瀑布模型,就是将整个软件开发过程分为多个阶段,往往是上一个阶段完全做完,才将输出结果交给下一个阶段。
  • 我们可以看到,瀑布模型的开发流量效率比较低下,因为团队之间无法频繁的沟通。而且,管道的使用模式,也不适合进程间频繁的交换数据
  • 于是,我们还得想其他的办法,比如我们是不是可以借助传统外企的沟通方式------邮件。邮件有一定的格式,比如抬头、正文、附件等,发送邮件可以建立收件人列表,所有在这个列表中的人,都可以反复的在此邮件的基础上回复,达到频繁沟通的目的。

在这里插入图片描述

消息队列

消息队列是消息的链表,存放在内存中,由内核维护

特点:

  • 与无名管道、有名管道一样,从消息队列中读出消息,消息队列中数据会被删除。
  • 只有内核重启或人工删除时,该消息才会被删除,若不人工删除消息队列,消息队列会一直存在于内存中
  • 消息队列中的消息是有格式的。
    • 消息队列有点儿像邮件,会分成一个一个独立的数据单元,也就是消息体,每个消息体都是固定大小的存储块,在字节流上不连续
    • 消息队列允许一个或多个进程向它写入或者读取消息,并且每条消息都有类型。消息队列标识符,来标识消息队列。消息队列在整个系统中是唯一的、
    • 消息队列可以实现消息的随机查询,消息不一定要以先进先出的次序读取,编程时可以按消息的类型读取。

有了消息这种模型,两个进程之间的通信就像咱们平时发邮件一样,你来一封,我回一封,可以频繁沟通了。

在这里插入图片描述

其消息结构可以定义如下。这里面的类型 type 和正文 text 没有强制规定,只要消息的发送方和接收方约定好即可。

struct msg_buffer {
    long mtype;
    char mtext[1024];
};

接下来,我们需要创建一个消息队列,使用msgget函数。这个函数需要有一个参数key,这是消息队列的唯一标识,应该是为唯一的。如何保证唯一性呢?这个还是和文件关联。

我们可以指定一个文件,ftok 会根据这个文件的 inode,生成一个近乎唯一的 key。只要在这个消息队列的生命周期内,这个文件不要被删除就可以了。只要不删除,无论什么时刻,再调用 ftok,也会得到同样的 key

#include <stdio.h>
#include <stdlib.h>
#include <sys/msg.h>
 
 
int main() {
  int messagequeueid;
  key_t key;
 
 
  if((key = ftok("/root/messagequeue/messagequeuekey", 1024)) < 0)
  {
      perror("ftok error");
      exit(1);
  }
 
 
  printf("Message Queue key: %d.\n", key);
 
 
  if ((messagequeueid = msgget(key, IPC_CREAT|0777)) == -1)
  {
      perror("msgget error");
      exit(1);
  }
 
 
  printf("Message queue id: %d.\n", messagequeueid);
}

在运行上面这个程序之前,我们先使用命令 touch messagequeuekey,创建一个文件,然后多次执行的结果就会像下面这样:

# ./a.out 
Message Queue key: 92536.
Message queue id: 32768.

System V IPC 体系有一个统一的命令行工具:ipcmk,ipcs 和 ipcrm 用于创建、查看和删除 IPC 对象。

例如,ipcs -q 就能看到上面我们创建的消息队列对象。

# ipcs -q
 
 
------ Message Queues --------
key        msqid      owner      perms      used-bytes   messages    
0x00016978 32768      root       777        0            0

接下来,我们来看如何发送信息。发送消息主要调用msgsnd 函数。第一个参数是 message queue 的 id,第二个参数是消息的结构体,第三个参数是消息的长度,最后一个参数是 flag。这里 IPC_NOWAIT 表示发送的时候不阻塞,直接返回。

下面的这段程序,getopt_long、do-while 循环以及 switch,是用来解析命令行参数的。命令行参数的格式定义在 long_options 里面。每一项的第一个成员“id”“type““message”是参数选项的全称,第二个成员都为 1,表示参数选项后面要跟参数,最后一个成员’i’‘t’'m’是参数选项的简称。

#include <stdio.h>
#include <stdlib.h>
#include <sys/msg.h>
#include <getopt.h>
#include <string.h>
 
 
struct msg_buffer {
    long mtype;
    char mtext[1024];
};
 
 
int main(int argc, char *argv[]) {
  int next_option;
  const char* const short_options = "i:t:m:";
  const struct option long_options[] = {
    { "id", 1, NULL, 'i'},
    { "type", 1, NULL, 't'},
    { "message", 1, NULL, 'm'},
    { NULL, 0, NULL, 0 }
  };
  
  int messagequeueid = -1;
  struct msg_buffer buffer;
  buffer.mtype = -1;
  int len = -1;
  char * message = NULL;
  do {
    next_option = getopt_long (argc, argv, short_options, long_options, NULL);
    switch (next_option)
    {
      case 'i':
        messagequeueid = atoi(optarg);
        break;
      case 't':
        buffer.mtype = atol(optarg);
        break;
      case 'm':
        message = optarg;
        len = strlen(message) + 1;
        if (len > 1024) {
          perror("message too long.");
          exit(1);
        }
        memcpy(buffer.mtext, message, len);
        break;
      default:
        break;
    }
  }while(next_option != -1);
 
 
  if(messagequeueid != -1 && buffer.mtype != -1 && len != -1 && message != NULL){
    if(msgsnd(messagequeueid, &buffer, len, IPC_NOWAIT) == -1){
      perror("fail to send message.");
      exit(1);
    }
  } else {
    perror("arguments error");
  }
  
  return 0;
}

接下来,我们可以编译并运行这个发送程序。

gcc -o send sendmessage.c
./send -i 32768 -t 123 -m "hello world"

接下来,我们再来看如何收消息。收消息主要调用msgrcv 函数,第一个参数是 message queue 的 id,第二个参数是消息的结构体,第三个参数是可接受的最大长度,第四个参数是消息类型, 最后一个参数是 flag,这里 IPC_NOWAIT 表示接收的时候不阻塞,直接返回。

#include <stdio.h>
#include <stdlib.h>
#include <sys/msg.h>
#include <getopt.h>
#include <string.h>
 
 
struct msg_buffer {
    long mtype;
    char mtext[1024];
};
 
 
int main(int argc, char *argv[]) {
  int next_option;
  const char* const short_options = "i:t:";
  const struct option long_options[] = {
    { "id", 1, NULL, 'i'},
    { "type", 1, NULL, 't'},
    { NULL, 0, NULL, 0 }
  };
  
  int messagequeueid = -1;
  struct msg_buffer buffer;
  long type = -1;
  do {
    next_option = getopt_long (argc, argv, short_options, long_options, NULL);
    switch (next_option)
    {
      case 'i':
        messagequeueid = atoi(optarg);
        break;
      case 't':
        type = atol(optarg);
        break;
      default:
        break;
    }
  }while(next_option != -1);
 
 
  if(messagequeueid != -1 && type != -1){
    if(msgrcv(messagequeueid, &buffer, 1024, type, IPC_NOWAIT) == -1){
      perror("fail to recv message.");
      exit(1);
    }
    printf("received message type : %d, text: %s.", buffer.mtype, buffer.mtext);
  } else {
    perror("arguments error");
  }
  
  return 0;
}

接下来,我们可以编译并运行这个发送程序。可以看到,如果有消息,可以正确地读到消息;如果没有,则返回没有消息。

# ./recv -i 32768 -t 123
received message type : 123, text: hello world.
# ./recv -i 32768 -t 123
fail to recv message.: No message of desired type

共享内存

但是有时候,项目组之间的沟通需要特别紧密,而且要分享一些比较大的数据。如果使用邮件,就发现,一方面邮件的来去不及时;另外一方面,附件大小也有限制,所以,这个时候,我们经常采取的方式就是,把两个项目组在需要合作的期间,拉到一个会议室进行合作开发,这样大家可以直接交流文档呀,架构图呀,直接在白板上画或者直接扔给对方,就可以直接看到。

可以看出来,共享会议室这种模型,类似进程间通信的共享内存模型。我们知道,每个进程都有自己的虚拟内存空间,不同的进程的虚拟内存空间映射到不同的物理地址中去。这个进程访问A地址和另外一个进程访问A地址,其实访问的是不同的物理内存,对数据的增删查改互不影响。

但是,咱们是不是可以变通一下,拿出一块虚拟地址空间,映射到相同的物理内存里面。这样这个进程写入的东西哎,另一个进程马上就能看到了,都不需要拷贝来拷贝去。

原理

共享内存允许两个不相关的进程访问同一个逻辑内存。共享内存是在两个正在进行的进程之间传递数据的一种非常有效的方式。

大多数的共享内存的实现,都把由不同进程之间共享的内存安排为同一段物体内存。

首先我们都知道我们执行的每一个程序,它看到的内存其实都是虚拟内存,虚拟内存需要进行页表的映射将进程地址映射到物理内存,具体处理大致如下面的图

在这里插入图片描述

在这里插入图片描述

优缺点

当中共享内存的大致原理相信我们可以看明白了,就是让两个进程地址通过页表映射到同一片物理地址以便于通信,你可以给一个区域里面写入数据,理所当然你就可以从中拿取数据,这也就构成了进程间的双向通信。

  • 共享内存是IPC通信当中传输速度最快的通信方式没有之一,理由很简单,客户进程和服务进程传递的数据直接从内存里存取、放入,数据不需要在两进程间复制,没有什么操作比这简单了。

  • 用共享内存进行数据通信,它对数据也没啥限制,也不要求通信的进程有一定的父子关系。

  • 最后就是共享内存的生命周期随内核。
    即所有访问共享内存区域对象的进程都已经正常结束,共享内存区域对象仍然在内核中存在(除非显式删除共享内存区域对象),在内核重新引导之前,对该共享内存区域对象的任何改写操作都将一直保留;简单地说,共享内存区域对象的生命周期跟系统内核的生命周期是一致的,而且共享内存区域对象的作用域范围就是在整个系统内核的生命周期之内。

但是,共享内存也并不完美

  • 共享内存没有提供同步的机制,这使得我们在使用共享内存进行进程间通信时,往往要借助其他的手段来进行进程间的同步工作。

总结:共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的 IPC 方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往与其他通信机制,如信号两,配合使用,来实现进程间的同步和通信

Unix/Linux编程:System V 共享内存

信号量

问题是,如果两个进程 attach 同一个共享内存,大家都往里面写东西,很有可能就冲突了。例如两个进程都同时写一个地址,那先写的那个进程会发现内容被别人覆盖了。

所以,这里就需要一种保护机制,使得同一个共享的资源,同时只能被一个进程访问。这就引入了信号量。信号量和共享内存往往要配合使用。

信号量主要是用来保护共享资源,使得资源在一个时刻只有一个进程(线程)所拥有。进程可以根据它判定是否能够访问某些共享资源,同时,进程也可以修改该标志。除了用于访问控制外,还可用于进程同步。

信号量有以下两种类型:

  • 二值信号量: 最简单的信号量形式,信号量的值只能取0或1,类似于互斥锁。 注:二值信号量能够实现互斥锁的功能,但两者的关注内容不同。信号量强调共享资源,只要共享资源可用,其他进程同样可以修改信号量的值;互斥锁更强调进程,占用资源的进程使用完资源后,必须由进程本身来解锁。
  • 计算信号量:信号量的值可以取任意非负值(当然受内核本身的约束)。

信号量其实就是一个计数器,主要用于实现进程间的互斥和同步,而不是用于存储进程间的通信数据。

我们可以将信号量初始化为一个数值,来代表某种资源的总体数量。对于信号量来讲,会定义两种原子操作:

  • 一个是P 操作,我们称为申请资源操作。这个操作会申请将信号量的数值减去 N,表示这些数量被他申请使用了,其他人不能用了。
  • 另一个是V 操作,我们称为归还资源操作,这个操作会申请将信号量加上 M,表示这些数量已经还给信号量了,其他人可以使用了。

例如,你有 100 元钱,就可以将信号量设置为 100。其中 A 向你借 80 元,就会调用 P 操作,申请减去 80。如果同时 B 向你借 50 元,但是 B 的 P 操作比 A 晚,那就没有办法,只好等待 A 归还钱的时候,B 的 P 操作才能成功。之后,A 调用 V 操作,申请加上 30 元,也就是还给你 30 元,这个时候信号量有 50 元了,这时候 B 的 P 操作才能成功,才能借走这 50 元。

所谓原子操作(Atomic Operation),就是任何一块钱,都只能通过 P 操作借给一个人,不能同时借给两个人。也就是说,当 A 的 P 操作(借 80)和 B 的 P 操作(借 50),几乎同时到达的时候,不能因为大家都看到账户里有 100 就都成功,必须分个先来后到。

Unix/Linux编程:System V信号量

信号

上面讲的进程间通信的方式,都是常规状态下的工作模式,对应我们平时的工作交接,收发邮件,联合开发等其实还有一种异常情况下的工作模式。

例如出现线上系统故障,这个时候,什么流程都来不及了,不可能发邮件,也来不及开会,所有的架构师、开发、运维都要被通知紧急出动。所以,7 乘 24 小时不间断执行的系统都需要有告警系统,一旦出事情,就要通知到人,哪怕是半夜,也要电话叫起来,处理故障。

对于到操作系统中,就是信号。信号没有特别复杂的数据结构,就是用一个代号一样的数字。linux提供了几十种信号,分别代表不同的意义。信号之间依靠它们的值来划分

信号可以在任何时候发送给某一进程,进程需要为这个信号配置信号处理函数。当某个信号发生的时候,就默认执行这个函数就可以了。这就相当于咱们运维一个系统应急手册,当遇到什么情况,做什么事情,都事先准备好,出了事情照着做就可以了。

  • signal是IPC中唯一一种异步的通信方法,它的本质是用软件来模拟硬件的中断机制。因此,信号也被叫做“软件中断”。进程收到信号,就意味着某一事件或异常情况的发生
  • 信号除了可以用于通信外,在其他地方的应用更加广泛

信号的种类

使用kill命令查询当前系统所支持的信号

]$ kill -l
 1) SIGHUP	 2) SIGINT	 3) SIGQUIT	 4) SIGILL	 5) SIGTRAP
 6) SIGABRT	 7) SIGBUS	 8) SIGFPE	 9) SIGKILL	10) SIGUSR1
11) SIGSEGV	12) SIGUSR2	13) SIGPIPE	14) SIGALRM	15) SIGTERM
16) SIGSTKFLT	17) SIGCHLD	18) SIGCONT	19) SIGSTOP	20) SIGTSTP
21) SIGTTIN	22) SIGTTOU	23) SIGURG	24) SIGXCPU	25) SIGXFSZ
26) SIGVTALRM	27) SIGPROF	28) SIGWINCH	29) SIGIO	30) SIGPWR
31) SIGSYS	34) SIGRTMIN	35) SIGRTMIN+1	36) SIGRTMIN+2	37) SIGRTMIN+3
38) SIGRTMIN+4	39) SIGRTMIN+5	40) SIGRTMIN+6	41) SIGRTMIN+7	42) SIGRTMIN+8
43) SIGRTMIN+9	44) SIGRTMIN+10	45) SIGRTMIN+11	46) SIGRTMIN+12	47) SIGRTMIN+13
48) SIGRTMIN+14	49) SIGRTMIN+15	50) SIGRTMAX-14	51) SIGRTMAX-13	52) SIGRTMAX-12
53) SIGRTMAX-11	54) SIGRTMAX-10	55) SIGRTMAX-9	56) SIGRTMAX-8	57) SIGRTMAX-7
58) SIGRTMAX-6	59) SIGRTMAX-5	60) SIGRTMAX-4	61) SIGRTMAX-3	62) SIGRTMAX-2
63) SIGRTMAX-1	64) SIGRTMAX
  • linux支持的信号一共有62种(没有32和33号信号)
    • 1-31是不可靠信号(标准信号)
      • 对同一个进程来说,每种标准信号只会被记录并处理一次
      • 如果发送给某一个进程的标准信号的种类有多个,它们的处理顺序是不确定的
    • 34-64是可靠信号(实时信号)
      • 多个同种类的实时信号都可以记录在案,可以按照信号的发送顺序被处理

信号的来源

  • 键盘输入(比如Ctrl+c)
  • 硬件故障
  • 系统函数调用
  • 软件种的非法运算

如何处理信号

进程响应信号的方式:

  • 忽略
  • 捕捉
  • 执行默认操作

linux对每一个标准信号都有默认的操作方式,一定是下面的方式之一。

  • 终止进程
  • 忽略信号
  • 终止进程并保存内存信息
  • 停止进程
  • 恢复进程(如果进程已经停止)

对于大部分标准信号,我们可以在程序中自定义应该怎么响应它。

  • SIGKILL和SIGSTOP不能自行处理,也不能忽略,对它们的响应只能是系统的默认操作

socket编程

golang之socket编程详解

总结

进程间通信的各种模式:

  • 类似瀑布开发模型中的管道
  • 类似邮件模式中的消息队列
  • 类似会议室联合开发的共享内存和信号量
  • 类似应急预案中的信号

四种中可以根据不同的通信需要,选择不同的模式。

  • 管道,是命令行中常用的模式
  • 消息队列其实很少使用,因为有太多用户级别的消息队列,功能更强大
  • 共享内存加信号量是比较常用的模式
  • 信号更加常用,机制也更复杂
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值