开头
每个进程的用户地址空间都是独立的,进程与进程之间,内部空间是隔离的,进程 A 不可能直接使用进程 B 的变量名的形式得到进程 B 中变量的值。但内核空间是每个进程都共享的,所以进程之间要通信必须通过内核。实现进程与进程之间的通信,常用的方式主要有:管道、消息队列、共享内存、信号量、信号、socket等等。
一、管道
在 Linux 命令中,常见的“|”符号就是一种管道。比如:
ps auxf | grep mysql
上面的命令中,“|”的功能是将前一个命令(ps auxf)的输出,作为后一个命令(grep mysql)的输入。这种管道没有名字,匿名管道,用完就销毁。命名管道也被叫做 FIFO,因为数据的传输方式是先进先出(first in first out)。
管道传输数据是单向的,如果想相互通信,需要创建两个管道才行。
管道创建、写入、读取
创建
mkfifo myPipe
myPipe 是新创建的管道的名称,基于 Linux 一切皆文件的理念,管道也是以文件的方式存在,可以用 ls 看到文件类型是 p,也就是 pipe(管道) 的意思:
$ ls -l prw-r--r--. 1 root root 0 Jul 17 02:45 myPipe
echo "hello" > myPipe # 将数据写进管道。程序会阻塞,只有当管道里的数据被读完后,程序才会正常继续。
cat < myPipe # 读取管道里的数据 # hello
管道的优缺点
缺点:管道的通信方式效率低,不适合进程间频繁地交换数据。
优点:简单。
二、消息队列
前面说到管道的通信方式效率很低,因此管道不适合进程间频繁地交换数据。
对于这个问题,消息队列可以解决。比如,A 进程要给 B 进程发送消息,A 进程将数据存入消息队列,B 进程只需要读取数据即可。反之亦如此。
消息队列的本质是保存在内核中的一种消息链表,在发送数据时,会分成独立的数据单元,也就是消息体(数据块)。消息体是用户自定义的数据类型,消息的发送方和接收方必须约定好消息体的数据类型,所以每个消息体都是固定大小的存储块,不像管道是无格式的字节流数据。如果进程从消息队列中读取了消息体,内核就会把这个消息体删除。
消息队列生命周期随内核,如果没有释放消息队列或者没有关闭操作系统,消息队列会一直存在,而前面提到的匿名管道的生命周期,是随进程的创建而建立,随进程的结束而销毁。
消息这种模型,两个进程之间的通信就像平时发邮件一样,你来一封,我回一封,可以频繁沟通。但邮件的通信方式存在不足的地方有两点:一是通信不及时,二是附件也有大小限制,这同样也是消息队列通信不足的点。
消息队列的优缺点
缺点:
- 通信不及时
- 不适合比较大数据的传输,因为在内核中每个消息体都有一个最大长度的限制,同时所有队列所包含的全部消息体的总长度也是有上限。在 Linux 内核中,会有两个宏定义 MSGMAX 和 MSGMNB,它们以字节为单位,分别定义了一条消息的最大长度和一个队列的最大长度。
- 消息队列通信过程中,存在用户态与内核态之间的数据拷贝开销,因为进程写入数据到内核中的消息队列时,会发生从用户态拷贝数据到内核态的过程,同理另一进程读取内核中的消息数据时,会发生从内核态拷贝数据到用户态的过程。
优点:
- 可以频繁地交换数据
- 可以自定义数据类型
三、共享内存
消息队列的读取和写入的过程,都会有发生用户态与内核态之间的消息拷贝过程。而共享内存就很好的解决了这一问题。
现代操作系统,对于内存管理,采用的是虚拟内存技术,也就是每个进程都有自己独立的虚拟内存空间,不同进程的虚拟内存映射到不同的物理内存中。所以,即使进程 A 和 进程 B 的虚拟地址是一样的,其实访问的是不同的物理内存地址,对于数据的增删查改互不影响。
共享内存的机制,就是拿出一块虚拟地址空间来,映射到相同的物理内存中。这样这个进程写入的东西,另外一个进程马上就能看到,大大提高了进程间通信的速度。
四、信号量
用了共享内存通信方式,带来新的问题:如果多个进程同时修改同一个共享内存,很有可能发生冲突。例如两个进程都同时写一个地址,先写的进程会的内容会被覆盖。
为了防止多进程竞争共享资源而造成的数据错乱,需要一种保护机制,使得共享的资源在任意时刻只能被一个进程访问。信号量就实现了这一保护机制。
信号量本质是一个整型的计数器,主要用于实现进程间的互斥与同步,而不是用于缓存进程间通信的数据。
信号量表示资源的数量,控制信号量的方式有两种原子操作:
-
P 操作:将信号量减去 -1,相减后如果信号量 < 0,则表明资源已被占用,进程需阻塞等待;相减后如果信号量 >= 0,则表明还有资源可使用,进程可正常继续执行。
-
V 操作:将信号量加上 1,相加后如果信号量 <= 0,则表明当前有阻塞中的进程,于是将该进程唤醒运行;相加后如果信号量 > 0,则表明当前没有阻塞中的进程;
P 操作是用在进入共享资源之前,V 操作是用在离开共享资源之后,这两个操作是必须成对出现的。
具体过程:
-
进程 A 在访问共享内存前,先执行 P 操作,由于信号量的初始值为 1,故在进程 A 执行 P 操作后信号量变为 0,表示共享资源可用,于是进程 A 就可以访问共享内存。
-
若此时,进程 B 也想访问共享内存,执行了 P 操作,结果信号量变为 -1,意味着临界资源已被占用,因此进程 B 被阻塞。
-
进程 A 访问完共享内存,执行 V 操作,使得信号量恢复为 0,接着就会唤醒阻塞中的线程 B,使得进程 B 可以访问共享内存,最后完成共享内存的访问后,执行 V 操作,使信号量恢复到初始值 1。
信号初始化为 1
,代表着是互斥信号量,它可以保证共享内存在任何时刻只有一个进程在访问,这就很好的保护了共享内存。
另外,在多进程里,每个进程并不一定是顺序执行的,它们基本是以各自独立的、不可预知的速度向前推进,但有时候我们又希望多个进程能密切合作,以实现一个共同的任务。
例如,进程 A 是负责生产数据,而进程 B 是负责读取数据,这两个进程相互合作、相互依赖,进程 A 必须先生产了数据,进程 B 才能读取到数据,所以执行是有前后顺序的。这时候,就可以用信号量来实现多进程同步的方式,我们可以初始化信号量为 0
。
具体过程:
-
如果进程 B 比进程 A 先执行了,那么执行到 P 操作时,由于信号量初始值为 0,故信号量会变为 -1,表示进程 A 还没生产数据,于是进程 B 就阻塞等待;
-
接着,当进程 A 生产完数据后,执行了 V 操作,就会使得信号量变为 0,于是就会唤醒阻塞在 P 操作的进程 B;
-
最后,进程 B 被唤醒后,意味着进程 A 已经生产了数据,于是进程 B 就可以正常读取数据了。
可以发现,信号初始化为 0
,就代表着是同步信号量,它可以保证进程 A 应在进程 B 之前执行。
五、信号
上面说的进程间通信,都是常规状态下的工作模式。对于异常情况下的工作模式,需要用信号的方式来通知进程。
信号跟信号量虽然名字相似,但两者用途完全不一样,就好像 Java 和 JavaScript 的区别。
在 Linux 操作系统中, 为了响应各种各样的事件,提供了几十种信号,分别代表不同的意义。可以通过 kill -l 命令查看所有的信号.
运行在 shell 终端的进程,我们可以通过键盘输入某些组合键的时候,给进程发送信号。例如
-
Ctrl+C 产生 SIGINT 信号,表示终止该进程;
-
Ctrl+Z 产生 SIGINTSIGTSTP 信号,表示停止该进程,但还未结束;
如果进程在后台运行,可以通过 kill 命令的方式给进程发送信号,但前提需要知道运行中的进程 PID 号,例如:
-
kill -9 1050 ,表示给 PID 为 1050 的进程发送
SIGKILL
信号,用来立即结束该进程;
所以,信号事件的来源主要有硬件来源(如键盘 Cltr+C )和软件来源(如 kill 命令)。
信号是进程间通信机制中唯一的异步通信机制,因为可以在任何时候发送信号给某一进程,一旦有信号产生,我们就有下面这几种,用户进程对信号的处理方式。
- 执行默认操作。Linux 对每种信号都规定了默认操作,例如,上面列表中的 SIGTERM 信号,就是终止进程的意思。Core 的意思是 Core Dump,也即终止进程后,通过 Core Dump 将当前进程的运行状态保存在文件里面,方便程序员事后进行分析问题在哪里。
- 捕捉信号。我们可以为信号定义一个信号处理函数。当信号发生时,就执行相应的信号处理函数。
- 忽略信号。当我们不希望处理某些信号的时候,就可以忽略该信号,不做任何处理。有两个信号是应用进程无法捕捉和忽略的,即 SIGKILL 和 SEGSTOP,它们用于在任何时候中断或结束某一进程。
六、socket
网络通信