进程间通信
进程间通信
进程间的通信,它的数据空间的独立性决定了它的通信相对比较复杂,需要通过操作系统。以前进程间的通信只能是单机版的,现在操作系统都继承了基于套接字(socket)的进程间的通信机制。这样进程间的通信就不局限于单台计算机了,实现了网络通信。
进程的通信机制主要有:管道、有名管道、消息队列、信号量、共享映射区、共享内存、信号、套接字。
Signal | 信号
信号是在软件层次上对中断机制的一种模拟,即模拟由系统内核发出,由于错误内存冲突等原因引起产生的请求。在原理上,一个进程收到一个信号与处理器收到一个中断请求可以说是一样的。信号是进程间通信机制中唯一的异步通信机制,一个进程不必通过任何操作来等待信号的到达,事实上,进程也不知道信号到底什么时候到达。
信号可以看做异步通知,通知接收信号的进程有哪些事情发生了。信号机制经过 POSIX(一个针对类 Unix 操作系统的标准化协议)实时扩展后,功能更加强大,除了基本通知功能外,还可以传递附加信息。信号事件的发生有两个来源:硬件来源(比如我们按下了键盘或者其它硬件故障);软件来源。
信号分为可靠信号和不可靠信号,实时信号和非实时信号。进程有三种方式响应信号:忽略信号、捕捉信号、执行默认操作。
Semaphore | 信号量
信号量也可以说是一个计数器,常用来处理进程或线程同步的问题,特别是对临界资源的访问同步问题;信号量的本质是一种数据操作锁,它本身不具有数据交换的功能,而是通过控制其他的通信资源(文件,外部设备)来实现进程间通信,它本身只是一种外部资源的标识可以用来控制多个进程对共享资源的访问,不是用于交换大批数据,而用于多线程之间的同步,常作为一种锁机制,防止某进程在访问资源时其它进程也访问该资源。
信号量分为有名与无名,信号量在进程是以有名信号量进行通信的,在线程是以无名信号进行通信的,因为线程 Linux 还没有实现进程间的通信,所以在 sem_init 的第二个参数要为 0,而且在多线程间的同步是可以通过有名信号量也可通过无名信号,但是一般情况线程的同步是无名信号量。无名信号量使用简单,而且 sem_t 存储在进程空间中,有名信号量必须 Linux 内核管理,由内核结构 struct ipc_ids 存储,是随内核持续的,系统关闭,信号量则删除,当然也可以显式删除,通过系统调用删除。
当信号量的值大于或等于 0 时,表示可以供并发进程访问的临界资源数,当小于 0 时,表示正在等待使用临界资源的进程数。更重要的是,信号量的值仅能由 PV 操作来改变。PV 操作与信号量的处理相关,P 表示通过的意思,V 表示释放的意思。PV 操作(原语也叫原子操作 Atomic Operation,是不可中断的过程)是典型的同步机制之一,用一个信号量与一个消息联系起来,当信号量的值为 0 时,表示期望的消息尚未产生;当信号量的值非 0 时,表示期望的消息已经存在。用 P V 操作实现进程同步时,调用 P 操作测试消息是否到达,调用 V 操作发送消息。
Pipe | 管道
管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程间使用,其中进程的亲缘关系通常是指父子进程关系。没有名字并且大小受限,传输的是无格式的流,所以两进程通信时必须约定好数据通信的格式。管道它就像一个特殊的文件,但这个文件只存在于内存中,在创建管道时,系统为管道分配了一个页面作为数据缓冲区,进程对这个数据缓冲区进行读写,以此来完成通信。其中一个进程只能读一个只能写,所以叫半双工通信,为什么一个只能读一个只能写呢?因为写进程是在缓冲区的末尾写入,读进程是在缓冲区的头部读取,他们各自 的数据结构不同,所以功能不同。
管道实现进程间通信的方式如下:
-
父进程创建管道,得到两个⽂件描述符指向管道的两端。
-
父进程 fork 出子进程,⼦进程也有两个⽂件描述符指向同⼀管道。
-
父进程关闭 fd[0],子进程关闭 fd[1],即⽗进程关闭管道读端,⼦进程关闭管道写端。
因为管道只支持单向通信,⽗进程可以往管道⾥写,⼦进程可以从管道⾥读,管道是⽤环形队列实现的,数据从写端流⼊从读端流出,这样就实现了进程间通信。当然,也可以父进程读,子进程写。
Named Pipe | 命名管道
命名管道(NamedPipe)是服务器进程和一个或多个客户进程之间通信的单向或双向管道。不同于匿名管道的是:命名管道可以在不相关的进程之间和不同计算机之间使用,服务器建立命名管道时给它指定一个名字,任何进程都可以通过该名字打开管道的另一端,根据给定的权限和服务器进程通信。命名管道提供了相对简单的编程接口,使通过网络传输数据并不比同一计算机上两进程之间通信更困难,不过如果要同时和多个进程通信它就力不从心了。
匿名管道只能在具有亲缘关系的进程间通信,命名管道与它不同,它提供了一个路径名与之关联,有了自己的传输格式。命名管道和管道的不同之处还有一点是,命名管道是个设备文件,存储在文件系统中,没有亲缘关系的进程也可以访问,但是它要按照先进先出的原则读取数据。同样也是半双工的。
Message Queue | 消息队列
消息队列是由消息的链表,存放在内核中并由消息队列标识符标识。消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。与管道不同的是,消息队列存放在内核中,只有在内核重启时才能删除一个消息队列,同样消息队列的大小也是受限制的。消息队列提供了一种从一个进程向另一个进程发送一个数据块的方法。每个数据块都被认为是一个管道,接收进程可以独立地接收含有不同管道的数据结构。
消息队列与命名管道一样,每个数据块都有一个最大长度的限制。我们可以将每个数据块当作是一种消息类型,发送和接收的内容就是这个类型对应的消息,每个类型相当于一个独立的管道,相互之间互不影响。消息队列进行通信的一些操作:
-
使用 msgget()函数创建打开队列;
-
使用 msgrcv()函数从队列中读数据;
-
使用 msgsnd()函数写数据到队列中;
-
使用 msgctl()函数控制消息队列。
Shared Memory | 共享内存
共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的 IPC 方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往与其他通信机制,如信号量配合使用,来实现进程间的同步和通信。
在使用共享内存区前,必须通过 mmap 将其附加到进程的地址空间或说为映射到进程空间。两个不同进程 A、B 共享内存的意思是,同一块物理内存被映射到 进程 A、B 各自的进程地址空间。进程 A 可以即时看到进程 B 对共享内存中数据的更新,反之亦然。由于多个进程共享同一块内存区域,必然需要某种同步机制,互斥锁和信号量都可以。
采用共享内存通信的一个显而易见的好处是效率高,因为进程可以直接读写内存,而不需要任何数据的拷贝。对于像管道和消息队列等通信方式,则需要在内核和用户空间进行四次的数据拷贝,而共享内存则只拷贝两次数据:一次从输入文件到共享内存区,另一次从共享内存区到输出文件。
实际上,进程之间在共享内存时,并不总是读写少量数据后就 解除映射,有新的通信时,再重新建立共享内存区域。而是保持共享区域,直到通信完毕为止,这样,数据内容一直保存在共享内存中,并没有写回文件。共享内存中的内容往往是在解除映射时才写回文件的。因此,采用共享内存的通信方式效率是非常高的。
-
实现共享内存的步骤如下:创建内存共享区,进程 1 通过操作系统提供的 api 从内存中申请一块共享区域,linux 系统中可以通过 shmget 函数实现,生成的共享内存块与某个特定的 key 进行绑定。
-
映射共享内存到进程 1 中:在 Linux 环境中,可以通过 shmat 实现。
-
映射共享内存到进程 2 中:进程 2 通过进程 1 的 shmget 函数和同一个 key 值,然后执行 shmat,将这个内存映射到进程 2 中。
-
进程 1 与进程 2 中相互通信:共享内存实现两个映射后,可以利用该区域进行信息交换,由于没有同步机制,需要参与通信的进程自己协商处理。
-
撤销内存映射关系:完成通信之后,需要撤销之前的映射操作,通过 shmdt 函数实现。
-
删除共享内存区:在 Linux 中通过 shctl 函数来实现。
共享内存映射区
通过 mmap 函数将磁盘文件的数据映射到内存,用户修改内存就能修改磁盘文件(数据实际存储在磁盘上)。
-
有血缘关系的进程间通信:因为子进程和父进程永远共享的有两个部分,第一是文件描述符,第二就是内存映射区。所以只需要父进程先用 open()函数打开一个文件得到 fd,再利用 mmap()函数创建一个内存映射区,然后 fork()一个子进程出来,子进程就可以直接通过 mmap() 返回的内存映射区首地址对共享部分进行读写了,相当于就是有血缘关系的进程间通信机制。
-
没有血缘关系的进程间通信:还是让一个进程先通过 open()函数打开一个文件得到 fd,再利用 mmap()创建一个内存映射区并得到首地址 ptr,然后另外一个无血缘关系的进程通过打开这个文件得到 fd1,也利用 mmap()函数将 fd1 传参进去,就会得到那个进程创建的内存映射区首地址 ptr1 了,这样两个互不相关的进程通过 ptr 和 ptr1 各自进行读写即可。(因为这两个内存映射区指向的磁盘存储区都是同一个)。
注意,在共享内存中打开的具体的文件是无所谓的,并不会被用到。
Socket | 套接字
套接字也是一种进程间通信机制,与其他通信机制不同的是,它可用于不同主机间的进程通信。