一共就这几种进程通信方式,不进来看看么

3,013 阅读15分钟

为什么需要通信

由于不同的进程运行在各自不同的内存空间中.一方对于变量的修改另一方是无法感知的.因此.进程之间的信息传递不可能通过变量或其它数据结构直接进行,只能通过进程间通信来完成。

并发进程之间的相互通信是实现多进程间协作和同步的常用工具.具有很强的实用性,进程通信操作系统内核层极为重要的部分。

几种常见的进程间通信

管道

管道,一听名字,我们联想到一个管子连通的两端,既然是个管子,就可以从一头输入,从另一头输出,形成一次通信,在shell命令行中,我们经常遇到这样的写法:

adb logcat -v time | grep "tag" > log.txt

熟悉么?‘|’ 符号其实就类似一个管道一样,将前一个命令的输出,通过管道输入到后一命令的输入,完成一次通信,作为一个标准的程序员,探究问题,要看其本质,这样才能将知识融会贯通,UNIX的设计原则是一切皆文件,那我们猜测,管道的本质也是一个文件,前面的进程以写方式打开文件,后面的进程以读方式打开。这样前面写完后面读,于是就实现了通信。而在Linux中,管道被文件系统所管理,文件系统是操作系统中负责管理持久数据的子系统,是在磁盘之上的,为什么你的电脑可以动态扩充磁盘,其实主要归功于文件系统,通过它,我们可以无限扩充,文件系统的好处是即使计算机断电了,磁盘里的数据并不会丢失,可以持久化的保存文件。

再说回管道,管道一种分为两种

  • 匿名管道
  • 命名管道

匿名管道 Pipes

所谓匿名就是没名,用完就没了,上面的‘|’,就是一个匿名管道。它的特点是只能在父子进程中应用,父进程在产生子进程前必须打开一个管道文件,然后fork产生子进程,这样子进程通过拷贝父进程的进程地址空间获得同一个管道文件的描述符,以达到使用同一个管道通信的目的。

命名管道 FIFO

通过 mkfifo 命令显式地创建。

mkfifo hello

hello 就是这个管道的名称。这个时候,我们 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

所以你发下一个问题没有,就是管道,每次发送的数据都是全量的,写入什么,那边就一股脑的收到什么。

Message Ques

消息队列, 很熟悉的词,消息队列跟管道最大的不同是,它独立于发送进程和接受进程而存在,消息队列有点儿像邮件,发送数据时,会分成一个一个独立的数据单元,也就是消息体,每个消息体都是固定大小的存储块,在字节流上不连续。

我们使用 msgget 函数创建一个消息队列,来看下消息队列的示例

#include <cstdio>
#include <cstdlib>
#include <sys/msg.h>

int main() {
    int messagequeueid;
    key_t key;
    
    
    if ((key = ftok("/", 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);
}
Message Queue key: 327682.
Message queue id: 65536.

ipcs -q 查看上面我们创建的消息队列对象,id:65536看到了吧。

zhangzhanyong@0523 ~ % ipcs -q
IPC status from <running system> as of Sun Jul 31 17:02:35 CST 2022
T     ID     KEY        MODE       OWNER    GROUP
Message Queues:
q  65536 0x00050002 --rw-rw-rw- zhangzhanyong    staff

那如何发消息呢? 只需要调用 msgsnd 函数,来看例子:

第一个参数是 message queue 的 id,第二个参数是消息的结构体,第三个参数是消息的长度,最后一个参数是 flag。

//写进程
#include<cstdio>
#include<sys/ipc.h>
#include<sys/msg.h>
#include<unistd.h>
#include<cstring>

struct msgs {
    long msg_types;
    char msg_buf[512];
};

int main()
{
    int qid;
    int len;
    struct msgs pmsg;

    pmsg.msg_types = getpid();

    sprintf(pmsg.msg_buf,"hello! this is %d\n",getpid());

    len = strlen(pmsg.msg_buf);

    qid = msgget(IPC_PRIVATE,IPC_CREAT|0666);

    msgsnd(qid,&pmsg,len,0);

    printf("successfully send a massage to queue:%d\n",qid);

    return 0;

}

怎么读呢?如下

//读进程
#include<cstdio>
#include<cstdlib>
#include<sys/msg.h>

#define BUFSZ 4096

struct msgs
{
    long msg_types;
    char msg_buf[512];
};

int main(int argc,char ** argv)
{
    int qid;
    int len;
    struct msgs pmsg;

    if(argc != 2)
    {
        perror("argc");
    }

    qid = atoi(argv[1]);

    len = msgrcv(qid,&pmsg,BUFSZ,0,0);

    if(len > 0)
    {
        pmsg.msg_buf[len] = '\0';

        printf("qid %d\n",qid);
        printf("msg type %ld\n",pmsg.msg_types);
        printf("msg text %s\n",pmsg.msg_buf);
    }
    else if(len == 0)
    {
        printf("no massage!\n");
    }
    else
    {
        perror("msgrcv error!\n");
    }

    return 0;
}

然后再命令行执行如下命令

zhangzhanyong@0523 untitled1 % gcc main.cpp -o snd_msg
zhangzhanyong@0523 untitled1 % ./snd_msg 
successfully send a massage to queue:65538
zhangzhanyong@0523 untitled1 % gcc rcv.cpp -o rcv_msg
zhangzhanyong@0523 untitled1 % ./rcv_msg 65538
qid 65538
msg type 10159
msg text hello! this is 10159

看到了吧,通过自定义消息模型,我们实现了消息队列方式的跨进程通信。

消息队列和管道的对比

对比可以增加你对它的记忆和认知,如下:

  • 匿名管道用于父子进程,消息队列没有这个要求,还有进程结束之后,匿名管道就释放了,但是消息队列还会存在(除非显示调用函数销毁)
  • 管道本质还是文件,存放在磁盘上,访问速度慢,消息队列是数据结构,存放在内存,访问速度快

Shared Memmory

共享内存, 我们知道程序会被分配到虚拟地址中,当需要加载数据时,通过中断机制,从真正的磁盘加载数据,每个应用程序都分配相同的虚拟地址,正常来说会映射到不同的物理地址,可偏偏我们有特殊的需求,可以将虚拟地址映射到相同的物理内存地址,这样就实现了内存共享,当进程A写入时,进程B便可以看到。同上我们写个例子来看下

创建共享内存-shmget

int shmget(key_t key, size_t size, int flag);

第一个参数是 key,和 msgget 里面的 key 一样,都是唯一定位一个共享内存对象,第二个参数是共享内存的大小,第三个参数如果是 IPC_CREAT,表示创建一个新的共享内存。

上面函数执行后,可以查看一下共享内存

zhangzhanyong@0523 untitled1 % ipcs

T     ID     KEY        MODE       OWNER    GROUP
Shared Memory:
m  65536 0x000004d2 --rw-rw-rw- zhangzhanyong    staff

上面只是创建了共享内存,我们的进程该如何访问呢?那就需要另一个函数,往下

访问内存-shmat

void *shmat(int shm_id, const void *addr, int flag);

第一个参数就是上面shmget函数返回的id值,第二个参数我们使用NULL即可,让内核自己选择一个合适的地址,第三个参数标志位,通常为0。这里访问,类似于建立连接,既然是建立连接,那一般也会有断开连接,如下

断开连接-shmdt

int shmdt(void *addr);

共享内存使用完毕后通常通过 shmdt 断开连接,其实断开还不算,我们还可以删除这块内存。

删除共享内存-shmctl

int shmctl(int shm_id, int cmd, struct shmid_ds *buf);

第一个参数就不讲了,第二个参数 cmd,通常有几个值,IPC_STAT 把shmid_ds结构中的数据设置为共享内存的当前关联值,即用共享内存的当前关联值覆盖shmid_ds的值。IPC_SET 如果进程有足够的权限,就把共享内存的当前关联值设置为shmid_ds结构中给出的值。IPC_RMID 删除共享内存段。第三个参数 buf是一个结构指针,它指向共享内存模式和访问权限的结构。

共享内存的优缺点

优点

  • 通信方便,函数简单易用
  • 数据免去传送拷贝过程,直接访问同一块内存,提高通信效率
  • 不需要父子进程关系

缺点

  • 没有同步机制,如果产生并发读写,需要利用锁来同步

Semaphore

信号量, 共享内存的缺点是没有同步机制,如果可以做到同一个共享的资源,同一时间只能被一个进程访问,那是不是就解决了这个缺点呢?是的,这时信号量就出现了。信号量和共享内存往往要配合使用。

信号量其实是一个计数器,主要用于实现进程间的互斥与同步,而不是用于存储进程间通信数据。那如何创建信号量呢?跟共享内存创建差不多

创建信号量-semget

int semget(key_t key, int num_sems, int sem_flags);

第一个参数 key 也是类似的,第二个参数 num_sems 不是指资源的数量,而是表示可以创建多少个信号量,形成一组信号量,也就是说,如果你有多种资源需要管理,可以创建一个信号量组。第三个参数设置信号量的访问权限

初始化资源-semctl

int semctl(int semid, int semnum, int cmd, union semun args);

union semun
{
  int val;
  struct semid_ds *buf;
  unsigned short int *array;
  struct seminfo *__buf;
};

第一个参数 semid 是这个信号量组的 id,第二个参数 semnum 才是在这个信号量组中某个信号量的 id,第三个参数是命令,如果是初始化,则用 SETVAL,详细看下面,第四个参数是一个 union。如果初始化,应该用里面的 val 设置资源总量。

cmd取值如下:

  • GETNCNT:返回值为semncnt的取值
  • GETPID:返回值为sempid的取值
  • GETVAL: 返回值为semval的取值
  • GETZCNT:返回值为semzcnt的取值
  • PC_INFO: 返回值为内核中信号量集数组的最高索引值
  • SEM_INFO: 与IPC_INFO相同
  • SEM_STAT: 返回值为semid指定的标识符

资源搞定后,就剩下操作了

操作信号量-semop

int semop(int semid, struct sembuf semoparray[], size_t numops);

struct sembuf 
{
  short sem_num; // 信号量组中对应的序号,0~sem_nums-1
  short sem_op;  // 信号量值在一次操作中的改变量
  short sem_flg; // IPC_NOWAIT, SEM_UNDO
}

第一个参数还是信号量组的 id,一次可以操作多个信号量,第二个参数将这些操作放在一个数组中,第三个参数 numops 就是有多少个操作。数组的每一项是一个 struct sembuf,里面的第一个成员是这个操作的对象是哪个信号量。

其中sem_op变量,若大于0则释放掉资源,小于0则获取共享资源,等于0表示资源已经处于使用状态。通过信号量同步进程间通信还是相当复杂的存在,由于应用复杂,我们就不研究那么深了,我们先关心学习对我们利用价值更高的知识。也可以在之后用到时,再深入研究哦。学习也是以选择的过程,不要死磕细节。程序员的时间很宝贵,不要陷进去无法自拔,反而是在浪费生命。

信号量有哪些优缺点?

优点

  • 当然是进程同步了

缺点

  • 你也看到了,信号量是有限的

Message Passing

消息传递系统,在该机制中,进程不必借助任何共享存储区域或数据结构,而是以格式化的消息为单位,将通信的数据封装在消息中,并利用操作系统提供的一组通信命令(原语)在进程间传递消息。

按实现方式不同,可分为两类:

  • 直接通信方式Direct Commnication:发送指令进程利用 OS 所提供的发送原语,直接把消息发送给目标进程
  • 间接通信方式Indirect Commnication:发送和接收进程通过共享中间实体(即邮箱)的方式进行消息的发送和接收

Direct Commnication

在直接通信下,进程必须明确命名通信中的发送者或接收者。

send() 和 receive() 定义为:

  • send(P, message)– 向进程 P 发送消息
  • receive(Q, message)– 从进程 Q 接收消息
  • receive(id, message)– 从任何进程接收消息,Id 替换为发送方进程的名称

直接通信中的通信链路具有以下特性:

  • 链接是自动建立的。进程需要彼此的身份才能发送消息。
  • 一个链接恰好与两个进程相关联。
  • 在两个进程之间,只有且只能一个链接。

Indirect Commnication

在间接通信中,消息从邮箱或端口发送和接收。这些进程可以将消息放入邮箱或从中删除消息。邮箱具有唯一标识。

只有拥有共享邮箱的两个进程才能进行通信。

  • send(A, message)– 向邮箱 A 发送消息
  • receive(A, message)– 从邮箱 A 接收消息

在该方案中,通信链接具有以下特点:

  1. 只有当两个进程具有相同的共享邮箱时,才会在两个进程之间建立链接。
  2. 一个链接可能与两个以上的进程相关联。
  3. 一对通信进程之间可能存在不同的链接,每个链接对应一个邮箱。

Socket

套接字,一个套接字就是一个通信标识类型的数据结构,包含通信目的地址、通信使用的端口号、网络通信的传输协议、进程所在的网络地址和针对客户或服务程序提供的不同系统调用(或 API 函数),是进程通信和网络通信的基本构件。套接字为客户-服务器模型而设计,通常包括两类:

  • 基于文件型:通信进程都运行在同一台机器环境中,套接字基于本地文件系统支持,一个套接字关联到一个特殊文件,通信双方通过对该文件读写实现通信。
  • 基于网络型:通常采用非对称方式通信,即发送者需要提供接收者命名。发送进程发出连接请求,随机申请一个套接字,主机为之分配一个端口与套接字绑定;接收进程拥有全局公认的套接字和指定端口(例如 http 服务器监听端口:80),并通过监听端口等待用户请求。

套接字3种类型

  • 流式套接字,即TCP套接字,用SOCK_STREAM表示
  • 数据报套接字,即UDP套接字(或称无连接套接字),用SOCK_DGRAM表示
  • 原始套接字,用SOCK_RAM表示

套接字地址结构由网络地址端口号组成,如192.168.1.1:3000

一般来说,Socket是以客户端和服务端的形式存在,下面列出各端主要经历的函数。

服务端Socket创建函数

int sockfd = socket(int domain, int type, int protocol);
  • sockfd: 套接字描述符,一个整数(如文件句柄)
  • domain: 整数,指定通信域。我们使用 POSIX 标准中定义的 AF_LOCAL 来在同一主机上的进程之间进行通信。对于通过 IPV4 连接的不同主机上的进程之间的通信,我们对通过 IPV6 连接的进程使用 AF_INET 和 AF_I NET 6。
  • type: 通信类型
    SOCK_STREAM:TCP(可靠,面向连接)
    SOCK_DGRAM:UDP(不可靠,无连接)
  • 协议: Internet 协议(IP)的协议值,为 0。这与出现在数据包 IP 标头中的协议字段上的数字相同。(有关详细信息,请参阅 man 协议)

服务端Socket绑定函数

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

创建套接字后,绑定函数将套接字绑定到 addr(自定义数据结构)中指定的地址和端口号。

服务端Socket监听接口

int listen(int sockfd, int backlog);

它将服务器套接字置于被动模式,等待客户端到服务器以建立连接。backlog 定义了 sockfd 的挂起连接队列可能增长到的最大长度。如果队列已满时连接请求到达,客户端可能会收到带有 ECONNREFUSED 指示的错误。

服务端Socket接收接口

int new_socket = accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

为套接字挂起连接队列中的第一个连接请求,sockfd,创建一个新的连接套接字,并返回一个引用该套接字的新文件描述符。至此,客户端和服务器之间的连接已经建立,它们已经准备好传输数据了。

客户端Socket创建及连接

int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

一般客户端只需要两步

  • 套接字连接: 与服务器创建套接字完全一样
  • Connect: connect() 系统调用将文件描述符 sockfd 引用的套接字连接到 addr 指定的地址。服务器的地址和端口在 addr 中指定。

客户端和服务端通信函数

int read(int fd,void *buffer,int length)
    
用例:
read(new_socket, buffer, 1024);
   

int send(int sockfd,void *buf,int len,int flags)
    
用例:
char* hello = "Hello from server";
send(new_socket, hello, strlen(hello), 0);

好了套接字就学到这里。让我们总结下

总结

我们一共学习了如下几种进程通信方式

  • 管道
  • 消息队列
  • 共享内存
  • 信号量
  • 消息传递系统
  • 套接字

管道分匿名和命名管道,管道的本质还是以文件形式,所以不如消息队列这种速度快,因为消息队列以数据结构的方式直接存放在内存中,共享内存也是内存中直接通信,但和消息队列最大的不同就是,共享内存是通过映射同一块内存实现,而消息队列并没有,它还是需要经历数据从用户空间到内核空间的拷贝,所以目前为止共享内存应该是效率最高的进程通信方式,但共享内存有个缺点就是无法自动同步,如果并发操作,就需要通过一些技术手段进行同步操作,这时信号量的作用就来了,它最主要的功能就是实现了进程间的同步机制,再有消息传递系统,我们可以不Care,套接字还是要学一下的,毕竟在它的基础上我们可以实现TPC、UDP通信。行了本次分享就这样,有问题留言哦。