凉了!张三同窗没答好「进程间通讯」,被面试官挂了....


前言

开场小故事mysql

炎炎夏日,张三骑着单车去面试花了 1 小时,一路上汗流浃背。程序员

结果面试过程只花了 5 分钟就结束了,面完的时候,天仍是依然是亮的,还得在烈日下奔波 1 小时回去。web

面试五分钟,骑车两小时。面试

你看,张三因面试没准备好,吹空调的时间只有 5 分钟,来回路上花了 2 小时晒太阳,你说惨不惨?sql

因此啊,炎炎夏日,为了能延长吹空调的时间,咱们应该在面试前准备得更充分些,吹空调时间是要本身争取的。shell

很明显,在这一场面试中, 张三在进程间通讯这一块没复习好,虽然列出了进程间通讯的方式,但这只是表面功夫,应该须要进一步了解每种通讯方式的优缺点及应用场景。编程

说真的,咱们此次一块儿帮张三一块儿复习下,加深他对进程间通讯的理解,好让他下次吹空调的时间能长一点。缓存


正文

每一个进程的用户地址空间都是独立的,通常而言是不能互相访问的,但内核空间是每一个进程都共享的,因此进程之间要通讯必须经过内核。bash

Linux 内核提供了很多进程间通讯的机制,咱们来一块儿瞧瞧有哪些?服务器

管道

若是你学过 Linux 命令,那你确定很熟悉「|」这个竖线。

$ ps auxf | grep mysql

上面命令行里的「|」竖线就是一个管道,它的功能是将前一个命令(ps auxf)的输出,做为后一个命令(grep mysql)的输入,从这功能描述,能够看出管道传输数据是单向的,若是想相互通讯,咱们须要建立两个管道才行。

同时,咱们得知上面这种管道是没有名字,因此「|」表示的管道称为匿名管道,用完了就销毁。

管道还有另一个类型是命名管道,也被叫作 FIFO,由于数据是先进先出的传输方式。

在使用命名管道前,先须要经过 mkfifo 命令来建立,而且指定管道名字:

$ mkfifo myPipe

myPipe 就是这个管道的名称,基于 Linux 一切皆文件的理念,因此管道也是以文件的方式存在,咱们能够用 ls 看一下,这个文件的类型是 p,也就是 pipe(管道) 的意思:

$ ls -l
prw-r--r--. 1 root    root         0 Jul 17 02:45 myPipe

接下来,咱们往 myPipe 这个管道写入数据:

$ echo "hello" > myPipe  // 将数据写进管道
                         // 停住了 ...

你操做了后,你会发现命令执行后就停在这了,这是由于管道里的内容没有被读取,只有当管道里的数据被读完后,命令才能够正常退出。

因而,咱们执行另一个命令来读取这个管道里的数据:

$ cat < myPipe  // 读取管道里的数据
hello

能够看到,管道里的内容被读取出来了,并打印在了终端上,另一方面,echo 那个命令也正常退出了。

咱们能够看出,管道这种通讯方式效率低,不适合进程间频繁地交换数据。固然,它的好处,天然就是简单,同时也咱们很容易得知管道里的数据已经被另外一个进程读取了。

那管道如何建立呢,背后原理是什么?

匿名管道的建立,须要经过下面这个系统调用:

int pipe(int fd[2])

这里表示建立一个匿名管道,并返回了两个描述符,一个是管道的读取端描述符 fd[0],另外一个是管道的写入端描述符 fd[1]。注意,这个匿名管道是特殊的文件,只存在于内存,不存于文件系统中。

其实,所谓的管道,就是内核里面的一串缓存。从管道的一段写入的数据,其实是缓存在内核中的,另外一端读取,也就是从内核中读取这段数据。另外,管道传输的数据是无格式的流且大小受限。

看到这,你可能会有疑问了,这两个描述符都是在一个进程里面,并无起到进程间通讯的做用,怎么样才能使得管道是跨过两个进程的呢?

咱们可使用 fork 建立子进程,建立的子进程会复制父进程的文件描述符,这样就作到了两个进程各有两个「 fd[0]fd[1]」,两个进程就能够经过各自的 fd 写入和读取同一个管道文件实现跨进程通讯了。

管道只能一端写入,另外一端读出,因此上面这种模式容易形成混乱,由于父进程和子进程均可以同时写入,也均可以读出。那么,为了不这种状况,一般的作法是:

  • 父进程关闭读取的 fd[0],只保留写入的 fd[1];
  • 子进程关闭写入的 fd[1],只保留读取的 fd[0];

因此说若是须要双向通讯,则应该建立两个管道。

到这里,咱们仅仅解析了使用管道进行父进程与子进程之间的通讯,可是在咱们 shell 里面并非这样的。

在 shell 里面执行 A | B命令的时候,A 进程和 B 进程都是 shell 建立出来的子进程,A 和 B 之间不存在父子关系,它俩的父进程都是 shell。

因此说,在 shell 里经过「|」匿名管道将多个命令链接在一块儿,实际上也就是建立了多个子进程,那么在咱们编写 shell 脚本时,能使用一个管道搞定的事情,就不要多用一个管道,这样能够减小建立子进程的系统开销。

咱们能够得知,对于匿名管道,它的通讯范围是存在父子关系的进程。由于管道没有实体,也就是没有管道文件,只能经过 fork 来复制父进程 fd 文件描述符,来达到通讯的目的。

另外,对于命名管道,它能够在不相关的进程间也能相互通讯。由于命令管道,提早建立了一个类型为管道的设备文件,在进程里只要使用这个设备文件,就能够相互通讯。

无论是匿名管道仍是命名管道,进程写入的数据都是缓存在内核中,另外一个进程读取数据时候天然也是从内核中获取,同时通讯数据都遵循先进先出原则,不支持 lseek 之类的文件定位操做。


消息队列

前面说到管道的通讯方式是效率低的,所以管道不适合进程间频繁地交换数据。

对于这个问题,消息队列的通讯模式就能够解决。好比,A 进程要给 B 进程发送消息,A 进程把数据放在对应的消息队列后就能够正常返回了,B 进程须要的时候再去读取数据就能够了。同理,B 进程要给 A 进程发送消息也是如此。

再来,消息队列是保存在内核中的消息链表,在发送数据时,会分红一个一个独立的数据单元,也就是消息体(数据块),消息体是用户自定义的数据类型,消息的发送方和接收方要约定好消息体的数据类型,因此每一个消息体都是固定大小的存储块,不像管道是无格式的字节流数据。若是进程从消息队列中读取了消息体,内核就会把这个消息体删除。

消息队列生命周期随内核,若是没有释放消息队列或者没有关闭操做系统,消息队列会一直存在,而前面提到的匿名管道的生命周期,是随进程的建立而创建,随进程的结束而销毁。

消息这种模型,两个进程之间的通讯就像平时发邮件同样,你来一封,我回一封,能够频繁沟通了。

但邮件的通讯方式存在不足的地方有两点,一是通讯不及时,二是附件也有大小限制,这一样也是消息队列通讯不足的点。

消息队列不适合比较大数据的传输,由于在内核中每一个消息体都有一个最大长度的限制,同时全部队列所包含的所有消息体的总长度也是有上限。在 Linux 内核中,会有两个宏定义 MSGMAXMSGMNB,它们以字节为单位,分别定义了一条消息的最大长度和一个队列的最大长度。

消息队列通讯过程当中,存在用户态与内核态之间的数据拷贝开销,由于进程写入数据到内核中的消息队列时,会发生从用户态拷贝数据到内核态的过程,同理另外一进程读取内核中的消息数据时,会发生从内核态拷贝数据到用户态的过程。


共享内存

消息队列的读取和写入的过程,都会有发生用户态与内核态之间的消息拷贝过程。那共享内存的方式,就很好的解决了这一问题。

现代操做系统,对于内存管理,采用的是虚拟内存技术,也就是每一个进程都有本身独立的虚拟内存空间,不一样进程的虚拟内存映射到不一样的物理内存中。因此,即便进程 A 和 进程 B 的虚拟地址是同样的,其实访问的是不一样的物理内存地址,对于数据的增删查改互不影响。

共享内存的机制,就是拿出一块虚拟地址空间来,映射到相同的物理内存中。这样这个进程写入的东西,另一个进程立刻就能看到了,都不须要拷贝来拷贝去,传来传去,大大提升了进程间通讯的速度。


信号量

用了共享内存通讯方式,带来新的问题,那就是若是多个进程同时修改同一个共享内存,颇有可能就冲突了。例如两个进程都同时写一个地址,那先写的那个进程会发现内容被别人覆盖了。

为了防止多进程竞争共享资源,而形成的数据错乱,因此须要保护机制,使得共享的资源,在任意时刻只能被一个进程访问。正好,信号量就实现了这一保护机制。

信号量实际上是一个整型的计数器,主要用于实现进程间的互斥与同步,而不是用于缓存进程间通讯的数据

信号量表示资源的数量,控制信号量的方式有两种原子操做:

  • 一个是 P 操做,这个操做会把信号量减去 -1,相减后若是信号量 < 0,则代表资源已被占用,进程需阻塞等待;相减后若是信号量 >= 0,则代表还有资源可以使用,进程可正常继续执行。
  • 另外一个是 V 操做,这个操做会把信号量加上 1,相加后若是信号量 <= 0,则代表当前有阻塞中的进程,因而会将该进程唤醒运行;相加后若是信号量 > 0,则代表当前没有阻塞中的进程;

P 操做是用在进入共享资源以前,V 操做是用在离开共享资源以后,这两个操做是必须成对出现的。

接下来,举个例子,若是要使得两个进程互斥访问共享内存,咱们能够初始化信号量为 1

具体的过程以下:

  • 进程 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 以前执行。


信号

上面说的进程间通讯,都是常规状态下的工做模式。对于异常状况下的工做模式,就须要用「信号」的方式来通知进程。

信号跟信号量虽然名字类似度 66.66%,但二者用途彻底不同,就好像 Java 和 JavaScript 的区别。

在 Linux 操做系统中, 为了响应各类各样的事件,提供了几十种信号,分别表明不一样的意义。咱们能够经过 kill -l 命令,查看全部的信号:

$ kill -l
 1) SIGHUP       2) SIGINT       3) SIGQUIT      4) SIGILL       5) SIGTRAP
 6) SIGABRT      7) SIGBUS       8) SIGFPE       9) SIGKILL     10) SIGUSR1
11) SIGSEGV     12) SIGUSR2     13) SIGPIPE     14) SIGALRM     15) SIGTERM
16) SIGSTKFLT   17) SIGCHLD     18) SIGCONT     19) SIGSTOP     20) SIGTSTP
21) SIGTTIN     22) SIGTTOU     23) SIGURG      24) SIGXCPU     25) SIGXFSZ
26) SIGVTALRM   27) SIGPROF     28) SIGWINCH    29) SIGIO       30) SIGPWR
31) SIGSYS      34) SIGRTMIN    35) SIGRTMIN+1  36) SIGRTMIN+2  37) SIGRTMIN+3
38) SIGRTMIN+4  39) SIGRTMIN+5  40) SIGRTMIN+6  41) SIGRTMIN+7  42) SIGRTMIN+8
43) SIGRTMIN+9  44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9  56) SIGRTMAX-8  57) SIGRTMAX-7
58) SIGRTMAX-6  59) SIGRTMAX-5  60) SIGRTMAX-4  61) SIGRTMAX-3  62) SIGRTMAX-2
63) SIGRTMAX-1  64) SIGRTMAX

运行在 shell 终端的进程,咱们能够经过键盘输入某些组合键的时候,给进程发送信号。例如

  • Ctrl+C 产生 SIGINT 信号,表示终止该进程;
  • Ctrl+Z 产生 SIGTSTP 信号,表示中止该进程,但还未结束;

若是进程在后台运行,能够经过 kill 命令的方式给进程发送信号,但前提须要知道运行中的进程 PID 号,例如:

  • kill -9 1050 ,表示给 PID 为 1050 的进程发送 SIGKILL 信号,用来当即结束该进程;

因此,信号事件的来源主要有硬件来源(如键盘 Cltr+C )和软件来源(如 kill 命令)。

信号是进程间通讯机制中惟一的异步通讯机制,由于能够在任什么时候候发送信号给某一进程,一旦有信号产生,咱们就有下面这几种,用户进程对信号的处理方式。

1.执行默认操做。Linux 对每种信号都规定了默认操做,例如,上面列表中的 SIGTERM 信号,就是终止进程的意思。Core 的意思是 Core Dump,也即终止进程后,经过 Core Dump 将当前进程的运行状态保存在文件里面,方便程序员过后进行分析问题在哪里。

2.捕捉信号。咱们能够为信号定义一个信号处理函数。当信号发生时,咱们就执行相应的信号处理函数。

3.忽略信号。当咱们不但愿处理某些信号的时候,就能够忽略该信号,不作任何处理。有两个信号是应用进程没法捕捉和忽略的,即 SIGKILLSEGSTOP,它们用于在任什么时候候中断或结束某一进程。


Socket

前面提到的管道、消息队列、共享内存、信号量和信号都是在同一台主机上进行进程间通讯,那要想跨网络与不一样主机上的进程之间通讯,就须要 Socket 通讯了。

实际上,Socket 通讯不只能够跨网络与不一样主机的进程间通讯,还能够在同主机上进程间通讯。

咱们来看看建立 socket 的系统调用:

int socket(int domain, int type, int protocal)

三个参数分别表明:

  • domain 参数用来指定协议族,好比 AF_INET 用于 IPV四、AF_INET6 用于 IPV六、AF_LOCAL/AF_UNIX 用于本机;
  • type 参数用来指定通讯特性,好比 SOCK_STREAM 表示的是字节流,对应 TCP、SOCK_DGRAM 表示的是数据报,对应 UDP、SOCK_RAW 表示的是原始套接字;
  • protocal 参数本来是用来指定通讯协议的,但如今基本废弃。由于协议已经经过前面两个参数指定完成,protocol 目前通常写成 0 便可;

根据建立 socket 类型的不一样,通讯的方式也就不一样:

  • 实现 TCP 字节流通讯: socket 类型是 AF_INET 和 SOCK_STREAM;
  • 实现 UDP 数据报通讯:socket 类型是 AF_INET 和 SOCK_DGRAM;
  • 实现本地进程间通讯: 「本地字节流 socket 」类型是 AF_LOCAL 和 SOCK_STREAM,「本地数据报 socket 」类型是 AF_LOCAL 和 SOCK_DGRAM。另外,AF_UNIX 和 AF_LOCAL 是等价的,因此 AF_UNIX 也属于本地 socket;

接下来,简单说一下这三种通讯的编程模式。

针对 TCP 协议通讯的 socket 编程模型

  • 服务端和客户端初始化 socket,获得文件描述符;
  • 服务端调用 bind,将绑定在 IP 地址和端口;
  • 服务端调用 listen,进行监听;
  • 服务端调用 accept,等待客户端链接;
  • 客户端调用 connect,向服务器端的地址和端口发起链接请求;
  • 服务端 accept 返回用于传输的 socket 的文件描述符;
  • 客户端调用 write 写入数据;服务端调用 read 读取数据;
  • 客户端断开链接时,会调用 close,那么服务端 read 读取数据的时候,就会读取到了 EOF,待处理完数据后,服务端调用 close,表示链接关闭。

这里须要注意的是,服务端调用 accept 时,链接成功了会返回一个已完成链接的 socket,后续用来传输数据。

因此,监听的 socket 和真正用来传送数据的 socket,是「两个」 socket,一个叫做监听 socket,一个叫做已完成链接 socket

成功链接创建以后,双方开始经过 read 和 write 函数来读写数据,就像往一个文件流里面写东西同样。

针对 UDP 协议通讯的 socket 编程模型

UDP 是没有链接的,因此不须要三次握手,也就不须要像 TCP 调用 listen 和 connect,可是 UDP 的交互仍然须要 IP 地址和端口号,所以也须要 bind。

对于 UDP 来讲,不须要要维护链接,那么也就没有所谓的发送方和接收方,甚至都不存在客户端和服务端的概念,只要有一个 socket 多台机器就能够任意通讯,所以每个 UDP 的 socket 都须要 bind。

另外,每次通讯时,调用 sendto 和 recvfrom,都要传入目标主机的 IP 地址和端口。

针对本地进程间通讯的 socket 编程模型

本地 socket 被用于在同一台主机上进程间通讯的场景:

  • 本地 socket 的编程接口和 IPv4 、IPv6 套接字编程接口是一致的,能够支持「字节流」和「数据报」两种协议;
  • 本地 socket 的实现效率大大高于 IPv4 和 IPv6 的字节流、数据报 socket 实现;

对于本地字节流 socket,其 socket 类型是 AF_LOCAL 和 SOCK_STREAM。

对于本地数据报 socket,其 socket 类型是 AF_LOCAL 和 SOCK_DGRAM。

本地字节流 socket 和 本地数据报 socket 在 bind 的时候,不像 TCP 和 UDP 要绑定 IP 地址和端口,而是绑定一个本地文件,这也就是它们之间的最大区别。


总结

因为每一个进程的用户空间都是独立的,不能相互访问,这时就须要借助内核空间来实现进程间通讯,缘由很简单,每一个进程都是共享一个内核空间。

Linux 内核提供了很多进程间通讯的方式,其中最简单的方式就是管道,管道分为「匿名管道」和「命名管道」。

匿名管道顾名思义,它没有名字标识,匿名管道是特殊文件只存在于内存,没有存在于文件系统中,shell 命令中的「|」竖线就是匿名管道,通讯的数据是无格式的流而且大小受限,通讯的方式是单向的,数据只能在一个方向上流动,若是要双向通讯,须要建立两个管道,再来匿名管道是只能用于存在父子关系的进程间通讯,匿名管道的生命周期随着进程建立而创建,随着进程终止而消失。

命名管道突破了匿名管道只能在亲缘关系进程间的通讯限制,由于使用命名管道的前提,须要在文件系统建立一个类型为 p 的设备文件,那么毫无关系的进程就能够经过这个设备文件进行通讯。另外,无论是匿名管道仍是命名管道,进程写入的数据都是缓存在内核中,另外一个进程读取数据时候天然也是从内核中获取,同时通讯数据都遵循先进先出原则,不支持 lseek 之类的文件定位操做。

消息队列克服了管道通讯的数据是无格式的字节流的问题,消息队列其实是保存在内核的「消息链表」,消息队列的消息体是能够用户自定义的数据类型,发送数据时,会被分红一个一个独立的消息体,固然接收数据时,也要与发送方发送的消息体的数据类型保持一致,这样才能保证读取的数据是正确的。消息队列通讯的速度不是最及时的,毕竟每次数据的写入和读取都须要通过用户态与内核态之间的拷贝过程。

共享内存能够解决消息队列通讯中用户态与内核态之间数据拷贝过程带来的开销,它直接分配一个共享空间,每一个进程均可以直接访问,就像访问进程本身的空间同样快捷方便,不须要陷入内核态或者系统调用,大大提升了通讯的速度,享有最快的进程间通讯方式之名。可是便捷高效的共享内存通讯,带来新的问题,多进程竞争同个共享资源会形成数据的错乱。

那么,就须要信号量来保护共享资源,以确保任什么时候刻只能有一个进程访问共享资源,这种方式就是互斥访问。信号量不只能够实现访问的互斥性,还能够实现进程间的同步,信号量实际上是一个计数器,表示的是资源个数,其值能够经过两个原子操做来控制,分别是 P 操做和 V 操做

与信号量名字很类似的叫信号,它俩名字虽然类似,但功能一点儿都不同。信号是进程间通讯机制中惟一的异步通讯机制,信号能够在应用进程和内核之间直接交互,内核也能够利用信号来通知用户空间的进程发生了哪些系统事件,信号事件的来源主要有硬件来源(如键盘 Cltr+C )和软件来源(如 kill 命令),一旦有信号发生,进程有三种方式响应信号 1. 执行默认操做、2. 捕捉信号、3. 忽略信号。有两个信号是应用进程没法捕捉和忽略的,即 SIGKILLSEGSTOP,这是为了方便咱们能在任什么时候候结束或中止某个进程。

前面说到的通讯机制,都是工做于同一台主机,若是要与不一样主机的进程间通讯,那么就须要 Socket 通讯了。Socket 实际上不只用于不一样的主机进程间通讯,还能够用于本地主机进程间通讯,可根据建立 Socket 的类型不一样,分为三种常见的通讯方式,一个是基于 TCP 协议的通讯方式,一个是基于 UDP 协议的通讯方式,一个是本地进程间通讯方式。

以上,就是进程间通讯的主要机制了。你可能会问了,那线程通讯间的方式呢?

同个进程下的线程之间都是共享进程的资源,只要是共享变量均可以作到线程间通讯,好比全局变量,因此对于线程间关注的不是通讯方式,而是关注多线程竞争共享资源的问题,信号量也一样能够在线程间实现互斥与同步:

  • 互斥的方式,可保证任意时刻只有一个线程访问共享资源;
  • 同步的方式,可保证线程 A 应在线程 B 以前执行;

好了,今日帮张三同窗复习就到这了,但愿张三同窗早日收到心意的 offer,给夏天划上充满汗水的句号。


好文推荐

「进程和线程」基础知识全家桶,30 张图一套带走

20 张图揭开「内存管理」的迷雾,瞬间豁然开朗

30 张图带你走进操做系统的「互斥与同步」