进程是一个实体,两个实体间的通信就需要介质。使用不同的介质,就对应了不同的通信方式。进程的通信方式分为两种,同主机和不同主机。下面我们来逐个分析。
1 匿名管道
匿名管道是进程间通信中比较简单的一种,他只用于有继承关系的进程,因为匿名,非继承关系的进程无法找到这个管道,也就无法完成通信,而有继承关系的进程,是通过fork出来的,父子进程可以获得得到管道。进一步来说,子进程可以使用继承于父进程的资源,但是他无法使用叔伯进程的资源。管道通信的原理如下。
父子进程通过fork后,子进程继承了父进程的文件描述符。所以他们指向同一个数据结构。父子进程通常只需要单向通信,父子进程各关闭自己的一端。当父子进程对管道进程读写的时候,操作系统会控制这一切,包括数据的读取和写入,进程的挂起和唤醒。
2 命名管道
正如1中所说,匿名管道可以完成进程间通信,但是他有一定的限制,他的限制来自于他是匿名的,所有其他进程无法找到他,命名管理就是用来解决这个问题。有名字,进程们就可以通过名字去找到这个管道来通信。原理如下。
首先创建一个文件名为my_fifo的文件,然后进程们以读或写的方式去打开这个文件(以什么方式打开则具有对应的能力)。因为一个文件对应一个inode,所以不同的文件以同样的文件名打开一个文件时,他指向的inode是一样的。所以这个inode就是进程间通信的介质。他指向一块内存用于通信。然后其他的就和匿名管道一样了。
3 消息队列
进程间通信的前提是需要共享介质,所以不同的进程间通信,就是找到不同的共享介质。消息队列的原理就是操作系统维护一块数据,然后各个进程通过key来换取一个id,后续通过id进行消息的存取。使用过程。
// 发送进程
msgid = msgget((key_t)1234, 0666 | IPC_CREAT);
msgsnd(msgid, (void *)&data, MAX_TEXT, 0)
// 接收进程
msgid = msgget((key_t)1234, 0666 | IPC_CREAT);
msgrcv(msgid, (void *)&data, BUFSIZ, msgtype, 0)
进程通过key可以获取或者创建(系统维护)一个msqid_ds结构体。然后操作系统会返回key对应的一个id。后续进程通过id对msqid_ds进行存取。msqid_ds是表示一个消息队列的管理者。各个进程使用一个msqid_ds进行通信。操作系统会有权限控制,大小控制等。
4 共享内存
共享内存的原理和消息队列类型,都是开辟一块内存作为通信的介质。
共享内存的使用步骤。
// 通过key拿到一个id,每个进程都通过同样的key则可以得到同样的id
id= shmget((key_t)key, ...);
/*
挂载到进程的地址空间,我们知道进程的地址是使用vma管理的,
这里就是插入一个新的vma,拿到共享内存的首地址address,
接下来就可以对这块内存进行读写了。
*/
address = shmat(shmid, 0, 0);
// 操作
// 从进程地址空间剥离出来,即不再使用这个空间
shmdt(shm);
1 操作系统有一个全局的结构体数据,每次需要一块共享的内存时(shmget),从里面取一个结构体,记录相关的信息。并返回一个id。
2 调用shmat的时候传入shmget返回的id。shmat根据id找到对应的shmid_ds 结构体。新建一个vm_area_struct结构体。开始地址和结束地址根据shmid_ds 中的信息计算,也就是用户申请的大小。接着把vm_area_struct插入进程中管理vm_area_struct的avl树。并且把一些上下文信息保存到页表项。
3 进程访问共享内存范围中的地址时,触发缺页中断。
4 如果还没分配物理地址则分配,否则直接范围已经分配的地址。如果分配了物理地址,则把物理地址写入进程的页表项。下次就不会缺页中断了。
5 其他进程共享该块内存的时候,如果访问范围内的地址,处理过程是类似的。进程访问某一个地址,发生缺页中断,发现这时候共享内存已经映射了物理地址。最后改写自己的页表项。因为各个进程都对应同一块内存,所以操作的时候会互相感知,实现通信。
5 信号
信号通信是进程通信中最简单的一种,但是他所能携带的信息有限,他只是通知其他进程一个信号,而不能发送具体的数据。我们先看一下进程结构体中关于信号的字段。
struct task_struct {
...
// 收到的信号
long signal;
// 每个信号对应的处理函数和一些标记
struct sigaction sigaction[32];
// 当前屏蔽的信号
long blocked;
};
当一个进程发送一个信号给另一个进程的时候,会调用kill函数。我们看看这个函数的逻辑。
int sys_kill(int pid,int sig)
{
struct task_struct **p = NR_TASKS + task;
int err, retval = 0;
/*
pid等于0则给当前进程的整个组发信号,大于0则给某个进程发信号,
-1则给全部进程发,小于-1则给某个组发信号
*/
if (!pid) while (--p > &FIRST_TASK) {
if (*p && (*p)->pgrp == current->pid)
if (err=send_sig(sig,*p,1))
retval = err;
} else if (pid>0) while (--p > &FIRST_TASK) {
if (*p && (*p)->pid == pid)
if (err=send_sig(sig,*p,0))
retval = err;
} else if (pid == -1) while (--p > &FIRST_TASK)
if (err = send_sig(sig,*p,0))
retval = err;
else while (--p > &FIRST_TASK)
if (*p && (*p)->pgrp == -pid)
if (err = send_sig(sig,*p,0))
retval = err;
return retval;
}
/*
发送信号给进程sig是发送的信号,p是接收信号的进程,priv是权限,
1是代表可以直接设置,比如给自己发信息,priv为0说明需要一定的权限
*/
static inline int send_sig(long sig,struct task_struct * p,int priv)
{
if (!p || sig<1 || sig>32)
return -EINVAL;
// 这里使用euid,即进程设置了suid位的话,可以扩大权限,即拥有文件属主的权限
if (priv || (current->euid==p->euid) || suser())
p->signal |= (1<<(sig-1));
else
return -EPERM;
return 0;
}
我们看到发送信号的逻辑很简单,收到判断要给哪些进程发送信号,然后判断有没有发送权限,最后修改另一些进程结构体中的signal字段。这就表示那些进程收到了信号。那么在某个时机,那些进程就会处理这个信号。这些时机包括,系统调用返回和时钟中断返回等。
6 socket
socket通信的原理比较简单,但是他的实现非常复杂,因为网络的情况比较多样复杂。socket通信的方式有基于连接和不基于连接的。不管是否基于连接,socket通信都是基于四元组(源ip、源端口、目的ip、目的端口)。
1 不基于连接,比如UDP,那两端就直接发送数据,对端接收就可以。
2 基于连接,基于连接的,流程会比较长,大概分为建立连接,通信,关闭连接三个步骤。
基于连接的进程间通信,首先需要有一个进程在监听某个端口(监听型socket),我们叫他为服务进程。如果哪个进程想和这个服务进程通信,那么就要先和服务进程完成三次握手。完成后,服务进程会新建一个通信型socket和客户进程进行数据通信。服务进程继续监听。
7 unix域
unix域是基于socket通信的一个特例,因为他的实现中使用了socket技术,但是他是基于单个主机上的进程间通信。因为在同一个主机内,所以就少了很多网络上的问题,那就减少了复杂度。unix域和传统的socket通信类型,服务器监听,客户端连接,由于在同主机,就不必要使用ip和端口的方式,浪费一个端口。unix域采用的是一个文件作为标记。大致原理如下。
1 服务器首先拿到一个socket结构体,和一个unix域相关的unix_proto_data结构体。
2 服务器bind一个文件。对于操作系统来说,就是新建一个文件,然后把文件路径信息存在unix_proto_data中。
3 listen
4 客户端通过同样的文件路径调用connect去连接服务器。这时候客户端的结构体插入服务器的连接队列,等待处理。
5 服务器调用accept摘取队列的节点,然后新建一个通信socket进行通信。
unix域通信本质还是基于内存之间的通信,客户端和服务器都维护一块内存,然后实现全双工通信,而unix域的文件路径,只不过是为了让客户端进程可以找到服务端进程。而通过connect和accept让客户端和服务器对应的结构体关联起来,后续就可以互相往对方维护的内存里写东西了。就可以实现进程间通信。
8 mmap
mmap可以映射文件,从而达到进程间通信的目前,mmap的原理是
1 打开一个文件,拿到一个文件描述符。
2 根据mmap的参数,申请一个vma结构体。并且传入fd表示映射文件。
3 把vma插入到调用进程的vma链表和树中。返回首地址(用户指定或者系统默认分配)。
4 用户通过3中返回的地址,进行内存的读写,这时候对应的是文件的读写。
5 另一个进程同样执行1-4的步骤,即有两个进程都映射到同一个文件。两个进程进行读写的时候,就完成了进程间通信。