进程间有哪些通信方式?
直接开讲每个进程的用户地址空间都是独立的一般而言是不能互相访问的但内核空间是每个进程都共享的所以进程之间要通信必须通过内核。Linux 内核提供了不少进程间通信的机制我们来一起瞧瞧有哪些#管道如果你学过 Linux 命令那你肯定很熟悉「|」这个竖线。$ ps auxf | grep mysql上面命令行里的「|」竖线就是一个管道它的功能是将前一个命令ps auxf的输出作为后一个命令grep mysql的输入从这功能描述可以看出管道传输数据是单向的如果想相互通信我们需要创建两个管道才行。同时我们得知上面这种管道是没有名字所以「|」表示的管道称为匿名管道用完了就销毁。管道还有另外一个类型是命名管道也被叫做FIFO因为数据是先进先出的传输方式。在使用命名管道前先需要通过mkfifo命令来创建并且指定管道名字$ mkfifo myPipemyPipe 就是这个管道的名称基于 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 内核中会有两个宏定义MSGMAX和MSGMNB它们以字节为单位分别定义了一条消息的最大长度和一个队列的最大长度。消息队列通信过程中存在用户态与内核态之间的数据拷贝开销因为进程写入数据到内核中的消息队列时会发生从用户态拷贝数据到内核态的过程同理另一进程读取内核中的消息数据时会发生从内核态拷贝数据到用户态的过程。#共享内存消息队列的读取和写入的过程都会有发生用户态与内核态之间的消息拷贝过程。那共享内存的方式就很好的解决了这一问题。现代操作系统对于内存管理采用的是虚拟内存技术也就是每个进程都有自己独立的虚拟内存空间不同进程的虚拟内存映射到不同的物理内存中。所以即使进程 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) SIGRTMIN1 36) SIGRTMIN2 37) SIGRTMIN3 38) SIGRTMIN4 39) SIGRTMIN5 40) SIGRTMIN6 41) SIGRTMIN7 42) SIGRTMIN8 43) SIGRTMIN9 44) SIGRTMIN10 45) SIGRTMIN11 46) SIGRTMIN12 47) SIGRTMIN13 48) SIGRTMIN14 49) SIGRTMIN15 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 终端的进程我们可以通过键盘输入某些组合键的时候给进程发送信号。例如CtrlC 产生SIGINT信号表示终止该进程CtrlZ 产生SIGTSTP信号表示停止该进程但还未结束如果进程在后台运行可以通过kill命令的方式给进程发送信号但前提需要知道运行中的进程 PID 号例如kill -9 1050 表示给 PID 为 1050 的进程发送SIGKILL信号用来立即结束该进程所以信号事件的来源主要有硬件来源如键盘 CltrC 和软件来源如 kill 命令。信号是异步通信机制因为可以在任何时候发送信号给某一进程一旦有信号产生我们就有下面这几种用户进程对信号的处理方式。1.执行默认操作。Linux 对每种信号都规定了默认操作例如上面列表中的 SIGTERM 信号就是终止进程的意思。2.捕捉信号。我们可以为信号定义一个信号处理函数。当信号发生时我们就执行相应的信号处理函数。3.忽略信号。当我们不希望处理某些信号的时候就可以忽略该信号不做任何处理。有两个信号是应用进程无法捕捉和忽略的即SIGKILL和SEGSTOP它们用于在任何时候中断或结束某一进程。#Socket前面提到的管道、消息队列、共享内存、信号量和信号都是在同一台主机上进行进程间通信那要想跨网络与不同主机上的进程之间通信就需要 Socket 通信了。实际上Socket 通信不仅可以跨网络与不同主机的进程间通信还可以在同主机上进程间通信。我们来看看创建 socket 的系统调用int socket(int domain, int type, int protocol)三个参数分别代表domain 参数用来指定协议族比如 AF_INET 用于 IPV4、AF_INET6 用于 IPV6、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 操作。与信号量名字很相似的叫信号它俩名字虽然相似但功能一点儿都不一样。信号是异步通信机制信号可以在应用进程和内核之间直接交互内核也可以利用信号来通知用户空间的进程发生了哪些系统事件信号事件的来源主要有硬件来源如键盘 CltrC 和软件来源如 kill 命令一旦有信号发生进程有三种方式响应信号 1. 执行默认操作、2. 捕捉信号、3. 忽略信号。有两个信号是应用进程无法捕捉和忽略的即SIGKILL和SIGSTOP这是为了方便我们能在任何时候结束或停止某个进程。前面说到的通信机制都是工作于同一台主机如果要与不同主机的进程间通信那么就需要 Socket 通信了。Socket 实际上不仅用于不同的主机进程间通信还可以用于本地主机进程间通信可根据创建 Socket 的类型不同分为三种常见的通信方式一个是基于 TCP 协议的通信方式一个是基于 UDP 协议的通信方式一个是本地进程间通信方式。以上就是进程间通信的主要机制了。你可能会问了那线程通信间的方式呢同个进程下的线程之间都是共享进程的资源只要是共享变量都可以做到线程间通信比如全局变量所以对于线程间关注的不是通信方式而是关注多线程竞争共享资源的问题信号量也同样可以在线程间实现互斥与同步互斥的方式可保证任意时刻只有一个线程访问共享资源同步的方式可保证线程 A 应在线程 B 之前执行#