计算机知识宇宙
  • 介紹
  • 基础知识
    • 计算机系统
      • 进程与线程
        • 进程
        • 线程
        • 调度算法
        • 进程间通信
      • 锁
    • 计算机网络
    • 算法
  • 编程语言
    • Golang
      • 垃圾回收机制
  • 进阶实践
    • 云原生
      • 图解 Kubernetes
        • Deployment Controller 篇
        • Informer 篇(上)
        • QoS 篇
  • 附录
    • 概念
由 GitBook 提供支持
在本页
  • 总结
  • 管道
  • 消息队列
  • 共享内存
  • 信号量
  • 信号
  • socket
  • 线程间通信
  • 管道
  • 管道创建原理
  • 消息队列
  • 共享内存
  • 信号量
  • 信号
  • Scoket
  • TCP 协议 socket 模型
  • UDP 协议 socket 模型
  • 本地 socker 模型
  • 参考

这有帮助吗?

  1. 基础知识
  2. 计算机系统
  3. 进程与线程

进程间通信

上一页调度算法下一页锁

最后更新于4年前

这有帮助吗?

每个进程的用户地址都是独立的,一般是不能互相访问的,但内核空间是所有进程共享的,所以进程之间的通信必须通过内核。

本文主要介绍 Linux 内核提供的通信机制,为了方便查看和复习,各种进程通信机制的总结会放在最前面,方便利用碎片时间复习。

总结

每个进程的用户空间都是独立的,不能互相访问,需要借助内核空间通信,因为每个进程都共享一个内核空间。

管道

Linux 内核提供了不少进程间通信的方式,最简单的是管道,管道分为匿名管道和命名管道。

  • 匿名管道:是只存在于内存的特殊文件;shell 中的 | 就是匿名管道;通信的数据是无格式的流且大小受限,且是单向通信;匿名管道只能在父子关系的进程间通信;匿名管道的生命周期随进程

  • 命名管道:突破了匿名管道只能在有亲缘关系的进程间通信的限制;会在文件系统中创建一个 p 类型的设备文件,毫无关系的进程就可通过这个设备文件通信

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

消息队列

克服了管道通信的数据是无格式字节流的问题;消息队列实际是存在于内核的「消息链表」;用户可以自定义数据类型,需要发送与接收方保持数据类型一致;消息队列的通信不是最快的,每次数据的写入和读取都要经过用户态和内核态之间的拷贝过程。

共享内存

可以解决消息队列用户态和内核态之间数据拷贝带来的开销,直接分配一个共享空间,每个进程都可以直接访问,不需要陷入内核态或者系统调用,速度最快;但是会出现多个进程竞争贡献资源造成数据混乱的情况。

信号量

保护共享内存,确保任何时刻只能由一个进程访问共享资源;信号量不仅可以实现互斥锁,还可以实现进程间的同步;信号量其实是一个计数器,表示的事资源个数,其值可以通过两个院子操作来控制,分别是 P 操作和 V 操作。

信号

信号是进程间通信机制中唯一的异步通信机制,可以在应用进程和内核之间直接交互;内核也可以利用信号来通知用户空间的进程发生了哪些系统事件;信号的来源主要有硬件来源(如键盘 Cltr+C)和软件来源(如 kill 命令)。

进程有三种响应方式:

  1. 执行默认操作

  2. 捕捉信号

  3. 忽略信号

SIGKILL 和 SEGSTOP 两个信号是无法捕捉和忽略的,这是为了方便能在任何时候结束和停止某个进程。

socket

要与不同主机间的进程通信,就需要 socket 通信了,socket 实际上不仅用于不同的主机间进程通信,还可以用于本机进程通信。

根据创建 socket 类型不同,分为三种常见的通信方式:

  • 基于 TCP 协议的通信方式

  • 基于 UDP 协议的通信方式

  • 本地进程间通信方式

线程间通信

同个进程下的现场之间都是共享进程资源的,只要是共享变量都可以做到线程间通信,多以对于线程间关注的不是通信方式,而是关注多线程竞争共享资源,信号量也同样可以在线程间实现互斥与同步:

  • 互斥,可保证任意时刻只有一个线程访问共享资源

  • 同步,可保证线程 A应在线程 B 之前执行

管道

$ ps auxf | grep redis

说道管道,最想想到的就是 Linux 命令中的 | 了,它的功能是将前一个命令(ps auxf)的输出,最为后一个命令(grep redis)的输入,是单向的,如果相互通信需要两个管道才行。

这种方式生成的管道,属于匿名管道,用完之后就会销毁。

还有一种命名管道(FIFO),可以使用 mkfifo 命令来创建:

$ mkfifo myPipe

myPipo 是管道名称,基于 Linux 一切皆文件的理念,管道是以文件方式存在的,可以使用 ls 命令查看:

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

可以看到这个文件的类型为 p,也就是 pipe(管道),同时终端也会以不同的演示来显示这个文件名。

接下来往 muPipe 管道写入数据:

$ echo "hello" > myPipe  // 将数据写进管道
                         // 此时程序会停住,并不会和一般写入文件一样退出

这是因为管道的内容没有被读取,只有当管道中的数据被读取完后,命令才会正常退出,这里打开一个新终端:

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

管道中的数据被读取出来,并打印在新终端上;切回原终端可以发现命令也正常退出了。

可以看出管道这种通信方式效率低,不适合进程间频繁的交换数据。,优点就是简单,方便我们得知管道中的数据是否已被另一个进程读取了。

管道创建原理

创建匿名管道,通过系统调用:

int pipe(int fd[2])

这里创建一个匿名管道,并返回两个描述符:

  • 管道读取端描述符 fd[0]

  • 管道写入端描述符 fd[1]

注意:匿名管道是特殊文件,只存在于内存,不存在文件系统中。

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

父子进程通信

到这,两个描述符还都在一个进程中,并没有起到通信的作用。使用 fork 创建子进程,创建子进程会复制父进程的文件描述符,这样就做到了两个进程各自有两个 fd[0] 和 fd[1],而各自的文件描述符都连在同一个管道上,两个进程可以通过各自的 fd 写入和读取同一个管道文件,实现跨进程通信。

但上面这种模式容易造成混乱,因为父子进程可以同时写入,也都可以读出,为了避免这种情况:

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

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

所以双向通行需要两个管道。

兄弟进程通信

但是在 shell 中使用 A | B 命令的时候,A 进程和 B 进程都是 shell 创建出来的子进程,他们是兄弟进程。

所以在 shell 中通过 | 匿名管道将多个命令连接在一起,实际上也及时创建了多个子进程,那么在编写 shell 脚本时,能使用一个管道搞定的事情,就不要多用一个管道,这样可以坚持创建子进程的系统开销。

对于匿名管道,他的通信范围是存在父子关系的进程。因为管道没有实体,也没有管道文件,只能通过 fork 来复制父进程 fd 文件描述符,来达到通信的目的。

对于命名管道,它可以在不相关的进程见互相通信。因为命令管道创建了一个类型为管道的设备文件,在进程里只要使用这个设备文件,就可以相互通信。

小结:不管是匿名管道还是命名管道,进程写入的数据都是缓存在内核中,另一个进程读取数据时自然也是从内核中读取,同时通信数据都遵循先进先出原则,不支持 lseek 之类的文件定位操作。

消息队列

由于管道的通信方式效率低,因此不适合进程间频繁地交换数据。

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

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

但消息队列也存在问题,通信不及时和数据大小有限制。因此,消息队列不适合比较大数据的传输,因为内核中每个消息体都有一个最大长度的限制,同时所有队列的总长度也是有上限的。在 Linux 中有两个宏定义 MSGMAX 和 MSGMNB,他们以字节为单位,分别定义了一条消息的最大长度和一个队列的最大长度。

消息队列通信过程中,存在用户态与内核态之间的数据拷贝开销,写入和读取都会发生用户态和内核态的数据拷贝。

共享内存

为了避免用户态与内核态之间消息拷贝的系统开销,共享内存就是一种解决办法。

现代操作系统,对于内存管理,采用的事虚拟内存技术,每个进程都有自己独立的虚拟内存,不同的进程的虚拟内存映射到不同的物流内存中。所以,即使进程 A 和进程 B的虚拟地址是一样的,其实访问的是不同的物流地址,对于数据的增删改互不影响。

共享内存的机制,就是拿出一块虚拟地址空间,映射到相同的物流内存中。这样一个进程写入的东西,另一个进程马上就能看到,无需来回拷贝,大大提升了通信的速度。

信号量

为了防止多进程竞争共享资源,信号量这一保护机制保障在任何时刻共享资源只能被一个进程访问。

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

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

  • P,这个操作会把信号量 -1,相减后如果 < 0,则表示资源已被占用,进程需要阻塞等待;如果 >= 0,则表明资源可以使用,进程正常执行

  • V,这个操作会把信号量 +1,相加后如果 <= 0,则表明当前有阻塞中的进程,于是会将该进程唤醒运行;如果 >0,则表明当前没有阻塞的进程

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

信号初始化为 1,就代表着互斥信号量,它可以保证共享内存在任何时刻只有一个进程在访问,这就很好的保护了共享内存。

信号初始位为 0,就代表着同步信号量,可以保证进程 A 在进程 B 之前执行。

信号

对于异常情况下的工作模式,需要使用「信号」的方式来通知进程。

在 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 信号,表示停止该进程,但未结束

信号是进程通信机制中唯一的异步通信机制,因为可以在任何时候发送信号给某一进程,一旦有信号产生,会有如下几种处理方式:

  1. 执行默认操作,Linux 对每种信号都有默认操作。

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

  3. 忽略信号,当我们不希望处理某些新哈吉时,就可以忽略该信号,不做任何处理。

但有两个信号应用程序是无法捕捉和忽略的,即 SIGKILL 和 SEGSTOP,它们用于在任何时候中断和结束某一进程。

Scoket

上面说到的这些方式,只能在同一台主机上进行进程间通信,如果想要跨网络与不同主机上的进程通信,就需要 Socket 通信了。Socket 不仅可以跨网络与不同主机的进程间通信,还可以在同主机上进程间通信。

创建 Socket 的系统调用:

int socket(int domain, int type, int protocal)
  • domain 参数用来指定协议族,比如: AF_INET 用于 IPV4;AF_INET6 用于 IPV6;AF_LOCAL/AF_UNIX 用于本机

  • type 参数用来指定通信特性,比如 SOCK_STREAM 表示的事字节流,对于 TCP;SOCK_DGRAM 表示的事数据报,对应 UDP;SOCK_RAW 表示是原始套接字

  • protocal 参数原本是用来指定通信协议的,先现在基本废弃,一般置 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_LOACL 是等价的,所以 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。

成功建立连接后,双方通过 read 和 write 函数来读写数据,就像往一个文件流里写东西一样。

UDP 协议 socket 模型

UDP 是没有连接的,不需要三次握手,也不会像 TCP 调用 listen 和 connect,但 UDP 仍需要 IP 地址和端口号,因此也需要 bind。

对于 UDP,不需要维护连接,也就没有发送方和接收方,甚至都不存在客户端和服务度的概念,只需有一个 socket 多个机器就可以任意通信,因此每一个 UDP 的 socket 都需要 bind。

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

本地 socker 模型

本地 socker 被用于同一台主机上进程间通信的场景:

  • 本地 socket 的编程接口和 IPV4、IPV6 套接字编程接口是一致的,都可以支持「字节流」和「数据报」两种协议

  • 本地 socket 的实现效率大大高于 IPV4 和 IPV6 的字节流、数据报 socket 实现

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

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

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

参考

凉了!张三同学没答好「进程间通信」,被面试官挂了.... - 小林coding