1、IPC 机制

(1)管道 PIPE

实际是用于进程间通信的一段共享内存,创建管道的进程称为管道服务器,连接到一个管道的进程为管道客户机。一个进程在向管道写入数据后,另一进程就可以从管道的另一端将其读取出来

  • 特点:
    • 半双工,需要双方通信时,要建立起两个管道
    • 只能用于父子进程或兄弟进程之间(具有亲缘关系的进程)。如 fork 或 exec 创建的新进程,使用 exec 创建新进程时,需要将管道的文件描述符作为参数传递给 exec 创建的新进程。 当父进程与 fork 创建的子进程直接通信时,发送数据的进程关闭读端,接受数据的进程关闭写端
    • 单独构成一种独立的文件系统:管道对于管道两端的进程而言是一个文件,但不属于某种文件系统,而是单独构成一种文件系统,且只存在与内存中
    • 数据的读出和写入:一个进程向管道中写的内容被管道另一端的进程读出。写入的内容每次都添加在管道缓冲区的末尾,且每次都从缓冲区的头部读出数据
  • 实现机制:管道是由内核管理的一个缓冲区,一个缓冲区不需要很大,它被设计成为环形的数据结构,以便管道可以被循环利用。当管道中没有信息的话,从管道中读取的进程会等待,直到另一端的进程放入信息。当管道被放满信息的时候,尝试放入信息的进程会等待,直到另一端的进程取出信息。当两个进程都终结的时候,管道也自动消失。
  • pipe 函数原型:通过使用底层的 read 和 write 调用来访问数据。 向 file_descriptor[1] 写数据,从 file_descriptor[0] 读数据。写入与读取原则是先进先出
  • 读写规则

    • 当没有数据可读时
      O_NONBLOCK disable:read 调用阻塞,即进程暂停执行,等到有数据来到为止
      O_NONBLOCK enable:read 调用返回 -1,errno 值为 EAGAIN
    • 当管道满时
      O_NONBLOCK disable: write 调用阻塞,直到有进程读走数据
      O_NONBLOCK enable:调用返回 -1,errno 值为 EAGAIN
    • 如果所有管道写端对应的文件描述符被关闭:read 返回 0
    • 如果所有管道读端对应的文件描述符被关闭:write 操作会产生信号 SIGPIPE
    • 当要写入的数据量不大于 PIPE_BUF(Posix.1 要求 PIPE_BUF 至少 512 字节)时,linux 将保证写入的原子性
    • 当要写入的数据量大于 PIPE_BUF 时,linux 不再保证写入的原子性

      (2)命名管道 FIFO

      特殊类型文件,在系统中以文件形式存在。允许没有亲缘关系的进程间通信

  • 管道和命名管道区别:

    • 命名管道 FIFO,IO 操作和普通管道 IO 操作基本一样,区别在命名管道中,管道可以事先创建好,必须用 open 函数显示建立连接到管道的通道,在 fork 时直接复制相关数据或用 exec 创建的新进程时把管道的文件描述符当参数传递进去
    • 一般 FIFO 和 PIPE 总处于阻塞状态。如果命名管道 FIFO 打开时设置读权限,则读进程将一直阻塞,直到其他进程打开该 FIFO 并写入数据。反之亦然。在 open 时使用 O_NONBLOCK 标志可以关闭默认的阻塞操作

      (3)消息队列

      • 内核地址空间的内部链表,通过 linux 内核在各个进程直接传递内容,消息顺序地发送到消息队列中,每个消息队列可以用 IPC 标识符唯一地进行识别。每个消息队列中的消息构成一个独立的链表
      • 克服信号承载信息量少,管道只能承载无格式字符流
      • 消息总大小不能超过 8192 个字节
  • 本质:链表,有消息队列标识符(queue ID)。 msgget 创建一个新队列或打开一个存在的队列,向队列末端添加一条新消息;msgrcv 从队列中取消息, 不一定遵循先进先出,可以按消息类型字段取

  • 消息队列与命名管道比较:
    • 消息队列的进程可以不相关,通过发送和接收方式传递数据
    • 命名管道发送数据用 write,接收数据用 read,消息队列中发送数据用 msgsnd,接收数据用 msgrcv,对每个数据都有一个最大长度限制
    • 与命名管道相比,消息队列的优势
      • 消息队列可独立于发送和接收进程存在,消除了在同步命名管道的打开和关闭时可能产生的困难
      • 通过发送消息可避免命名管道的同步和阻塞问题,不需要由进程自己来提供同步方法
      • 接收程序可以通过消息类型有选择地接收数据,不是像命名管道中那样只能默认地接收

        (4)信号 signal

        unix 系统最古老的进程间通信机制,用于一个或几个进程间传递异步信号。可以有各种异步事件产生,如键盘中断等

(5)信号量 Semaphore

一种计数器,用于控制对多个进程共享的资源进行的访问。常被用作锁机制,在某个进程正在对特定的资源进行操作时,信号量可防止另一个进程去访问它。 只取正整数值且只允许对这个值进行两种操作:等待(wait)和信号(signal)(P、V操作,P用于等待,V用于信号)

  • p(sv):如果 sv 的值大于 0,就给它减 1;如果它的值等于 0,就挂起该进程的执行
  • V(sv):如果有其他进程因等待 sv 而被挂起,就让它恢复运行;如果没有其他进程因等待 sv 而挂起,则给它加 1
  • P 相当于申请资源,V 相当于释放资源

    (6)共享内存

    • 在多个进程间共享内存区域的一种进程间通信方式,由 IPC 为进程创建的一个特殊地址范围。其他进程可以将同一段共享内存连接到自己的地址空间中。所有进程都可以访问共享内存中的地址,如果一个进程向共享内存中写入了数据,所做的改动将立刻被其他进程看到
    • IPC 最快捷的方式,管道、消息队列等方式需要将数据通过中间机制进行转换。多个进程间的共享内存是同一物理空间,仅映射到各进程的地址不同,不需复制,可以直接使用
    • 没有同步机制,需要自己控制
  • 消息队列、信号量、共享内存相似:都是 XSI IPC,都用一个非负整数的标识符加以引用(消息队列的 msg_id,信号量的 sem_id,共享内存的 shm_id,分别通过 msgget、semget 以及 shmget 获得),标志符是 IPC 对象的内部名,每个 IPC 对象都有一个键(key_t key)相关联,将这个键作为该对象的外部名

  • XSI IPC 和 PIPE、FIFO 的区别:

    • XSI IPC 的 IPC 结构在系统范围内起作用,没用使用引用计数。如果一个进程创建一个消息队列,并在消息队列中放入几个消息,进程终止后,即使现在没有程序使用该消息队列,消息队列及其内容依然保留。而 PIPE 在最后一个引用管道的进程终止时,管道就被完全删除。对于 FIFO 最后一个引用 FIFO 的进程终止时,FIFO 还在系统,但其中的内容会被删除
    • 和 PIPE、FIFO 不一样,XSI IPC 不使用文件描述符,不能用 ls 查看 IPC 对象,不能用 rm 命令删除,不能用 chmod 命令删除访问权限。只能使用 ipcs 和 ipcrm 来查看、删除

      (7)内存映射 mmap()

      • 由一个文件到一块内存的映射。与虚拟内存类似,通过内存映射文件可以保留一个地址的区域,同时将物理存储器提交给此区域,内存文件映射的物理存储器来自一个已经存在于磁盘上的文件,且在对该文件进行操作前必须先对文件进行映射。不必再对文件执行 I/O 操作
      • 普通做法把磁盘上的文件先拷贝到内核空间一个缓冲区再拷贝到用户空间(内存),用户修改后再拷贝到缓冲区再拷贝到磁盘文件,一共四次拷贝。mmap() 真正拷贝是在在缺页中断处理时进行,将文件直接映射到用户空间,中断处理函数根据这个映射关系,直接将文件从硬盘拷贝到用户空间,只进行一次数据拷贝。效率高于 read/write
  • 共享内存和内存映射文件区别:

    • 内存映射文件是利用虚拟内存把文件映射到进程的地址空间中去,进程操作文件就像操作进程空间地址一样,在频繁处理一个文件或大文件时,IO 效率比普通 IO 效率高
    • 共享内存是内存映射文件的一种特殊情况,内存映射的是一块内存,而非磁盘上的文件。在不同进程间访问同一内存,需要共享内存的进程通过 API 访问,就像访问一个硬盘上的文件一样
  • 内存映射文件与虚拟内存区别:
    • 都是将一部分内容加载到内存,另一部放在磁盘上。对用户都是透明的
    • 虚拟内存是硬盘的一部分,是内存和硬盘的数据交换区,内存映射是一个文件到一块内存的映射,程序通过内存指针就可以对文件进行访问
      • 虚拟内存的基础是分页机制和局部性原理(时间局部性和空间局部性),内存映射文件不是局部性

        (8)套接字 socket

        套接字明确将客户端与服务器区分开来,可以实现多个客户端连到同一服务器

2、IO 多路复用

单线程或单进程同时监测若干个文件描述符是否可以执行 IO 操作的能力

  • DMA(Direct Memory Access,直接存储器访问):处理 IO
  • Pagecache:Linux 内核所使用的主要磁盘高速缓存。内核读写磁盘时都用到
    • 如果程序想读部分不在高速缓存,先申请一个 4KB 大小的新页框加到 PageCache,再用磁盘读到的数据填充
    • 写操作时,先把要写的数据写到 pageCache,标记当前页面为脏,然后程序自己调用系统调用刷盘,或等内核到自己的默认设置刷盘,没及时写时断电白写
  • 文件描述符 fd:Linux 将一切抽象为文件,文件描述符用于对应打开/新建的文件,本质是个非负整数。实际上是个索引值,指向内核为每个进程所维护的该进程打开文件的记录表。程序打开现有或创建新文件时,内核向进程返回一个文件描述符
    • 每个进程一旦创建都有三个默认的文件描述符,u 代表读写都可
      • 0u(标准输入)
      • 1u(标准输出)
      • 2u(报错信息输出)
    • 每个文件描述符代表的数据结构中都有自己的偏移量,表示它可从当前文件哪个位置进行操作(读写)
    • 每个进程都有自己的文件描述符,因为进程隔离,不同进程维护的文件描述符可重复
    • 假如不同进程的相同文件描述符指向同一文件,仍各自维护自己的偏移量指针,每个进程可各自访问自己区域
  • socket:socket 类型的文件描述符有自己的缓存数据区域,但不是要刷盘的,是要通过网卡发走的,中间经历各种网络协议包装成数据包发往目标 IP 地址

    (1)select

  • 调用过程:

    • 使用 copy_from_user 从用户空间拷贝 fd_set 到内核空间
    • 注册回调函数 __pollwait
    • 遍历所有 fd,调用其对应的 poll 方法(socket 是 sock_poll,根据情况调用到 tcp_poll、udp_poll 或 datagram_poll)
    • __pollwait 把 current(当前进程)挂到设备的等待队列中,不同的设备有不同的等待队列,tcp_poll 等待队列是 sk->sk_sleep(把进程挂到等待队列中不代表进程已经睡眠)。设备收到一条消息(网络设备)或填写完文件数据(磁盘设备)后唤醒设备等待队列上睡眠的进程,current 被唤醒
    • poll 返回一个描述读写操作是否就绪的 mask 掩码,根据这个掩码给 fd_set 赋值
    • 如果遍历完所有 fd 还没有返回一个可读写的 mask 掩码,调用 schedule_timeout,current 进入睡眠。当设备驱动发生自身资源可读写后唤醒其等待队列上睡眠的进程。超过超时时间(schedule_timeout 指定)没人唤醒,调用 select 的进程会重新被唤醒获得 CPU,重新遍历 fd
    • 把 fd_set 从内核空间拷贝到用户空间
  • 本质通过设置或者检查存放 fd 标志位的数据结构进行下一步处理
  • 优点:一次系统调用把所有 fds 传给内核,减少 BIO 多次调用开销(假设 1000 个连接只有一个发来数据,BIO 需向内核发送 1000 次系统调用,999 次无意义,消耗时间和内存资源)
  • 缺点
    • 单个进程可监视的 fd 数量被限制,即能监听端口的大小有限
    • 对 socket 进行扫描时是线性扫描,即采用轮询,效率较低:
      • 每次调用 select 需要把 fd 集合从用户态拷贝到内核态,开销在 fd 很多时很大
      • 每次调用 select 需要在内核遍历传递进来的所有 fd,开销在 fd 很多时很大
    • 需要维护一个用来存放大量 fd 的数据结构,用户空间和内核空间在传递该结构时复制开销大

Linux - 图1

(2)poll

  • 和 select 比较:
    • 描述 fd 集合的方式不同,poll 使用 pollfd 结构,select fd_set 结构
    • 管理多个描述符也是轮询
    • 没有最大文件描述符数量限制
    • 本质和 select 没区别,将用户传入的数组拷贝到内核空间,查询每个 fd 对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有 fd 后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后又再次遍历 fd。经历多次无谓遍历
    • 没有最大连接数的限制,原因是基于链表存储
  • 缺点

    • 每次 poll 都要重新遍历全量 fds
    • 和 select 一样,大量 fd 数组被整体复制于用户态和内核地址空间之间,不管是否有意义
    • “水平触发”,如果报告了fd后,没有被处理,那么下次poll时会再次报告该fd

      (3)epoll

      Linux_epoll.png
  • Linux 特有

  • 使用“事件”的就绪通知方式,通过 epoll_ctl 注册 fd,一旦 fd 就绪,内核采用类似 callback 回调机制激活该 fd,epoll_wait 便可以收到通知
  • 工作模式
    • LT
      • fd 可读后,如果服务程序读走一部分就结束此次读取,LT 模式下该文件描述符仍然可读
      • fd 可写后,如果服务程序写了一部分就结束此次写入,LT 模式下该文件描述符仍然可写
    • ET
      • fd 可读后,如果服务程序读走一部分就结束此次读取,ET 模式下该文件描述符不可读,需等到下次数据到达时才变为可读,要保证循环读取数据,确保把所有数据读出
      • fd 可写后,如果服务程序写了一部分就结束此次写入,ET 模式下该文件描述符不可写,要写入数据,确保把数据写满
  • 优点:
    • 没有最大并发连接限制,能打开的 FD 的上限远大于 1024(1G 的内存能监听约 10 万个端口)
    • 效率提升,不是轮询,不随着 FD 数目增加效率下降。只有活跃可用的 FD 才调用 callback 函数
    • 只管“活跃”的连接,跟连接总数无关,效率高于 select 和 poll
    • 内存拷贝:利用 mmap() 文件映射内存加速与内核空间的消息传递,减少复制开销
  • 相比 select、poll:
    • 调用接口上,select 和 poll 都只提供了一个函数—— select 或 poll 函数。epoll 提供三个函数,epoll_create(创建 epoll 句柄)、epoll_ctl(注册要监听的事件类型)、epoll_wait(等待事件产生)
      • epoll_ctl 函数:每次注册新事件到 epoll 句柄中时(在 epoll_ctl 中指定 EPOLL_CTL_ADD),把所有的 fd 拷贝进内核,而不是在 epoll_wait 时重复拷贝。保证每个 fd 在整个过程只拷贝一次
      • 不像 select 或 poll 每次都把 current 轮流加入 fd 对应的设备等待队列中,只在 epoll_ctl 时把 current 挂一遍并为每个 fd 指定一个回调函数,当设备就绪,唤醒等待队列上的等待者时,调用回调函数,把就绪的 fd 加入就绪链表。epoll_wait 在就绪链表中查看有没有就绪的 fd(利用 schedule_timeout() 实现睡一会,判断一会的效果)
      • 没有 FD 个数限制
  • 机制

    • 设计:在 Linux 内核中申请一个简易的文件系统(文件系统一般用 B+ 树)
      • 调用 epoll_create() 建立一个 epoll 对象(在 epoll 文件系统中为这个句柄对象分配资源)
      • 调用 epoll_ctl 向 epoll 对象添加连接的套接字
      • 调用 epoll_wait 收集发生的事件连接
    • 当某一进程调用 epoll_create 方法时,Linux 内核会创建一个 eventpoll 结构体

      1. struct eventpoll{
      2. //...
      3. /*红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/
      4. struct rb_root rbr;
      5. /*双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件*/
      6. struct list_head rdlist;
      7. //...
      8. };
    • 每个 epoll 对象都有一个独立的 eventpoll 结构体,用于存放通过 epoll_ctl 方法向 epoll 对象中添加进来的事件。这些事件都会挂载在红黑树中,重复添加的事件可以通过红黑树高效识别出来

    • 所有添加到 epoll 中的事件都与设备(网卡)驱动程序建立回调关系,当相应事件发生时调用回调方法,在内核中叫 ep_poll_callback,将发生的事件添加到 rdlist 双链表中
    • 对于每个事件都会建立一个 epitem 结构体

      1. struct epitem{
      2. struct rb_node rbn;//红黑树节点
      3. struct list_head rdllink;//双向链表节点
      4. struct epoll_filefd ffd; //事件句柄信息
      5. struct eventpoll *ep; //指向其所属的eventpoll对象
      6. struct epoll_event event; //期待发生的事件类型
      7. }
    • 当调用 epoll_wait 检查是否有事件发生时,只需检查 eventpoll 对象中 rdlist 双链表是否有 epitem 元素。如果 rdlist 不为空,把发生的事件复制到用户态,同时将事件数量返回给用户

image.png

  • 调用

    • epoll_create() 系统调用。返回一个句柄,之后所有的使用都依靠句柄标识
    • epoll_ctl() 系统调用。向 epoll 对象中添加、删除、修改事件,返回 0 表示成功,返回 -1 表示失败
    • epoll_wait() 系统调用。收集在 epoll 监控中已经发生的事件

      (4)对比

  • 支持一个进程所能打开的最大连接数

    • select:单个进程所能打开的最大连接数有 FD_SETSIZE 宏定义,32 个整数的大小(32 位机器是 3232,64 位机器为3264)
    • poll:没有最大连接数的限制,原因是基于链表来存储的
    • epoll:有上限,但是很大,1G 内存的机器上可以打开 10 万左右的连接
  • FD 剧增后带来的 IO 效率问题
    • select:每次调用都对连接进行线性遍历,随着 FD 增加遍历速度慢
    • poll:同上
    • epoll:epoll 内核中实现是根据每个 fd 上的 callback 函数,只有活跃的 socket 才会主动调用 callback,在活跃 socket 较少情况下,epoll 没有前两者线性下降的性能问题,但所有 socket 都很活跃的情况可能有性能问题
  • 消息传递方式
    • select:内核需要将消息传递到用户空间,都需要内核拷贝动作
    • poll:同上
    • epoll:epoll 通过内核和用户空间共享一块内存来实现
  • 总结:
    • 在连接数少且连接都十分活跃情况下,select 和 poll 的性能可能比 epoll 好,epoll 的通知机制需要很多函数回调
      • select,poll 实现需要自己不断轮询所有 fd 集合直到设备就绪,期间可能要睡眠和唤醒多次交替。epoll 也调用 epoll_wait 不断轮询就绪链表,期间也可能多次睡眠和唤醒交替,但设备就绪时调用回调函数,把就绪 fd 放入就绪链表中,并唤醒在 epoll_wait 中睡眠的进程。虽然都要睡眠和交替,但 select 和 poll 在“醒着”时要遍历整个 fd 集合,epoll 在“醒着”时只判断就绪链表是否为空就行,节省大量 CPU 时间
    • select 低效是因为每次都需要轮询
      • select,poll 每次调用都要把 fd 集合从用户态往内核态拷贝一次,且把 current 往设备等待队列中挂一次,而 epoll 只要一次拷贝,且把 current 往等待队列上挂也只挂一次(在 epoll_wait 的开始,这里的等待队列不是设备等待队列,只是一个 epoll 内部定义的等待队列),节省不少开销