背景知识
IPC
对象内核中用于进程间通信的数据结构,全局可见,如消息队列的
msg_queue
结构体、信号量的sem_array
结构体,共享内存的shmid_kernel
结构体。类似于普通文件是通过文件名(文件描述符)进行读写操作,通过IPC key
和IPC
标识符进行IPC
对象的读写操作。IPC
标识符ID
类似于文件描述符
fd
,可以用一个IPC
标识符来引用一个IPC
对象,是一个32
位整数,是IPC
对象的外部名字。返回给用户进程的。IPC key
(魔数)IPC
对象的内部名,是一个独一无二的整数,用来确保IPC
对象的唯一性。该整数类型为key_t
,在sys/types.h
中被定义为长整型。
类似于普通文件通过文件名open
一个文件,获得文件描述符;IPC
对象是get
函数根据给定的key
去创建一个IPC
对象,并返回IPC
标识符ID
。根据新资源是信号量、消息队列还是共享内存,分别调用semget()
、msgget()
或者shmget()
函数创建IPC
资源。这三个函数的主要目的都是从
IPC key
(作为第一个参数传递)中导出相应的IPC
标识符ID
,进程以后就可以使用这个标识符对资源进行访问。如果还没有IPC
资源和IPC
关键字相关联,就创建一个新的资源。如果一切都顺利,那么函数就返回一个正的IPC
标识符,否则,就返回一个错误码。
在各个独立进程能够访问IPC
对象之前,IPC
对象必须在系统内唯一标识。为此,每种IPC
结构在创建时分配了一个IPC key
(程序员自由分配)。凡知道这个IPC key
的各个程序,都能够通过它得到一个标识符ID
,进而访问对应的IPC
对象。如果独立的应用程序需要彼此通信,则通常需要将该魔数永久地编译到程序中。
在访问IPC
对象时,操作系统采用了基于文件访问权限的一个权限系统。每个IPC
对象都有一个用户ID
和一个组ID
,依赖于产生IPC
对象的程序在何种UID/GID
之下运行。读写权限在初始化时分配。类似于普通的文件,这些控制了3
种不同用户类别的访问:所有者、组、其他。
IPC
在内核中的默认命名空间通过ipc_namespace
的静态实例init_ipc_ns
实现,每个命名空间都包含如下信息:
1 | // ipc.h |
该结构体中我们更感兴趣的是数组ids
。每个数组元素对应于一种IPC
机制:共享内存、信号量、消息队列。每个数组项指向一个struct ipc_ids
实例,该结构用于跟踪各类别现存的IPC
对象。比如,索引0
对应的是信号量,其后是消息队列,最后是共享内存。
1 | // ipc/util.h |
前几个成员保存了有关IPC
对象状态的一般信息。
in_use
保存了当前使用中IPC
对象的数目。seq
和seq_max
用于连续产生用户空间IPC
标识符ID
。计算方式为ID = seq * M + id
。M
是固定的宏,值为32768
,seq
被初始化为0
,每次产生一个ID
后加1
,id
为内核内部使用的一个数。rw_mutex
是一个内核信号量。它用于实现信号量操作,避免用户空间中的竞态条件。该互斥量有效地保护了包含信号量值的数据结构。
每个IPC
对象都由kern_ipc_perm
的一个实例表示,并且都有一个IPC
标识符ID
,ipcs_idr
用于将ID
关联到指向对应的kern_ipc_perm
实例的指针。
kern_ipc_perm
的成员保存了有关IPC
对象的所有者和访问权限等信息。
1 | struct kern_ipc_perm |
key
保存了用户程序用来标识IPC
对象的IPC key
,。id
就是前面用来计算标识符ID
的公式中的id
uid
和gid
分别指定了所有者的用户ID
和组ID
。cuid
和cgid
保存了产生信号量的进程的用户ID
和组ID
。seq
是一个序号,在分配IPC
对象时使用,和ipc_ids
结构中意思相同,为创建该资源是使用的seq
。mode
保存了位掩码,指定了所有者、组、其他用户的访问权限。
应用场景
数据传输:一个进程需要将它的数据发送给另一个进程,发送的数据量在一个字节到几兆字节之间。
共享数据:多个进程想要操作共享数据,一个进程对共享数据的修改,别的进程应该立刻看到。
通知事件:一个进程需要向另一个或一组进程发送消息,通知它发生了某种事件(如进程终止时要通知父进程)。
资源共享:多个进程之间共享同样的资源。为了作到这一点,需要内核提供锁和同步机制。
进程控制:有些进程希望完全控制另一个进程的运行(如
Debug
进程)模式,此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
每个进程的用户地址空间都是独立的,但是内核空间是共享的,所以,进程之间要想相互通信都要经过内核。Linux
提供的几种进程间通信方式有管道、消息队列、信号量、信号、共享内存和本地套接字
管道
管道只能单向传输数据,如果要想双向传输数据,需要创建两个管道。管道分为命名管道和匿名管道。
命名管道的可通过mkfifo
命令来创建,并且指定管道名。
1 | mkfifo mypipe |
命名管道是以文件的方式存在于文件系统中,类型为p
。进程之间可以通过这个文件进行通信。它是为了解决下面将要介绍的匿名管道只能用于具有亲缘关系的进程间通信的局限性的。
匿名管道通过pipe
系统调用创建:
1 | int pipe(int fd[2]); |
该函数通过传出参数返回两个文件描述符,如果不清楚文件描述符的概念,参考这篇文章。一个是管道读取端描述符fd[0]
,一个是管道的写入端描述符fd[1]
。匿名管道是只存在内存中的特殊文件,实际上就是内核中的一块缓存。管道传输的数据是无格式的字节流且大小受限。
通过fork
系统调用创建子进程,创建的子进程会复制父进程的文件描述符,这样两个进程就可以通过各自的fd
写入和读取同一个管道文件实现跨进程通信了。
管道只能一端写入,另一端读出,所以上面这种模式容易造成混乱,因为父进程和子进程都可以同时写入,也都可以读出。那么,为了避免这种情况,通常的做法是:
- 父进程关闭读取的
fd[0]
,只保留写入的fd[1]
; - 子进程关闭写入的
fd[1]
,只保留读取的fd[0]
;
在shell
里面执行A | B
命令的时候,A
进程和B
进程都是shell
创建出来的子进程,A
和B
之间不存在父子关系,它俩的父进程都是shell
。
当管道中没有信息的话,从管道中读取的进程会等待,直到另一端的进程放入信息。当管道被放满信息的时候,尝试放入信息的进程会等待,直到另一端的进程取出信息。当两个进程都终结的时候,管道也自动消失。
在Linux
中,管道的实现并没有使用专门的数据结构,而是借助了文件系统的file
结构和VFS
的索引节点inode
。通过将两个file
结构指向同一个临时的VFS
索引节点,而这个VFS
索引节点又指向一个物理页面而实现的。
管道是个环形缓冲区,对环形缓冲区的维护,主要是协调好数据读写的两个指针,以及生产者、消费者的休眠时机。环形缓冲区中一个指针用于读数据,另一个用于写数据。当缓冲区已满时,生产者要睡眠,并在睡眠前唤醒消费者,当缓冲区为空时,消费者要睡眠,并在睡眠前唤醒生产者。当缓冲区满或空时,使一方休眠,这是保证数据不丢失的方法。管道其实就是典型的生产者和消费者问题。
管道写函数通过将字节复制到VFS
索引节点指向的物理内存而写入数据,而管道读函数则通过复制物理内存中的字节而读出数据。当然,内核必须利用一定的机制同步对管道的访问,为此,内核使用了锁、等待队列和信号。
当写进程向管道中写入时,它利用标准的库函数write()
,系统根据库函数传递的文件描述符,可找到该文件的file
结构。file
结构中指定了用来进行管道专门写操作的函数pipe_write
地址,于是,内核调用该函数完成写操作。写入函数在向内存中写入数据之前,必须首先检查VFS
索引节点中的信息,同时满足如下条件时,才能进行实际的内存复制工作:
- 内存中有足够的空间可容纳所有要写入的数据;
- 内存没有被读程序锁定。
如果同时满足上述条件,写入函数首先锁定内存,然后从写进程的地址空间中复制数据到内存。否则,写入进程就休眠在VFS
索引节点的等待队列中,接下来,内核将调用调度程序,而调度程序会选择其他进程运行。写入进程实际处于可中断的等待状态,当内存中有足够的空间可以容纳写入数据,或内存被解锁时,读取进程会唤醒写入进程,这时,写入进程将接收到信号。当数据写入内存之后,内存被解锁,而所有休眠在索引节点的读取进程会被唤醒。
管道的读取过程和写入过程类似,通过read系统调用转调用管道专用读操作函数pipe_read
。但是,进程可以在没有数据或内存被锁定时立即返回错误信息,而不是阻塞该进程,这依赖于文件或管道的打开模式。反之,进程可以休眠在索引节点的等待队列中等待写入进程写入数据。当所有的进程完成了管道操作之后,管道的索引节点被丢弃,而共享数据页也被释放。
可以说管道适用于具有亲缘关系的进程间通信。无论是命名管道还是匿名管道,进程写入的数据都是缓存在内核的内存缓冲区中,读取的时候自然也是从内核的内存缓冲区中读取,因此需要在用户态和内核态之间转换。因此,管道的通信方式是低效率的,不适合进程间频繁地、大块地交换数据。
消息队列
消息队列的通信模式比如说,A
进程要给B
进程发送消息,A
进程把数据放在对应的消息队列后就可以正常返回了,B
进程需要的时候再去读取数据就可以了。同理,B
进程要给A
进程发送消息也是如此。
消息队列是保存在内核中的消息链表,在发送数据时,会分成一个一个独立的数据单元,也就是消息体(数据块),消息体是用户自定义的数据类型,消息的发送方和接收方要约定好消息体的数据类型,所以每个消息体都是固定大小的存储块,不像管道是无格式的字节流数据。如果进程从消息队列中读取了消息体,内核就会把这个消息体删除。
消息队列生命周期根随内核,如果没有释放消息队列或者没有关闭操作系统,消息队列会一直存在。发送者和接收者通过消息队列通信时,无需同时运行,例如,发送进程可以打开一个队列,写入消息,然后结束工作,接收进程在发送者结束之后启动,仍然可以访问队列并(根据消息编号)获取消息。中间的一段时间内消息由内核维护。
消息队列不适合比较大数据的传输,因为在内核中每个消息体都有一个最大长度的限制,每个消息都至少分配一个内存页,同时所有队列所包含的全部消息体的总长度也是有上限。在Linux
内核中,会有两个宏定义MSGMAX
和MSGMNB
,它们以字节为单位,分别定义了一条消息的最大长度和一个队列的最大长度。
消息队列通信过程中,存在用户态与内核态之间的数据拷贝开销,因为进程写入数据到内核中的消息队列时或者另一进程读取内核中的消息数据时,会发生从内核缓冲区拷贝数据到用户缓冲区的过程。
消息队列的实现:
消息队列使用名为msg_queue
的结构体:
1 | struct msg_queue |
q_messages
中的各个消息都封装在msg_msg
结构体中:
1 | struct msg_msg |
消息正文紧接着该数据结构的实例之后存储。使用next,可以使消息分布到任意数目的页上。在通过消息队列通信时,发送进程和接收进程都可以进入睡眠:如果消息队列已经达到最大容量,则发送者在试图写入消息时会进入睡眠;如果队列中没有消息,那么接收者在试图获取消息时会进入睡眠。
睡眠的发送者放置在msg_queue
的q_senders
链表中,睡眠的接收者放置在q_receivers
链表中,链表元素使用下列数据结构:
1 | struct msg_sender |
共享内存
操作系统内存管理采用的是虚拟内存技术,也就是每个进程都有自己独立的虚拟内存空间,不同进程的虚拟内存映射到不同的物理内存中。所以,即使进程A
和进程B
的虚拟地址是一样的,其实访问的是不同的物理内存地址,对于数据的增删查改互不影响。
共享内存的机制,就是拿出一块虚拟地址空间来,映射到相同的物理内存中。这样这个进程写入的东西,另外一个进程马上就能看到了,都不需要拷贝来拷贝去,传来传去,大大提高了进程间通信的速度。
但是共享内存通信方式带来新的问题,那就是如果多个进程同时修改同一个共享内存,很有可能就冲突了。例如两个进程都同时写一个地址,那先写的那个进程会发现内容被别人覆盖了。
共享内存的实现:
同样,在smd_ids
全局变量的entries
数组中保存了kern_ipc_perm
和shmid_kernel
的组合,以便管理IPC
对象的访问权限。对每个共享内存对象都创建一个伪文件,通过shm_file
连接到shmid_kernel
的实例。内核使用shm_file->f_mapping
指针访问地址空间对象(struct address_space
),用于创建匿名映射。还需要设置所涉及各进程的页表,使得各个进程都能够访问与该IPC
对象相关的内存区域。
信号量
为了防止多进程竞争共享资源,而造成的数据错乱,所以需要保护机制,使得共享的资源,在任意时刻只能被一个进程访问。正好,信号量就实现了这一保护机制。
信号量其实是一个整型的计数器,程序对其访问都是原子操作,主要用于实现进程间的互斥与同步,而不是用于缓存进程间通信的数据。
信号量表示资源的数量,控制信号量的方式有两种原子操作:
- 一个是
P
操作,这个操作会把信号量减去-1
,相减后如果信号量< 0
,则表明资源已被占用,进程需阻塞等待;相减后如果信号量>= 0
,则表明还有资源可使用,进程可正常继续执行。 - 另一个是
V
操作,这个操作会把信号量加上1
,相加后如果信号量<= 0
,则表明当前有阻塞中的进程,于是会将该进程唤醒运行;相加后如果信号量> 0
,则表明当前没有阻塞中的进程;
P
操作是用在进入共享资源之前,V
操作是用在离开共享资源之后,这两个操作是必须成对出现的。
信号量初始化为1
,就代表着是互斥信号量,它可以保证共享内存在任何时刻只有一个进程在访问,这就很好的保护了共享内存。信号初始化为0
,就代表着是同步信号量,它可以保证进程A
应在进程B
之前执行。
信号量的实现:
sem_queue
是一个数据结构,用于将信号量与睡眠进程关联起来,该进程想要执行信号量操作,但目前不允许执行。换句话说,信号量的待决操作列表中,每一项都是该数据结构的实例。
1 | // sem.h |
对每个信号量,都有一个队列管理与信号量相关的所有睡眠进程。该队列并未使用内核的标准设施实现(即struct list_head
),而是通过next
和prev
指针手工实现的。
sleeper
是一个指针,指向等待执行信号量操作进程的task_struct
实例。pid
指定了等待进程的PID
。id
保存了标识该信号量的ID
。sops
是一个指针,指向保存待决信号量操作的数组。操作数目(即,数组的长度)在nsops
中定义。alter
表明操作是否修改信号量的值(例如,状态查询不改变值)。sma
保存了一个指针,指向用于管理信号量状态的数据结构的实例。
1 | // sem.h |
系统中的每个信号量集合,都对应于该数据结构的一个实例。该实例用于管理集合中的所有信号量(这个信号量集合指的是,每个进程操作信号量时,信号量都有一个值,将这些值组合在这个集合中,由下面要介绍的sem_base
指向的数组表示)。
信号量访问权限保存在我们熟悉的
kern_ipc_perm
类型的sem_perm
成员中。sem_nsems
指定了一个用户信号量集合中信号量的数目。sem_base
是一个数组,每个数组项描述了集合中的一个信号量。其中保存了当前的信号量值和上一次访问它的进程的PID
。1
2
3
4
5struct sem
{
int semval; // 当前值
int sempid; // 上一次操作进程的 pid
};sem_otime
指定了上一次访问信号量的时间。sem_ctime
指定了上次修改信号量值的时间。sem_pending
指向待决信号量操作的链表。该链表由sem_queue
实例组成。sem_pending_last
用于快速访问该链表的最后一个元素,而sem_pending
指向链表的起始。
从当前命名空间获得sem_ids
实例开始,内核通过ipcs_idr
找到ID
到指针的映射,在其中查找所需的kern_ipc_perm
实例。kern_ipc_perm
项可以转换为sem_array
的实例。信号量的当前状态需要通过与另外两个结构的联系获取。
- 待决操作通过
sem_queue
实例的链表管理。等待操作执行的睡眠进程,也可以通过该链表确定。 struct sem
实例的数组用于保存集合中各个信号量的值。
kern_ipc_perm
是用来管理IPC
对象的数据结构的第一个成员,不止对信号量是这样,消息队列和共享内存对象也是如此。这使得内核可以使用同样的代码检查所有3
种对象的访问权限。放在第一个位置还方便转换为sem_array
。
每个sem_queue
成员包含了一个指针sops
,指向sembuf
实例的数组,sembuf
详细描述了在信号量上将要执行的操作。使用sembuf
实例的数组,可以使用一个semctl
调用,用于在信号量集合的各个信号量上执行操作。
1 | // sem.h |
它不仅保存了信号量在信号量集合struct sem[]
中的索引(sem_num
),还有所要进行的操作(sem_op
)和一些操作标志(sem_flg
)。
信号
上面说的进程间通信,都是常规状态下的工作模式。对于异常情况下的工作模式,就需要用「信号」的方式来通知进程。在Linux
操作系统中, 为了响应各种各样的事件,提供了几十种信号,分别代表不同的意义。我们可以通过kill -l
命令,查看所有的信号:
1 | kill -l |
运行在shell
终端的进程,我们可以通过键盘输入某些组合键的时候,给进程发送信号。例如
Ctrl + C
产生SIGINT
信号,表示终止该进程;Ctrl + Z
产生SIGTSTP
信号,表示停止该进程,但还未结束;
如果进程在后台运行,可以通过kill
命令的方式给进程发送信号,但前提需要知道运行中的进程PID
号,例如:
1 | $kill -9 1050 |
表示给PID
为1050
的进程发送SIGKILL
信号,用来立即结束该进程。
所以,信号事件的来源主要有硬件来源(如键盘Cltr + C
)和软件来源(如kill
命令)。
信号是进程间通信机制中唯一的异步通信机制,因为可以在任何时候发送信号给某一进程,一旦有信号产生,我们就有下面这几种,用户进程对信号的处理方式。
执行默认操作。
Linux
对每种信号都规定了默认操作,例如,上面列表中的SIGTERM
信号,就是终止进程的意思。Core
的意思是Core Dump
,也即终止进程后,通过Core Dump
将当前进程的运行状态保存在文件里面,方便程序员事后进行分析问题在哪里。捕捉信号。我们可以为信号定义一个信号处理函数。当信号发生时,我们就执行相应的信号处理函数。
忽略信号。当我们不希望处理某些信号的时候,就可以忽略该信号,不做任何处理。有两个信号是应用进程无法捕捉和忽略的,即
SIGKILL
和SEGSTOP
,它们用于在任何时候中断或结束某一进程。
本地套接字
我们先来看看创建socket
的系统调用:
1 | 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
参数原本是用来指定通信协议的,但现在基本废弃。因为协议已经通过前面两个参数指定完成,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
是等价的。
本地字节流socket
和本地数据报socket
在bind
的时候,不像TCP
和UDP
要绑定IP
地址和端口,而是绑定一个本地文件,这也是它们之间的最大区别。