进程间通信
最后更新于
这有帮助吗?
每个进程的用户地址都是独立的,一般是不能互相访问的,但内核空间是所有进程共享的,所以进程之间的通信必须通过内核。
本文主要介绍 Linux 内核提供的通信机制,为了方便查看和复习,各种进程通信机制的总结会放在最前面,方便利用碎片时间复习。
每个进程的用户空间都是独立的,不能互相访问,需要借助内核空间通信,因为每个进程都共享一个内核空间。
Linux 内核提供了不少进程间通信的方式,最简单的是管道,管道分为匿名管道和命名管道。
匿名管道:是只存在于内存的特殊文件;shell 中的 |
就是匿名管道;通信的数据是无格式的流且大小受限,且是单向通信;匿名管道只能在父子关系的进程间通信;匿名管道的生命周期随进程
命名管道:突破了匿名管道只能在有亲缘关系的进程间通信的限制;会在文件系统中创建一个 p 类型的设备文件,毫无关系的进程就可通过这个设备文件通信
无论匿名还是命名管道,进程写入的数据都是缓存在内核的,另一个进程也是从内核中读取,同时通信遵循先进先出原则,不支持 lseek 之类的文件定位操作。
克服了管道通信的数据是无格式字节流的问题;消息队列实际是存在于内核的「消息链表」;用户可以自定义数据类型,需要发送与接收方保持数据类型一致;消息队列的通信不是最快的,每次数据的写入和读取都要经过用户态和内核态之间的拷贝过程。
可以解决消息队列用户态和内核态之间数据拷贝带来的开销,直接分配一个共享空间,每个进程都可以直接访问,不需要陷入内核态或者系统调用,速度最快;但是会出现多个进程竞争贡献资源造成数据混乱的情况。
保护共享内存,确保任何时刻只能由一个进程访问共享资源;信号量不仅可以实现互斥锁,还可以实现进程间的同步;信号量其实是一个计数器,表示的事资源个数,其值可以通过两个院子操作来控制,分别是 P 操作和 V 操作。
信号是进程间通信机制中唯一的异步通信机制,可以在应用进程和内核之间直接交互;内核也可以利用信号来通知用户空间的进程发生了哪些系统事件;信号的来源主要有硬件来源(如键盘 Cltr+C)和软件来源(如 kill
命令)。
进程有三种响应方式:
执行默认操作
捕捉信号
忽略信号
SIGKILL
和 SEGSTOP
两个信号是无法捕捉和忽略的,这是为了方便能在任何时候结束和停止某个进程。
要与不同主机间的进程通信,就需要 socket 通信了,socket 实际上不仅用于不同的主机间进程通信,还可以用于本机进程通信。
根据创建 socket 类型不同,分为三种常见的通信方式:
基于 TCP 协议的通信方式
基于 UDP 协议的通信方式
本地进程间通信方式
同个进程下的现场之间都是共享进程资源的,只要是共享变量都可以做到线程间通信,多以对于线程间关注的不是通信方式,而是关注多线程竞争共享资源,信号量也同样可以在线程间实现互斥与同步:
互斥,可保证任意时刻只有一个线程访问共享资源
同步,可保证线程 A应在线程 B 之前执行
说道管道,最想想到的就是 Linux 命令中的 |
了,它的功能是将前一个命令(ps auxf
)的输出,最为后一个命令(grep redis
)的输入,是单向的,如果相互通信需要两个管道才行。
这种方式生成的管道,属于匿名管道,用完之后就会销毁。
还有一种命名管道(FIFO),可以使用 mkfifo
命令来创建:
myPipo
是管道名称,基于 Linux 一切皆文件的理念,管道是以文件方式存在的,可以使用 ls
命令查看:
可以看到这个文件的类型为 p,也就是 pipe(管道),同时终端也会以不同的演示来显示这个文件名。
接下来往 muPipe
管道写入数据:
这是因为管道的内容没有被读取,只有当管道中的数据被读取完后,命令才会正常退出,这里打开一个新终端:
管道中的数据被读取出来,并打印在新终端上;切回原终端可以发现命令也正常退出了。
可以看出管道这种通信方式效率低,不适合进程间频繁的交换数据。,优点就是简单,方便我们得知管道中的数据是否已被另一个进程读取了。
创建匿名管道,通过系统调用:
这里创建一个匿名管道,并返回两个描述符:
管道读取端描述符 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
命令,查看所有信号:
运行在 shell 终端的进程,可以通过键盘发送信号,例如:
Ctrl+C 产生 SIGINT
信号,表示终止该进程
Ctrl+Z 产生 SIGTSTP
信号,表示停止该进程,但未结束
信号是进程通信机制中唯一的异步通信机制,因为可以在任何时候发送信号给某一进程,一旦有信号产生,会有如下几种处理方式:
执行默认操作,Linux 对每种信号都有默认操作。
捕捉信号,可以为信号定义一个信号处理函数,当信号发生时,我们就执行相应的信号处理函数。
忽略信号,当我们不希望处理某些新哈吉时,就可以忽略该信号,不做任何处理。
但有两个信号应用程序是无法捕捉和忽略的,即 SIGKILL
和 SEGSTOP
,它们用于在任何时候中断和结束某一进程。
上面说到的这些方式,只能在同一台主机上进行进程间通信,如果想要跨网络与不同主机上的进程通信,就需要 Socket 通信了。Socket 不仅可以跨网络与不同主机的进程间通信,还可以在同主机上进程间通信。
创建 Socket 的系统调用:
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
服务端和客户端初始化 socket
,得到文件描述符
服务端调用 bind
绑定 IP 地址和端口
服务调用 listen
进行监听
服务端调用 accept
等待客户端连接
客户端调用 connect
想服务端的地址和端口发起连接请求
服务端 accept
返回用于传输 socket
的文件描述符
客户端调用 write
写入数据;服务端调用 read
读取数据
客户端断开连接时,会调用 close
,那么服务端在 read
读取数据时,会读取到 EOF
,待处理完数据后,服务端调用 close
关闭连接
注意:服务端调用 accept
时,连接成功了会返回一个已完成连接的 socket 用来传输数据。所以监听的 socket 和真正传输数据的 socket 是两个,一个叫做监听 socket,一个叫做已完成连接 socket。
成功建立连接后,双方通过 read 和 write 函数来读写数据,就像往一个文件流里写东西一样。
UDP 是没有连接的,不需要三次握手,也不会像 TCP 调用 listen
和 connect
,但 UDP 仍需要 IP 地址和端口号,因此也需要 bind
。
对于 UDP,不需要维护连接,也就没有发送方和接收方,甚至都不存在客户端和服务度的概念,只需有一个 socket 多个机器就可以任意通信,因此每一个 UDP 的 socket 都需要 bind
。
同时在每次通信时,调用 sendto
和 recvfrom
都要传入目标主机的 IP 地址和端口。
本地 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 地址和端口,而是绑定一个本地文件,这也是他们之间最大的区别。