fd与io模型

Linux 中的文件描述符fd

fd 文件描述符简介

在linux中,fd全称“File descriptor”,中文名为“文件描述符”。文件描述符是一个非负整数,本质上是一个索引值(这句话非常重要)。

Linux中的文件描述符(fd)我们知道在Linux系统中一切皆可以看成是文件,文件又可分为:普通文件目录文件链接文件设备文件。在操作这些所谓的文件的时候,我们每操作一次就找一次名字,这会耗费大量的时间和效率。所以Linux中规定每一个文件对应一个索引,这样要操作文件的时候,我们直接找到索引就可以对其进行操作了。

文件描述符(file descriptor)就是内核为了高效管理这些已经被打开的文件所创建的索引。

同时还规定系统刚刚启动的时候,0是标准输入,1是标准输出,2是标准错误。这意味着如果此时去打开一个新的文件,它的文件描述符会是3,再打开一个文件文件描述符就是4……

Linux内核对所有打开的文件有一个文件描述符表格,里面存储了每个文件描述符作为索引与一个打开文件相对应的关系,简单理解就是下图这样一个数组,文件描述符(索引)就是文件描述符表这个数组的下标,数组的内容就是指向一个个打开的文件的指针。

image-20221019140931477

文件描述符结构

上面只是简单理解,实际上关于文件描述符,Linux内核维护了3个数据结构

  1. 进程级的文件描述符表
  2. 系统级的打开文件描述符表
  3. 文件系统的i-node表

进程级文件描述符:

一个 Linux 进程启动后,会在内核空间中创建一个 PCB 控制块,PCB 内部有一个文件描述符表(File descriptor table),记录着当前进程所有可用的文件描述符,也即当前进程所有打开的文件。进程级的描述符表的每一条记录了单个进程所使用的文件描述符的相关信息,进程之间相互独立,一个进程使用了文件描述符3,另一个进程也可以用3

系统级的打开文件描述符表:

  • 当前文件偏移量(调用read( )和write( )时更新,或使用lseek( )直接修改)
  • 打开文件时的标识(open( )的flags参数)
  • 文件访问模式(如调用open( )时所设置的只读模式、只写模式或读写模式)
  • 与信号驱动相关的设置
  • 对该文件i-node对象的引用,即i-node 表指针

文件系统的i-node表:

  • 文件类型(例如:常规文件、套接字或FIFO)和访问权限
  • 一个指针,指向该文件所持有的锁列表
  • 文件的各种属性,包括文件大小以及与不同类型操作相关的时间戳

文件描述符、打开的文件句柄以及i-node之间的关系如下图:

image-20221019141435416
  • 在进程 A 中,文件描述符 1 和 20 都指向了同一个打开文件表项,标号为 23(指向了打开文件表中下标为 23 的数组元素),这可能是通过调用 dup()、dup2()、fcntl() 或者对同一个文件多次调用了 open() 函数形成的。

  • 进程 A 的文件描述符 2 和进程 B 的文件描述符 2 都指向了同一个文件73,这可能是在调用 fork() 后出现的(即进程 A、B 是父子进程关系),或者是不同的进程独自去调用 open() 函数打开了同一个文件,此时进程内部的描述符正好分配到与其他进程打开该文件的描述符一样。

  • 进程 A 的描述符 0 和进程 B 的描述符 3 分别指向不同的打开文件表项,但这些表项均指向 i-node 表的同一个条目(标号为 1976);换言之,它们指向了同一个文件。发生这种情况是因为每个进程各自对同一个文件发起了 open() 调用。同一个进程两次打开同一个文件,也会发生类似情况。

    这就说明:同一个进程的不同文件描述符可以指向同一个文件;不同进程可以拥有相同的文件描述符;不同进程的同一个文件描述符可以指向不同的文件(一般也是这样,除了 0、1、2 这三个特殊的文件);不同进程的不同文件描述符也可以指向同一个文件。

Socket

Unix Socket 介绍

和IP可以标识一台计算机一样,进程在网络里面通信也是需要被唯一标识,那么如果是在一台计算机里,PID可以标识一个进程,但是在网络里多态计算机,PID就不能唯一标识一个进程,那就思考一下,IP可以标识一台主机,TCP协议端口号可以唯一标识一个进程,那么用(IP|协议|端口号)即可唯一标识一个进程。然后网络中两个进程就可以使用Socket进行通信。

Socket是对TCP/IP协议的抽象,是操作系统对外开放的接口。套接字(socket)是一个抽象层,应用程序可以通过它发送或接收数据,可对其进行像对文件一样的打开、读写和关闭等操作。

Socket让我们能够更加方便的使用TCP/IP协议栈,它对TCP/IP协议抽象,形成一些函数比如创建、关闭、发送等。

Socket起源于Unix,Unix遵从“一切皆文件(Everything is a file)”的思想,那么Socket也是,Socket的操作就是基与打开、读写、关闭这种模式实现的。服务器和客户端各自维护一个文件,在建立连接之后,可以向自己的文件写入内容,读取对方内容,或供对方读取自己内容,通讯结束时关闭文件。

image-20221019145428307

Socket通信流程

image-20221019145501964

tcp网络大致流程如上图,其中socket可以理解为一种fd(fd是文件描述符,文件具有io,io属性,内存与外界任何地方通讯都是io),每个socket对应的fd都是由五元组组成(五元组(发送ip、接收ip、发送端口、接受端口、传输层协议(udp/tcp)))。

listen函数是监听等待,处理三次握手。当完成三次握手的fd放入到请求队列中。调用accept函数是从请求队列中取第一个连接信息,并且会创建一个新的fd,这个fd与对应外面的请求fd进行recv与send。如果请求队列当前没有请求,则accept() 将进入阻塞 状态直到有请求进入队列。

Socket IO模型

讲到了socket,那么也将IO模型一块研究

一些基本概念

  • 进程(线程)切换:所有系统都有调度进程的能力,它可以挂起一个当前正在运行的进程,并恢复之前挂起的进程。

  • 进程(线程)的阻塞:运行中的进程,有时会等待其他事件的执行完成,比如等待锁,请求I/O的读写;进程在等待过程会被系统自动执行阻塞,此时进程不占用CPU。

  • 文件描述符:在Linux,文件描述符是一个用于表述指向文件引用的抽象化概念,它是一个非负整数。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符,网络套接字也是文件描述符。

  • Linux信号处理:Linux进程运行中可以接收来自系统或者进程的信号值,然后根据信号值去运行相应捕捉函数;信号相当于是硬件中断的软件模拟。

  • 用户空间和内核空间:操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的权限。为了避免用户进程直接操作内核,保证内核安全,操作系统将虚拟内存划分为两部分,一部分是内核空间(kernel-space),一部分是用户空间(user-space)。在linux系统中,内核模块运行在内核空间,对应的进程处于内核态;而用户程序运行在用户空间,对应的进程处于用户态。

  • 同步:调用某个东西,调用方得等待这个调用结果返回才能继续往后执行。

  • 异步:和同步相反,调用方不会立刻得到结果,而是在调用发出之后调用者可以继续执行后续的操作,被调用者通过状态、通知或者回调函数来处理这个调用。

  • 阻塞:指调用结果返回之前,当前线程会被挂起(线程进入非可执行状态,在这个状态下,cpu不会给线程分配时间片,即线程暂停运行)。函数只有在得到结果之后才会返回。

  • 非阻塞:不能立刻得到结果之前,该调用不会阻塞当前线程,而会立刻返回。

同步和异步强调的是消息通信机制 (synchronous communication/ asynchronous communication)。阻塞和非阻塞 强调的是程序在等待调用结果(消息,返回值)时的状态。

同步IO和异步IO的区别就在于:数据访问的时候进程是否阻塞。

阻塞IO和非阻塞IO的区别就在于:应用程序的调用是否立即返回。

同步和异步都只针对于本机SOCKET而言的。

IO

unix世界里一切皆文件,而文件是什么?文件就是一串二进制流。不管socket、还是FIFO、管道、终端,对我们来说,一切都是文件,一切都是流,在信息交换的过程中,我们都是对这些流进行数据的收发操作,简称为I/O操作(input and output)。(如创建Socket,系统调用会返回一个文件描述符,之后对socket的操作会转化为对文件描述符的操作,也就是对文件流的操作,这是一种分层和抽象的思想。)

IO交互过程

用户进程中一个完整IO分为两个阶段:

  1. 用户空间 <====> 内核空间
  2. 内核空间 <====> 设备空间

网络I/O的本质是socket的读取,socket在linux系统被抽象为流,I/O可以理解为对流的操作。 这个操作又分为两个阶段: 等待流数据准备(wating for the data to be ready)。 从内核向进程复制数据(copying the data from the kernel to the process)。 第一步通常涉及等待网络上的数据分组到达,然后被复制到内核的某个缓冲区。 第二步把数据从内核缓冲区复制到应用进程缓冲区。

内核空间:存放内核代码、数据。运行操作系统和驱动程序。

用户空间:存放用户程序代码、数据。运行应用程序。

内核空间和用户空间传输数据需要调用请求kernel来协助完成IO,内核会为每个IO设备维护一个缓冲区(先访问缓冲区,没有在访问设备)。

一个网络输入的操作流程:网络数据到达网卡 -> 读取到内核缓冲区 -> 从内核缓冲区复制数据 -> 用户空间

IO模型分析

5中IO模型:阻塞式I/O、非阻塞式I/O、I/O复用、异步I/O、信号驱动I/O。

    1. 阻塞式I/O
image-20221019150403756

应用程序调用一个IO函数,导致应用程序阻塞,等待数据准备好。 如果数据没有准备好,一直等待….数据准备好了,从内核拷贝到用户空间,IO函数返回成功指示。

阻塞式I/O的特点:最基础的/O模型就是阻塞/O模型,也是最简单的模型。所有的操作都是顺序执行的。阻塞IO模型中,用户空间的应用程序执行一个系统调用(recvfrom),会导致应用程序被阻塞,直到内核缓冲区的数据准备好,并且将数据从内核复制到用户进程。最后进程才被系统唤醒处理数据。

缺点:进程或者线程会被阻塞,效率不高。

    1. 非阻塞式I/O
image-20221019150424862

非阻塞的接口相比于阻塞型接口的显著差异在于,在被调用之后立即返回。使用如下 的函数可以将某句柄 fd 设为非阻塞状态。fcntl( fd, F_SETFL, O_NONBLOCK )。这种方式称为轮询(polling),在这个不断测试的过程中,会大量的占用CPU的时间,因此这种模式CPU的利用率较低。

非阻塞I/O特点:非阻塞IO也是一种同步IO。它是基于轮询(polling)机制实现,在这种模型中,套接字是以非阻塞的形式打开的。就是说I/O操作不会立即完成,但是I/O操作会返回一个错误代码(EWOULDBLOCK),提示操作未完成。轮询检查内核数据,如果数据未准备好,则返回EWOULDBLOCK。进程再继续发起recvfrom调用,当然你可以暂停去做其他事。直到内核数据准备好,再拷贝数据到用户空间,然后进程拿到非错误码数据,接着进行数据处理。需要注意,拷贝数据整个过程,进程仍然是属于阻塞的状态。

缺点:不断轮询,会导致CPU占用率非常高。

    1. I/O多路复用

它的基本原理就是 select/epoll 这个function会不断的轮询所负责的所有 socket,当某个 socket 有数据到达了,就通知用户进程。

image-20221019150549478

当用户进程调用了 select,那么整个进程会被 block,而同时,kernel 会“监视”所有 select 负责的 socket,当任何一个 socket 中的数据准备好了,select 就会返回。这 个时候用户进程再调用 read 操作,将数据从 kernel 拷贝到用户进程。

在多路复用模型中,对于每一个 socket,一般都设置成为 non-blocking,但是,如上图所示,整个用户的 process 其实是一直被 block 的。只不过 process 是被 select 这个函数 block,而不是被 socket IO 给 block。因此 select()与非阻塞 IO 类似。

IO多路复用,就是对fd的管理,int select(int maxfdp1, fd_set *readset, fd_set *writeset, fd_set *exceptset, const struct timeval *timeout); 通俗点的参数解释就是select(io个数、可读fd、可写fd、出错fd、轮询时间)。这里,fd_set 类型可以简单的理解为按 bit 位标记句柄的队列,例如要在某 fd_set 中标记一个值为 16 的句柄,则该 fd_set 的第 16 个 bit 位被标记为 1。具体的置位、验证 可使用 FD_SET、FD_ISSET 等宏实现。在 select()函数中,readfds、writefds 和 exceptfds 同时作为输入参数和输出参数。如果输入的 readfds 标记了 16 号句柄,则 select()将检测16号句柄是否可读。在select()返回后,可以通过检查readfds有否标 记 16 号句柄,来判断该“可读”事件是否发生。另外,用户可以设置 timeout 时间。

多路复用I/O的特点:一般后端服务都会存在大量的socket连接,如果一次能查询多个套接字的读写状态,若有任意一个准备好,那就去处理它,效率会高很多。这就是“I/O多路复用”,多路指多个socket套接字,复用是指复用同一个进程或线程。

linux提供了select、poll、epoll等多路复用I/O的实现方式。select或poll、epoll是阻塞调用。
与阻寒IO不同,select不会等到socket数据全部到达再处理,而是有了一部分socket数据准备好就会恢复用户进程来处理。怎么知道有一部分数据在内核准备好了呢?答案:交给操作系统处理。

redis,nginx,netty,java的nio都用到了多路复用I/O。

缺点:编程比较麻烦。

    1. 异步I/O
image-20221019151346020

用户进程发起read操作之后,立刻就可以开始去做其它的事。而另一方面,从kernel 的角度,当它受到一个 asynchronous read 之后,首先它会立刻返回,所以不会对用户进 程产生任何 block。然后,kernel 会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel 会给用户进程发送一个 signal,告诉它 read 操作完成了。

异步 I/O 与信号驱动 I/O 的区别在于,异步 I/O 的信号是通知应用进程 I/O 完成,而信号驱动 I/O 的信号是通知应用进程可以开始 I/O。

数据拷贝的时候进程无需阻塞。当一个异步过程调用发出后,调用者不能立刻得到结果。实际处理这个调用的部件在完成后,通过状态、通知和回调来通知调用者的输入输出操作

异步IO的特点:相对同步IO,异步IO在用户进程发起异步读(aio_read)系统调用之后,无论内核缓冲区数据是否准备好,都不会阻塞当前进程;在aio_read系统调用返回后进程就可以处理其他逻辑。
socket数据在内核就绪时,系统直接把数据从内核复制到用户空间,然后再使用信号通知用户进程。两阶段时进程都足非阻塞的。

缺点:Linux对异步IO的支持不是很好,现实中很少使用。

    1. 信号驱动I/O
image-20221019154508981

首先我们允许套接口进行信号驱动 I/O,并安装一个信号处理函数,进程继续运行并不阻塞。当数据准备好时,进程会收到一个 SIGIO 信号,可以在信号处理函数中调用 I/O 操作函数处理数据。当数据报准备好读取时,内核就为该进程产生一个 SIGIO 信号。我们随后既可以在信号处理函数中调用 read 读取数据报,并通知主循环数据已准备好待处理,也可以立即通知主循环,让它来读取数据报。无论如何处理 SIGIO 信号,这种模型的优势在于等待数据报到达(第一阶段)期间,进程可以继续执行,不被阻塞。免去了 select 的阻塞与轮询,当有活跃套接字时,由注册的 handler 处理。

相比于非阻塞式 I/O 的轮询方式,信号驱动 I/O 的 CPU 利用率更高。

两次调用,两次返回;允许套接口进行信号驱动I/O,并安装一个信号处理函数,进程继续运行并不阻塞。当数据准备好时,进程会收到一个SIGIO信号,可以在信号处理函数中调用I/O操作函数处理数据。

信号驱动 I/O的特点:需要提供一个信号捕捉函数,并和socket套接字关联;发起sigaction调用之后进程就能解放去处理其他事。当数据在内核准备好后,进程会收到一个SIGIO信号,继而中断去运行信号捕捉函数,调用recvfrom把数据从内核读取到用户空间,再处理数据可以看出用户进程是不会阻塞在数据等待阶段,但数据拷贝阶段还是会阻塞等待。

缺点:信号会非常频繁,同时,信号只是一个标志位,只能告诉进程发生了事情,但具体什么
事情,发生了几次却不知道,现实中很少使用。

五大 I/O 模型比较

image-20221019163432658
  • 同步 I/O:将数据从内核缓冲区复制到应用进程缓冲区的阶段(第二阶段),应用进程会阻塞。

  • 异步 I/O:第二阶段应用进程不会阻塞。

  • 同步 I/O 包括阻塞式 I/O、非阻塞式 I/O、I/O 复用和信号驱动 I/O ,它们的主要区别在第一个阶段。

  • 非阻塞式 I/O 、信号驱动 I/O 和异步 I/O 在第一阶段不会阻塞。

epoll总结

select和poll的区别

相同点

  • select 和 poll 速度都比较慢,每次调用都需要将全部描述符从应用进程缓冲区复制到内核缓冲区。
  • 如果一个线程对某个描述符调用了 select 或者 poll,另一个线程关闭了该描述符,会导致调用结果不确定。

不同点

  • select()所使用的数据类型fd_set对于被检查的文件描述符数量有一个上限限制(FD_SETSIZE)。在Linux下,这个上限值默认为1024,修改这个上限需要重新编译应用程序。与之相反,poll()对于被检查的文件描述符数量本质上是没有限制的。
  • 由于select()的参数fd_set同时也是保存调用结果的地方,如果要在循环中重复调用select()的话,我们必须每次都要重新初始化fd_set。而poll()通过独立的两个字段events(针对输入)和revents(针对输出)来处理,从而避免每次都要重新初始化参数。
  • select()提供的超时精度(微秒)比poll()提供的超时精度(毫秒)高。(这两个系统调用的超时精度都受软件时钟粒度的限制。)
  • select用的是数组,是定长的,而poll用的是链表,可以不断扩充。
  • 几乎所有的系统都支持 select,但是只有比较新的系统支持 poll。
  • select 会修改描述符,而 poll 不会;
  • poll 提供了更多的事件类型,并且对描述符的重复利用上比 select 高。

select/poll的三个缺点:

  1. 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大。
  2. 同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大。
  3. select支持的文件描述符数量太小了,默认是1024.

epoll解决select的三个缺点:

  1. 第一个缺点:epol的解决方案在epoll_ctl函数中。每次注册新的事件到epoll句柄中时(在epoll_ctl中指定EPOLL_CTL_ADD),会把所有的fd拷贝进内核,而不是在epoll_wait的时候重复拷贝。epoll保证了每个fd在整个过程中只会拷贝一次(epoll_wait不需要复制)
  2. 第二个缺点:epoll为每个fd指定一个回调函数,当设备就绪,唤醒等待队列上的等待者时,就会调用这个回调函数,而这个回调函数会把就绪的fd加入一个就绪链表。epoll_wait的工作实际上就是在这个就绪链表中查看有没有就绪的fd(不需要遍历)。
  3. 第三个缺点:epoll没有这个限制,它所支持的fd上限是最大可以打开文件的数目,这个数字一般远大于2048,举个例子,在1GB内存的机器上大约是10万左右,一般来说这个数目和系统内存关系比较大。

epoll的高性能原因

  1. 减少内核态和用户态的复制:epoll使用了红黑树来保存需要监听的文件描述符事件,并且不需要反复在内核态和用户态中拷贝fd,epoll_ctl增别改操作快速。
  2. 免去遍历: epoll不需要遍历就能获取就绪fd,直接返回就绪链表即可。
  3. 零拷贝: linux.2.6之后使用了mmap技术,数据不在需要从内核复制到用户空间,零拷贝。

Select\poll\epoll应用场景

很容易产生一种错觉认为只要用 epoll 就可以了,select 和 poll 都已经过时了,其实它们都有各自的使用场景。

select 应用场景

注册fd频率不高,实时性要求高,监听fd的数量比较少

select 的 timeout 参数精度为微秒,而 poll 和 epoll 为毫秒,因此 select 更加适用于实时性要求比较高的场景,比如核反应堆的控制。

select 可移植性更好,几乎被所有主流平台所支持。

poll 应用场景

poll 没有最大描述符数量的限制,如果平台支持并且对实时性要求不高,应该使用 poll 而不是 select。

epoll 应用场景

只需要运行在 Linux 平台上,有大量的描述符需要同时轮询,并且这些连接最好是长连接。

需要同时监控小于 1000 个描述符,就没有必要使用 epoll,因为这个应用场景下并不能体现 epoll 的优势。

需要监控的描述符状态变化多,而且都是非常短暂的,也没有必要使用 epoll。因为 epoll 中的所有描述符都存储在内核中,造成每次需要对描述符的状态改变都需要通过 epoll_ctl() 进行系统调用,频繁系统调用降低效率。并且 epoll 的描述符存储在内核,不容易调试。

epoll 为什么用红黑树

  • 红黑树虽然定义复杂,但是它的限制条件是相对AVL宽松的。所以在进行插入删除操作的时候出现违反限制条件的状况较少,因而重平衡操作出现的机会比AVL少。基于上述原因,许多需要进行频繁删插操作的场景都使用来红黑树:
  • B+树主要应用于文件系统中,最大的原因是它高度平衡,branch factor较大,这样可以减少磁盘IO。

零拷贝

为什么要有DMA技术

在没有 DMA 技术前,I/O 的过程是这样的:

  • CPU 发出对应的指令给磁盘控制器,然后返回;
  • 磁盘控制器收到指令后,于是就开始准备数据,会把数据放入到磁盘控制器的内部缓冲区中,然后产生一个中断
  • CPU 收到中断信号后,停下手头的工作,接着把磁盘控制器的缓冲区的数据一次一个字节地读进自己的寄存器,然后再把寄存器里的数据写入到内存,而在数据传输的期间 CPU 是无法执行其他任务的。

过程如下图:

image-20230507132524269

可以看到,整个数据的传输过程,都要需要 CPU 亲自参与搬运数据的过程,而且这个过程,CPU 是不能做其他事情的。

简单的搬运几个字符数据那没问题,但是如果我们用千兆网卡或者硬盘传输大量数据的时候,都用 CPU 来搬运的话,肯定忙不过来。

计算机科学家们发现了事情的严重性后,于是就发明了 DMA 技术,也就是直接内存访问(*Direct Memory Access*) 技术。

什么是 DMA 技术?简单理解就是,在进行 I/O 设备和内存的数据传输的时候,数据搬运的工作全部交给 DMA 控制器,而 CPU 不再参与任何与数据搬运相关的事情,这样 CPU 就可以去处理别的事务

那使用 DMA 控制器进行数据传输的过程究竟是什么样的呢?下面我们来具体看看。

image-20230507132916998

具体过程:

  • 用户进程调用 read 方法,向操作系统发出 I/O 请求,请求读取数据到自己的内存缓冲区中,进程进入阻塞状态;
  • 操作系统收到请求后,进一步将 I/O 请求发送 DMA,然后让 CPU 执行其他任务;
  • DMA 进一步将 I/O 请求发送给磁盘;
  • 磁盘收到 DMA 的 I/O 请求,把数据从磁盘读取到磁盘控制器的缓冲区中,当磁盘控制器的缓冲区被读满后,向 DMA 发起中断信号,告知自己缓冲区已满;
  • DMA 收到磁盘的信号,将磁盘控制器缓冲区中的数据拷贝到内核缓冲区中,此时不占用 CPU,CPU 可以执行其他任务
  • 当 DMA 读取了足够多的数据,就会发送中断信号给 CPU;
  • CPU 收到 DMA 的信号,知道数据已经准备好,于是将数据从内核拷贝到用户空间,系统调用返回;

可以看到, CPU 不再参与「将数据从磁盘控制器缓冲区搬运到内核空间」的工作,这部分工作全程由 DMA 完成。但是 CPU 在这个过程中也是必不可少的,因为传输什么数据,从哪里传输到哪里,都需要 CPU 来告诉 DMA 控制器。

早期 DMA 只存在在主板上,如今由于 I/O 设备越来越多,数据传输的需求也不尽相同,所以每个 I/O 设备里面都有自己的 DMA 控制器。

传统的文件传输

image-20230507144352271

首先,期间共发生了 4 次用户态与内核态的上下文切换,因为发生了两次系统调用,一次是 read() ,一次是 write(),每次系统调用都得先从用户态切换到内核态,等内核完成任务后,再从内核态切换回用户态。

上下文切换到成本并不小,一次切换需要耗时几十纳秒到几微秒,虽然时间看上去很短,但是在高并发的场景下,这类时间容易被累积和放大,从而影响系统的性能。

其次,还发生了 4 次数据拷贝,其中两次是 DMA 的拷贝,另外两次则是通过 CPU 拷贝的,下面说一下这个过程:

  • 第一次拷贝,把磁盘上的数据拷贝到操作系统内核的缓冲区里,这个拷贝的过程是通过 DMA 搬运的。
  • 第二次拷贝,把内核缓冲区的数据拷贝到用户的缓冲区里,于是我们应用程序就可以使用这部分数据了,这个拷贝到过程是由 CPU 完成的。
  • 第三次拷贝,把刚才拷贝到用户的缓冲区里的数据,再拷贝到内核的 socket 的缓冲区里,这个过程依然还是由 CPU 搬运的。
  • 第四次拷贝,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程又是由 DMA 搬运的。

我们回过头看这个文件传输的过程,我们只是搬运一份数据,结果却搬运了 4 次,过多的数据拷贝无疑会消耗 CPU 资源,大大降低了系统性能。

这种简单又传统的文件传输方式,存在冗余的上文切换和数据拷贝,在高并发系统里是非常糟糕的,多了很多不必要的开销,会严重影响系统性能。

所以,要想提高文件传输的性能,就需要减少「用户态与内核态的上下文切换」和「内存拷贝」的次数

文件传输的优化

先来看看,如何减少「用户态与内核态的上下文切换」的次数呢?

读取磁盘数据的时候,之所以要发生上下文切换,这是因为用户空间没有权限操作磁盘或网卡,内核的权限最高,这些操作设备的过程都需要交由操作系统内核来完成,所以一般要通过内核去完成某些任务的时候,就需要使用操作系统提供的系统调用函数。

而一次系统调用必然会发生 2 次上下文切换:首先从用户态切换到内核态,当内核执行完任务后,再切换回用户态交由进程代码执行。

所以,要想减少上下文切换到次数,就要减少系统调用的次数

再来看看,如何减少「数据拷贝」的次数?

在前面我们知道了,传统的文件传输方式会历经 4 次数据拷贝,而且这里面,「从内核的读缓冲区拷贝到用户的缓冲区里,再从用户的缓冲区里拷贝到 socket 的缓冲区里」,这个过程是没有必要的。

因为文件传输的应用场景中,在用户空间我们并不会对数据「再加工」,所以数据实际上可以不用搬运到用户空间,因此用户的缓冲区是没有必要存在的

如何实现零拷贝

零拷贝技术实现的方式通常有 2 种:

  • mmap + write
  • sendfile

下面就谈一谈,它们是如何减少「上下文切换」和「数据拷贝」的次数。

mmap + write

在前面我们知道,read() 系统调用的过程中会把内核缓冲区的数据拷贝到用户的缓冲区里,于是为了减少这一步开销,我们可以用 mmap() 替换 read() 系统调用函数。

1
2
buf = mmap(file, len);
write(sockfd, buf, len);

mmap() 系统调用函数会直接把内核缓冲区里的数据「映射」到用户空间,这样,操作系统内核与用户空间就不需要再进行任何的数据拷贝操作。

image-20230507145312159

具体过程如下:

  • 应用进程调用了 mmap() 后,DMA 会把磁盘的数据拷贝到内核的缓冲区里。接着,应用进程跟操作系统内核「共享」这个缓冲区;
  • 应用进程再调用 write(),操作系统直接将内核缓冲区的数据拷贝到 socket 缓冲区中,这一切都发生在内核态,由 CPU 来搬运数据;
  • 最后,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程是由 DMA 搬运的。

我们可以得知,通过使用 mmap() 来代替 read(), 可以减少一次数据拷贝的过程。

但这还不是最理想的零拷贝,因为仍然需要通过 CPU 把内核缓冲区的数据拷贝到 socket 缓冲区里,而且仍然需要 4 次上下文切换,因为系统调用还是 2 次。

sendfile

在 Linux 内核版本 2.1 中,提供了一个专门发送文件的系统调用函数 sendfile(),函数形式如下:

1
2
#include <sys/socket.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

它的前两个参数分别是目的端和源端的文件描述符,后面两个参数是源端的偏移量和复制数据的长度,返回值是实际复制数据的长度。

首先,它可以替代前面的 read()write() 这两个系统调用,这样就可以减少一次系统调用,也就减少了 2 次上下文切换的开销。

其次,该系统调用,可以直接把内核缓冲区里的数据拷贝到 socket 缓冲区里,不再拷贝到用户态,这样就只有 2 次上下文切换,和 3 次数据拷贝。如下图:

image-20230507150333144

但是这还不是真正的零拷贝技术,如果网卡支持 SG-DMA(The Scatter-Gather Direct Memory Access)技术(和普通的 DMA 有所不同),我们可以进一步减少通过 CPU 把内核缓冲区里的数据拷贝到 socket 缓冲区的过程。

你可以在你的 Linux 系统通过下面这个命令,查看网卡是否支持 scatter-gather 特性:

1
2
$ ethtool -k eth0 | grep scatter-gather
scatter-gather: on

于是,从 Linux 内核 2.4 版本开始起,对于支持网卡支持 SG-DMA 技术的情况下, sendfile() 系统调用的过程发生了点变化,具体过程如下:

  • 第一步,通过 DMA 将磁盘上的数据拷贝到内核缓冲区里;
  • 第二步,缓冲区描述符和数据长度传到 socket 缓冲区,这样网卡的 SG-DMA 控制器就可以直接将内核缓存中的数据拷贝到网卡的缓冲区里,此过程不需要将数据从操作系统内核缓冲区拷贝到 socket 缓冲区中,这样就减少了一次数据拷贝;

所以,这个过程之中,只进行了 2 次数据拷贝,如下图:

image-20230507150347408

这就是所谓的零拷贝(*Zero-copy*)技术,因为我们没有在内存层面去拷贝数据,也就是说全程没有通过 CPU 来搬运数据,所有的数据都是通过 DMA 来进行传输的。

零拷贝技术的文件传输方式相比传统文件传输的方式,减少了 2 次上下文切换和数据拷贝次数,只需要 2 次上下文切换和数据拷贝次数,就可以完成文件的传输,而且 2 次的数据拷贝过程,都不需要通过 CPU,2 次都是由 DMA 来搬运。

所以,总体来看,零拷贝技术可以把文件传输的性能提高至少一倍以上

PageCache 有什么作用

回顾前面说道文件传输过程,其中第一步都是先需要先把磁盘文件数据拷贝「内核缓冲区」里,这个「内核缓冲区」实际上是磁盘高速缓存(*PageCache*)

由于零拷贝使用了 PageCache 技术,可以使得零拷贝进一步提升了性能,我们接下来看看 PageCache 是如何做到这一点的。

读写磁盘相比读写内存的速度慢太多了,所以我们应该想办法把「读写磁盘」替换成「读写内存」。于是,我们会通过 DMA 把磁盘里的数据搬运到内存里,这样就可以用读内存替换读磁盘。

但是,内存空间远比磁盘要小,内存注定只能拷贝磁盘里的一小部分数据。

那问题来了,选择哪些磁盘数据拷贝到内存呢?

我们都知道程序运行的时候,具有「局部性」,所以通常,刚被访问的数据在短时间内再次被访问的概率很高,于是我们可以用 PageCache 来缓存最近被访问的数据,当空间不足时淘汰最久未被访问的缓存。

所以,读磁盘数据的时候,优先在 PageCache 找,如果数据存在则可以直接返回;如果没有,则从磁盘中读取,然后缓存 PageCache 中。

还有一点,读取磁盘数据的时候,需要找到数据所在的位置,但是对于机械磁盘来说,就是通过磁头旋转到数据所在的扇区,再开始「顺序」读取数据,但是旋转磁头这个物理动作是非常耗时的,为了降低它的影响,PageCache 使用了「预读功能」

比如,假设 read 方法每次只会读 32 KB 的字节,虽然 read 刚开始只会读 0 ~ 32 KB 的字节,但内核会把其后面的 32~64 KB 也读取到 PageCache,这样后面读取 32~64 KB 的成本就很低,如果在 32~64 KB 淘汰出 PageCache 前,进程读取到它了,收益就非常大。

所以,PageCache 的优点主要是两个:

  • 缓存最近被访问的数据;
  • 预读功能;

这两个做法,将大大提高读写磁盘的性能。

但是,在传输大文件(GB 级别的文件)的时候,PageCache 会不起作用,那就白白浪费 DMA 多做的一次数据拷贝,造成性能的降低,即使使用了 PageCache 的零拷贝也会损失性能

这是因为如果你有很多 GB 级别文件需要传输,每当用户访问这些大文件的时候,内核就会把它们载入 PageCache 中,于是 PageCache 空间很快被这些大文件占满。

另外,由于文件太大,可能某些部分的文件数据被再次访问的概率比较低,这样就会带来 2 个问题:

  • PageCache 由于长时间被大文件占据,其他「热点」的小文件可能就无法充分使用到 PageCache,于是这样磁盘读写的性能就会下降了;
  • PageCache 中的大文件数据,由于没有享受到缓存带来的好处,但却耗费 DMA 多拷贝到 PageCache 一次;

所以,针对大文件的传输,不应该使用 PageCache,也就是说不应该使用零拷贝技术,因为可能由于 PageCache 被大文件占据,而导致「热点」小文件无法利用到 PageCache,这样在高并发的环境下,会带来严重的性能问题。

大文件传输的实现方式

那针对大文件的传输,我们应该使用什么方式呢?

我们先来看看最初的例子,当调用 read 方法读取文件时,进程实际上会阻塞在 read 方法调用,因为要等待磁盘数据的返回,如下图:

image-20230507150847123

具体过程:

  • 当调用 read 方法时,会阻塞着,此时内核会向磁盘发起 I/O 请求,磁盘收到请求后,便会寻址,当磁盘数据准备好后,就会向内核发起 I/O 中断,告知内核磁盘数据已经准备好;
  • 内核收到 I/O 中断后,就将数据从磁盘控制器缓冲区拷贝到 PageCache 里;
  • 最后,内核再把 PageCache 中的数据拷贝到用户缓冲区,于是 read 调用就正常返回了。

对于阻塞的问题,可以用异步 I/O 来解决,它工作方式如下图:

image-20230507150907138

它把读操作分为两部分:

  • 前半部分,内核向磁盘发起读请求,但是可以不等待数据就位就可以返回,于是进程此时可以处理其他任务;
  • 后半部分,当内核将磁盘中的数据拷贝到进程缓冲区后,进程将接收到内核的通知,再去处理数据;

而且,我们可以发现,异步 I/O 并没有涉及到 PageCache,所以使用异步 I/O 就意味着要绕开 PageCache。

绕开 PageCache 的 I/O 叫直接 I/O,使用 PageCache 的 I/O 则叫缓存 I/O。通常,对于磁盘,异步 I/O 只支持直接 I/O。

前面也提到,大文件的传输不应该使用 PageCache,因为可能由于 PageCache 被大文件占据,而导致「热点」小文件无法利用到 PageCache。

于是,在高并发的场景下,针对大文件的传输的方式,应该使用「异步 I/O + 直接 I/O」来替代零拷贝技术

直接 I/O 应用场景常见的两种:

  • 应用程序已经实现了磁盘数据的缓存,那么可以不需要 PageCache 再次缓存,减少额外的性能损耗。在 MySQL 数据库中,可以通过参数设置开启直接 I/O,默认是不开启;
  • 传输大文件的时候,由于大文件难以命中 PageCache 缓存,而且会占满 PageCache 导致「热点」文件无法充分利用缓存,从而增大了性能开销,因此,这时应该使用直接 I/O。

另外,由于直接 I/O 绕过了 PageCache,就无法享受内核的这两点的优化:

  • 内核的 I/O 调度算法会缓存尽可能多的 I/O 请求在 PageCache 中,最后「合并」成一个更大的 I/O 请求再发给磁盘,这样做是为了减少磁盘的寻址操作;
  • 内核也会「预读」后续的 I/O 请求放在 PageCache 中,一样是为了减少对磁盘的操作;

于是,传输大文件的时候,使用「异步 I/O + 直接 I/O」了,就可以无阻塞地读取文件了。

所以,传输文件的时候,我们要根据文件的大小来使用不同的方式:

  • 传输大文件的时候,使用「异步 I/O + 直接 I/O」;
  • 传输小文件的时候,则使用「零拷贝技术」;

在 nginx 中,我们可以用如下配置,来根据文件的大小来使用不同的方式:

1
2
3
4
5
location /video/ { 
sendfile on;
aio on;
directio 1024m;
}

当文件大小大于 directio 值后,使用「异步 I/O + 直接 I/O」,否则使用「零拷贝技术」。

刘小恺(Kyle) wechat
如有疑问可联系博主