朝小闇的博客

海上月是天上月,眼前人是心上人

分布式系统设计学习笔记(一)进程通信

第二章.进程通信

  • 进程通信的两种方式:
    • 同一物理节点上不同进程:管道、共享内存和消息队列等实现;
    • 不同物理节点上不同进程:通信网络实现;

1 同一节点上进程间通信

1.1 管道

单向通信信道,使用write系统调用发送数据,使用read系统调用接收数据。

  • 无名管道:

    • 简称管道,只在父子进程或同父子进程间使用,并与创建该管道的进程同时存在;

    • 管道实质是允许双向通信的,但被强制用作单向通信信道。为了避免两个进程同时自由读写管道,因此习惯上每个进程只读或只写,并且关闭本进程不需要的文件描述符;

    • 通过pipe系统调用创建管道,成功时返回两个文件描述符,一个向管道写入,一个向管道读出:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      /*1.定义*/
      #include <unistd.h>
      // 如果调用成功,filedes[0]用来从管道读出数据,filedes[1]用来向管道写入数据
      int pipe(int filedes[2]);

      /*2.实例*/
      #include <unistd.h>
      #include <stdio.h>
      #define MSGSIZE 16
      char *msg1 = "hello, book # 1 ";
      char *msg2 = "hello, book # 2 ";
      char *msg3 = "hello, book # 3 ";
      main(){
      char inbuf[MSGSIZE];
      int p[2],j;
      // 创建一个管道
      if(pipe(p) == -1){
      perror("pipe call failed");
      exit(1);
      }
      // 通过fork调用生成的子进程会共享父进程的文件描述符p,父进程创建一个管道后,则可以实现父子进程通信
      switch(pid == fork()){
      case -1:
      perror("fork call failed");
      exit(2);
      case 0:
      // 如果是child进程则写入管道
      write(p[1],msg1,MSGSIZE);
      write(p[1],msg2,MSGSIZE);
      write(p[1],msg3,MSGSIZE);
      default:
      // 如果是parent进程则从管道读出
      for(j=0;j<3;j++){
      read(p[0],inbuf,MSGSIZE);
      printf("%s\n",inbuf);
      }
      wait(NULL);
      exit(0);
      }
      }
  • FIFO或命名管道:

    • FIFO在读写时具有和管道相同的属性,甚至在内核级FIFO和管道共享大量的公共代码,但是FIFO是一个持久的设备(文件),被赋予了一个UNIX文件名,有自己的所有者、大小以及访问权限;

    • 初始化部分与管道不同:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      #include <sys/types.h>
      #include <sys/stat.h>
      // mkfifo创建FIFO,文件,参数pathname指定名字,mode给定FIFO的权限,权限有四个如0666
      int mkfifo(const char *pathname, mode_t mode);
      // 创建完成后必须使用open打开该文件,类似于各种文本文件等的打开
      #include <fcntl.h>
      // 注意,此时open函数会被阻塞直至另一个进程打开这个FIFO来读取数据或者该FIFO已被打开则open立即返回调用
      fd = open(pathname, O_WRONLY);
      // 另一种非阻塞调用必须使用O_NONBLOCK,如果没有进程打开FIFO读取,则open返回-1,而不阻塞
      if((fd = open(pathname, O_WRONLY|O_NONBLOCK)) == -1)
      perror("open fail");
      // ?打开之后怎么写入和读取FIFO?

1.2 消息队列

消息队列是一个消息的链接列表,消息都保存在内核中。同时该队列具有FIFO特性,但其中的消息能够以随意的顺序被检索,或者通过消息类型从队列中检索消息。

  • 创建和打开消息队列:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    #include <sys/types.h>
    #include <sys/ipc.h>
    #include <sys/msg.h>
    // key是队列键值,成功则返回消息队列ID,失败返回-1并设置出错变量errno的值
    int msgget(key_t key, int flags);

    /* flags: IPC_CREAT(无key队列则创建队列) IPC_EXCL(和前者一起使用,已有则失败,不能单独使用)
    errno: 出错变量
    EACCESS 拒绝访问
    EEXIST 队列存在不能创建
    EIDRM 队列已被标记为删除
    ENOENT 队列不存在
    ENOMEN 没有足够内存创建队列
    ENOSPC 超出最大队列限制
    */

    // 创建和打开一个消息队列
    int open_queue(key_t key){
    int qid;
    if((qid=msgget(key,IPC_CREAT|0660)) == -1){
    return(-1);
    }
    return(qid);
    }
  • 写入队列:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    #include <sys/types.h>
    #include <sys/ipc.h>
    #include <sys/msg.h>
    // 添加新消息到队列末尾,执行成功返回0,失败返回-1并设置全局出错变量errno
    // msqid是msgget调用返回的队列ID;msgsz是添加消息的字节数不包含消息类型的长度;msgp是一个指向消息的指针,消息由msgbuf结构构成;msgflag设置为0则省略,设置为IPC_NOWAIT表示队列已满时不写入,且控制返回到调用进程,没有指明时调用进程会挂起直到写入;
    int msgsnd(int msqid, struct msgbuf *msgp, int msgsz, int msgflag);

    // 发送消息
    int send_message(int qid, struct msgbuf *qbuf){
    int result, length;
    length = sizeof(struct msgbuf) - sizeof(long);
    if((result = msgsnd(qid,qbuf,length,0)) == -1){
    return(-1);
    }
    return(result);
    }
  • 读取队列消息:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    #include <sys/types.h>
    #include <sys/ipc.h>
    #include <sys/msg.h>
    // 调用成功返回复制到消息缓冲区的字节数,失败返回-1
    // msqid指定要读取消息的队列;msgp是存储消息的缓冲区地址;msgsz是消息缓冲区的长度,不包括mtype的长度;mtype是要读取的消息类型,为0时返回最早的消息;msgflag=IPC_NOWAIT时,没有消息时调用会把ENOMSG返回到调用进程,否则挂起直到获取新消息;
    int msgrcv(int msqid, struct msgbuf *msgp, int msgsz, long mtype, int msgflag);

    // 读取消息,读取消息后,队列中该条消息被删除
    int read_message(int qid, long type, struct msgbuf *qbuf){
    int result, length;
    length = sizeof(struct msgbuf) - sizeof(long);
    if((result = msgrcv(qid,qubf,legth,type,0)) == -1){
    return(-1);
    }
    return(result);
    }

    /* msgflag:
    MSG_NOERROR 返回消息比msgsz字节多时,消息被截短到msgsz,且不会提示;
    否则msgrcv返回-1且设置errno变量为E2BIG,仍保留原消息;
    IPC_NOWAIT 无指定类型消息,调用进程返回,并设置errno变量为ENOMSG;
    否则msgrcv阻塞直到出现满足条件的消息;
    */
    // 查看消息队列,检查符合条件的消息是否到来
    int peek_message(int qid, long type){
    int result;
    if((result = msgrcv(qid,NULL,0,type,IPC_NOWAIT)) == -1){
    // 有消息并且未被读取
    if(errno == E2BIG)
    return(TRUE);
    }
    return(FALSE);
    }
  • 删除消息队列:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    #include <sys/types.h>
    #include <sys/ipc.h>
    #include <sys/msg.h>
    // msgctl函数提供对消息队列的控制功能,msgqid为已存在的队列ID
    /* cmd:
    IPC_RMID 删除队列msqid
    IPC_STAT 使用队列的msqid_ds结构填充buf,使用户查看队列的内容而不会删除任何消息,是一种非破坏的读操作;
    IPC_SET 让用户改变队列的UID、GID、访问模式和队列的最大字节数
    */
    int msgctl(int msqid, int cmd, struct msqid_ds *buf);

    // 删除消息队列
    int remove_queue(int qid){
    if(msgctl(qid,IPC_RMID,0) == -1){
    return(-1)
    }
    return(0);
    }

1.3 共享内存

共享内存是对将要在多个进程之间映射和共享的内存区域所做的映射。不同进程可以同时访问相同的内存区域,能够共享地址空间的若干部分,这种通信方式没有中介,最快但并不容易实现。

  • 共享内存技术的实现:

  • image-20210303155149702

    1. 由一个进程创建一个共享内存段,并设置大小和访问权限。使用系统调用shmget
    2. 进程挂载此存储段并映射到自己当前数据空间,每个进程通过其挂接的地址访问共享内存段。使用系统调用shmat
    3. 当进程对共享内存段的操作结束时,使用系统调用shmdt从虚拟地址空间中分离该共享内存段;
    4. 进程使用系统调用shmctl对该共享内存段进行操作;
    5. 当所有进程完成操作,由共享内存的创建者进程删除该共享内存;
  • 共享内存的操作函数:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    // 1.shmget:通过key值获取或创建一块共享内存
    #include <sys/shm.h>
    int shmget(key_t key, int size, int shmflg);

    // 2.shmctl:查询共享内存的使用情况以及进行一些控制操作
    #include <sys/shm.h>
    int shmctl(int shmid, int cmd, struct shmid_ds *buf);
    shmctl(shm_id,IPC_RMID,0);//删除shm_id指示的共享内存

    // 3.shmat:根据shmid得到指向共享内存段的指针,即这个共享内存段被附加到进程虚拟地址空间中
    #include <sys/shm.h>
    void *shmat(int shmid, const void *shmaddr, int shmflg);

    // 4.shmdt:将共享内存段从调用进程的地址空间中分离出去,参数shmaddr是通过shmat得到的指向共享内存的指针
    #include <sys/shm.h>
    int shmdt(const void *shmaddr);

2 不同节点上的进程间通信

对于不同节点上的进程通信,主要采用Internet socket技术实现,计算机网络通信通常包括广播通信、多播通信和点对点通信。

2.1 网络通信分层结构模型

  • ISO OSI/RM 七层协议:

    image-20210303162849640
  • 因特网模型(该模型请参考本人计算机网络笔记部分):

    image-20210303162954387
    • 分布计算系统的通信模型:

      • 由于中间件包含许多通用协议,因此在应用层和传输层之间独立存在;
      image-20210303163047519

2.2 进程通信原语

通信原语是分布式操作系统内核和服务层之间使用进程通信协议实现的接口,用于分布式进程间的通信和同步。

  • 松散耦合分布计算系统不使用共享变量,物理上分开的进程通信实现的两种方法:报文传递、远程过程调用(RPC);
2.21 报文传递:

通过一个进程向另一个进程发送一个包含共享数据的报文完成进程通信。适用于两个不共享内存的进程。

1
2
3
// 发送和接收原语
send(b,msg);
receive(a,msg);
  • 阻塞原语:

    • 又称同步原语,send一直被阻塞直到发送的信息被接收方收到并得到应答,receive也一直被阻塞直到要接收的信息到达且被接收;

    • 获得了更好的同步性但丢失了并发性;

    • 缓冲器:

      • ?!发送的信息和对发送信息的应答都放在缓冲器中,直到相应的进程被解除阻塞;
      image-20210303205241681 image-20210303205301451
    • 是否阻塞:

      • 探听法:通过判断缓冲区实现。实时应用采用循环检测,被称作忙等待;或者采用定时检测,时间间隔不能太短;
      • 中断法:不需要浪费时间检测缓冲区,更为有效;
    • 定时器:防止测试失效或中断未产生

      • 无缓冲时,每当read原语执行时,定时器启动,超时则重复执行,重复次数有限;
      • 分布式环境下,一般至少使用一个缓冲区;
  • 非阻塞原语:

    • 又称异步通信原语,非阻塞send原语将要发送的报文送入一个缓冲区后,立即将控制权返还给发送进程,发送进程开始执行下一条语句;
2.22 远程过程调用(RPC)

RPC不仅允许两个进程间传送报文,还能传送像调用参数和返回结果这样更为复杂的数据结构。同时调用者在发送RPC后会被阻塞等待返回值,且同报文传递相比可能会阻塞更长的时间。

2.23 报文传递实例1——socket进程通信

socket:每个socket只属于一个进程,两个socket通过网络主机地址IP和进程端口号Port进行两个进程之间的通信。其中IP和Port在发送者主机经由socket传递打包为报文,接收者主机由接收进程对应socket根据Port接收报文,并传递给相应进程。且支持面向连接的TCP和面向非连接的UDP传输协议。

注:此笔记根据书本,只介绍UNIX环境下的socket通信机制,windows环境下的socket称为Winsock

  • socket原语:

    • /* 1.建立一个socket    
          domain = AF_UNIX,s是UNIX socket;    domain = AF_INET,s是Internet socket;
          type = SOCK_DGRAM,s是面向非连接的socket,选择UDP协议;    type = SOCK_STREAM,s是面向连接的socket,选择TCP协议;
          选择UDP时,protocol = 0 作缺省值
      */
      s = int socket(domain,type,protocol);
      
      /* 2.绑定socket s的地址
          localaddr初始是一个地址,与s结合后指向socket s 的地址,类型是struct sockaddr_in *
          addrlen是这个地址结构的字节数
      */
      int bind(s,localaddr,addrlen);
      
      // 接下来一部分原语都在面向连接通信中实现
      /* 3.发起连接
          远端的socket由其地址指针server指出,serverlen是这个地址结构的字节数
      */
      int connect(s,server,serverlen);
      
      /* 4.创建队列存储想连接到socket s的外来连接请求
          n是该队列的长度,它允许一个服务器处理多个通信请求
      */
      listen(s,n);
      
      /* 5.将一个连向socket s的连接请求从队列中移出,或等待一个连向socket s的连接请求
          成功之后将产生一个新的socket new_sock,该new_sock用于同from地址的远程socket通信
          远程socket地址由from指出,fromlen是这个地址结构的字节数
      */
      new_sock = int accept(s,from,fromlen);
      
      /* flags    MSG_OODB        发送/接收紧急数据
                  MSG_PEEK        检查数据但不读
                  MSG_DONTROUTE    发送不带路由包的数据
      */
      /* 6.发送报文
          已连接状态下,在给定的socket s上发送一个报文,报文由buf指定,长度由buflen指定
      */
      send(s,buf,buflen,flags);
      
      /* 7.接收报文
          已连接状态下,在给定的socket s上接收一个报文,收到的报文放在缓冲区buf里,缓冲区buf大小为buflen
      */
      recv(s,buf,buflen,flags);
      
      /* 8.拆除连接
          拆除一个socket上的连接
      */
      close(s);
      
      /* 9.终止连接
          终止一个socket上的连接,使在排队中的数据立即作废
          how    0    不想读取数据
              1    不再发送数据
              2    不再发送或接收数据
      */
      shutdown(s,how);
      
      /* 10.无连接发送报文
          在无连接的socket上发送一个报文,该报文发送给远程socket,地址由to指出,长度由tolen指定
      */
      sendto(s,buf,buflen,flags,to,tolen);
      
      /* 11.无连接接收报文
          在无连接的socket上接收一个报文
      */
      sendto(s,buf,buflen,flags,from,fromlen);
      
      /* 12.检查连接的一组socket
          检查该socket连接的一组socket中哪些有报文到达需要接收
          nfds指定文件说明符范围,timeout指定select等待的时间,其它三个参数作为位掩码,分别使用读、写、异常处理文件设置
      */
      select(nfds,readfds,writefds,exceptfds,timeout);
    • 通信模型:

      • image-20210304222956670

3 组通信

组通信是指一个报文能够被发送到多个接受者的通信。

  • 组通信:
    • 一到多:广播通信;
    • 多到一;
    • 多到多:排序问题,按接收语义的严格程度分
      • 捎带顺序:保证捎带了标识这些报文关联的信息的报文能按顺序接收;
      • 一致顺序:所有接收者按完全相同的顺序接收报文,但发送顺序和接收顺序可能不同;
      • 全局顺序:按照发送顺序接收;
  • 组通信的实现方式依赖于硬件:
    • 支持组播通信:某些网络可以生成特殊的地址被多台机器监听,当一个报文发送到该地址时,该报文自动传送到监听该地址的机器上;
    • 支持广播通信:含有某种特殊地址的报文可以被网络中所有机器接收,每个机器自己匹配目的地址,不同则丢弃该报文,效率较低;
    • 不支持组播和广播:向一个组内所有接收者个发送一次,效率最低;
  • 设计组通信:
    • 封闭组和开放组:
      • 封闭组:只有组成员才能向全组发送报文,或者组外能向组内成员点对点发送报文,用于并行处理;
      • 开放组:所有进程都允许向这个组发送报文;
    • 对等组和分级组:
      • 对等组:组内所有进程是平等的,不存在单点失效问题,但决策较慢;
      • 分级组:组内进程存在级别,例如协调者工作者等,即使崩溃但能迅速反应;
    • 管理组成员:
      • 集中式管理:设置一个组管理员,高效直接,但存在单点失效问题;
      • 分布式管理:每一个进程的加入离开都需要发送报文给组内所有进程;
      • 三个问题:
        • 如果组中成员发现某个组员进程对所有事情都没有响应则删除该进程;
        • 加入和离开一个组必须和发送报文同步,进入即时同步接收报文;
        • 多个进程失效时,需要有一个进程负责重建组,于是需要重建协议;
    • 组寻址:每个进程组有一个地址
      • 由系统内核实现;
      • 由发送进程实现:发送进程维持一张表,指出接收进程组的所有进程的目的地址;
      • 预测寻址:每个报文包含一个预测表达式(布尔表达式),值为真则接收报文,否则丢弃;
    • 发送和接收原语:
      • group_send
      • group_receive
    • 原子性:组内所有进程要么全部正确接收报文,要么全部丢失;
    • 组重叠:可能出现语义顺序不一致;
-------- 本文结束 感谢阅读 --------