Skip to content

1. 进程间的通信方式

小林coding

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

1.1 管道

匿名管道

$ ps auxf | grep mysql
传输数据是单向的,如果想相互通信,需要创建两个管道。

命名管道,也被叫做 FIFO,因为数据是先进先出的传输方式:

# 创建命名管道
$ mkfifo myPipe

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

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

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

管道这种通信方式效率低,不适合进程间频繁地交换数据

1.1.1 匿名管道的创建

匿名管道的创建需要通过下面这个系统调用:

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 创建出来的子进程,A 和 B 之间不存在父子关系,它俩的父进程都是 shell。

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

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

1.2 消息队列

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

消息队列生命周期跟随内核,如果没有释放消息队列或者没有关闭操作系统,消息队列会一直存在。

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

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

1.3 共享内存

共享内存的机制,就是拿出一块虚拟地址空间来,映射到相同的物理内存中。不会发生用户态与内核态之间的消息拷贝过程。

进程间不需要拷贝来拷贝去,传来传去,大大提高了进程间通信的速度。

1.4 信号量

共享内存通信方式,在多进程场景中会造成读写冲突。

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

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

  • P 操作,把信号量减一。
  • 若相减后 信号量 < 0,则表明资源已被占用,进程需要阻塞等待被唤醒
  • 若相减后 信号量 >= 0,则表明还有资源可以使用,进程可以正常继续执行
  • V 操作,把信号量加一。
  • 若相加后 信号量 <= 0,则表明当前存在被阻塞的进程,然后将该进程唤醒
  • 若相加后 信号量 > 0,则表明当前不存在被阻塞的进程

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

不同的信号量初值决定了进程间不同的通信方式:

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

例如,进程 A 是负责生产数据,而进程 B 是负责读取数据:

  • 如果进程 B 比进程 A 先执行了,那么执行到 P 操作时,由于信号量初始值为 0,故信号量会变为 -1,表示进程 A 还没生产数据,于是进程 B 就阻塞等待;
  • 接着,当进程 A 生产完数据后,执行了 V 操作,就会使得信号量变为 0,于是就会唤醒阻塞在 P 操作的进程 B;
  • 最后,进程 B 被唤醒后,意味着进程 A 已经生产了数据,于是进程 B 就可以正常读取数据了。

1.5 信号

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

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

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

信号是进程间通信机制中唯一的异步通信机制,因为可以在任何时候发送信号给某一进程。下面是用户进程对信号的处理方式:

  1. 执行默认操作。Linux 对每种信号都规定了默认操作,例如,上面列表中的 SIGTERM 信号,就是终止进程的意思。
  2. 捕捉信号。我们可以为信号定义一个信号处理函数。当信号发生时,我们就执行相应的信号处理函数。
  3. 忽略信号。当我们不希望处理某些信号的时候,就可以忽略该信号,不做任何处理。有两个信号是应用进程无法捕捉和忽略的,即 SIGKILLSIGSTOP,它们用于在任何时候中断或结束某一进程。

1.5.1 信号

1.6 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 表示的是原始套接字;
  • protocOl 参数原本是用来指定通信协议的,但现在基本废弃。因为协议已经通过前面两个参数指定完成,protocol 目前一般写成 0 即可;

1.6.1 针对 TCP 协议通信的编程模型

监听的 socket 和真正用来传送数据的 socket,是「两个」socket,一个叫作监听 socket,一个叫作已完成连接 socket。

1.6.2 针对 UDP 协议通信的编程模型

1.6.3 本地进程间通信

本地 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 地址和端口,而是绑定一个本地文件,这也就是它们之间的最大区别。