网络I/O模型 解读
网络、内核
网卡能「接收所有在网络上传输的信号」,但正常情况下只接受发送到该电脑的帧和广播帧,将其余的帧丢弃。
所以网络 I/O 其实是网络与服务端(电脑内存)之间的输入与输出
内核
查看内核版本 : uname -r
查看可升级的版本: yum list kernel --showduplicates
升级内核: yum update kernel-3.10.0-1160.80.1.el7
内核的任务
- 用于应用程序执行的流程管理。
- 内存和I / O(输入/输出)管理。
- 系统调用控制(内核的核心行为)。
- 借助设备驱动程序进行设备管理
Cpu指令集
在说用户态与内核态之前,有必要说一下 C P U 指令集,指令集是 C P U 实现软件指挥硬件执行的媒介,具体来说每一条汇编语句都对应了一条 C P U 指令,而非常非常多的 C P U 指令 在一起,可以组成一个、甚至多个集合,指令的集合叫 C P U 指令集。 同时 C P U 指令集 有权限分级,大家试想,C P U 指令集 可以直接操作硬件的,要是因为指令操作的不规范`,造成的错误会影响整个计算机系统的。好比你写程序,因为对硬件操作不熟悉,导致操作系统内核、及其他所有正在运行的程序,都可能会因为操作失误而受到不可挽回的错误,最后只能重启计算机才行。 而对于硬件的操作是非常复杂的,参数众多,出问题的几率相当大,必须谨慎的进行操作,对开发人员来说是个艰巨的任务,还会增加负担,同时开发人员在这方面也不被信任,所以操作系统内核直接屏蔽开发人员对硬件操作的可能,都不让你碰到这些 C P U 指令集。
用户态/内核态
- 用户态、内核态的指令都是 CPU 都在执行,所以我们可以换个说法,实际上这个态代表的是当前 CPU 的
状态。那既然这些指令最终都由 CPU 执行,那对其区分的理由是什么呢?
那是因为,CPU 指令根据其重要的程度,也分为不同的权限。有一些指令执行失败了无关痛痒,而有一些>指令失败了会导致整个操作系统崩溃,甚至需要重启系统。如果将这些指令随意开放给应用程序的话,整>个系统崩溃的概率将会大大的增加。ring 0被叫做内核态,完全在操作系统内核中运行。ring 3被叫做用户态,在应用程序中运行。 用户态和内>核态是操作系统的两种运行级别,两者最大的区别就是特权级不同。用户态拥有最低的特权级,内核态拥>有较高的特权级。运行在用户态的程序不能直接访问操作系统内核数据结构和程序。
内核态和用户态之间的转换方式主要包括:系统调用,异常和中断。
系统调用
这是用户态进程主动要求切换到内核态的一种方式,用户态进程通过系统调用申请使用操作系统>提供的服务程序完成工作,比如fork()实际上就是执行了一个创建新进程的系统调用。而系统调用的机制其>核心还是使用了操作系统为用户特别开放的一个中断来实现,例如Linux的int 80h中断。
- 异常
当CPU在执行运行在用户态下的程序时,发生了某些事先不可知的异常,这时会触发由当前运行进程切换到处理此异常的内核相关程序中,也就转到了内核态,比如缺页异常。
- 外围设备中断
当外围设备完成用户请求的操作后,会向CPU发出相应的中断信号,这时CPU会暂停执行下>一条即
将要执行的指令转而去执行与中断信号对应的处理程序,如果先前执行的指令是用户态下的程序,
那么这个转换的过程自然也就发生了由用户态到内核态的切换。比如硬盘读写操作完成,系统会切换到硬>盘读写* 处理程序中执行后续操作等。
- 这3种方式是系统在运行时由用户态转到内核态的最主要方式,其中系统调用可以认为是用户进程主动发起的,异常和外围设备中断则是被动的。
- 相信大家都听过这样的话「用户态和内核态切换的开销大」,但是它的开销大在那里呢?简单点来说有下面几点
- 保留用户态现场(上下文、寄存器、用户栈等)
- 复制用户态参数
- 用户栈切到内核栈
- 进入内核态额外的检查(因为内核代码对用户不信任)
- 执行内核态代码 复制内核态代码执行结果
- 回到用户态 恢复用户
现场(上下文、寄存器、用户栈等)
综上所述,减少用户态内核态的切换就系统性能调优的主要手段。
文件描述符FD
Linux 系统中,把一切都看做是文件,当进程打开现有文件或创建新文件时,内核向进程返回一个文件描述符,文件描述符就是内核为了高效管理已被打开的文件所创建的索引,用来指向被打开的文件,所有执行I/O操作的系统调用都会通过文件描述符。
man
yum install -y man-pages
man 命令 查看linux 系统使用手册的工具
网络
什么是网络IO
一般情况下,在软件中我们常说的 I/O 是指「网络 I/O 和磁盘 I/O」,今天我们就来聊下网络 I/O
网络 I/O 就是网络中的输入与输出,我们再说详细点,正常的网络通信中,一条消息发送的过程中有一个很重要的媒介,叫做「网卡」,它的作用有两个
- 一是将电脑的数据封装为帧,并通过网线(对无线网络来说就是电磁波)将数据发送到网络上去
- 二是接收网络上其它设备传过来的帧,并将帧重新组合成数据,发送到所在的电脑中。
TCP/IP
- server 创建监听 socket 后,执行 bind() 绑定 IP 和端口,然后调用 listen() 监听,代表 server 已经准备好接收请求了,listen 的主要作用其实是初始化半连接和全连接队列大小
- server 准备好后,client 也创建 socket ,然后执行 connect 向 server 发起连接请求,这一步会被阻塞,需要等待三次握手完成,第一次握手完成,服务端会创建 socket(这个 socket 是连接 socket,注意不要和第一步的监听 socket 搞混了),将其放入半连接队列中,第三次握手完成,系统会把 socket 从半连接队列摘下放入全连接队列中,然后 accept 会将其从全连接队列中摘下,之后此 socket 就可以与客户端 socket 正常通信了,默认情况下如果全连接队列里没有 socket,则 accept 会阻塞等待三次握手完成
Socket
I/O 模型简介
一般情况下,一次网络数据的传输会从客户端发送给服务端,由服务端网卡接受,转交给内存,最后由 cpu 执行相应的业务操作,只要有一点电脑知识的读者大多数都知道,cpu、显卡、内存等电脑中你数得上名字的模块,运行效率最高的就是 cpu 了,所以「为了整个网络传输的提效,就诞生出了五种网络 I/O 模型」
- 阻塞式I/O模型
- 非阻塞式I/O模型
- I/O多路复用模型
- 信号驱动式I/O模型
- 异步I/O模型
阻塞式I/O模型
说明
Linux中,默认情况下所有的socket都是阻塞的。这里有必要辨析以下阻塞和非阻塞这两个概念,这两个概念描述的是用户线程调用内核I/O操作的方式,其中阻塞是指I/O操作需要彻底完成后才返回到用户空间;而非阻塞则是指I/O操作被调用后立即返回给用户一个状态值,不需要等到I/O操作彻底完成。
问题
除非特别指定,几乎所有的I/O接口都是阻塞型的,即系统调用时不返回调用结果,只有当该系统调用获得结果或者超时出错才返回。这样的机制给网络编程带来了较大的影响,当线程因处理数据而处于阻塞状态时,线程将无法执行任何运算或者相应任何网络请求。
改进方案
在服务器端使用阻塞I/O模型时结合多进程/多线程技术。让每一个连接都拥有独立的进程/线程,任何一个连接的阻塞都不会影响到其他连接。(选择多进程还是多线程并无统一标准,因为进程的开销远大于线程,所以在连接数较大的情况下推荐使用多线程。而进程相较于线程具有更高的安全性,所以如果单个服务执行体需要消耗较多的CPU资源,如需要进行大规模或长时间的数据运算或文件访问推荐使用多进程)。
当连接数规模继续增大,无论使用多线程还是多进程都会严重占据系统资源,降低系统对外界的响应效率,线程或者进程本身也更容易陷入假死。此时可以采用“线程池”或“连接池”来降低创建和销毁进程/线程的频率,减少系统开销。
非阻塞式I/O模型
I/O多路复用模型
I/O多路复用(也叫做事件驱动I/O)通过系统调用select()、poll、或者epoll()实现进程同时检查多个文件描述符,以找出其中任何一个是否可执行I/O操作。通过上图可以看出I/O多路复用与阻塞I/O模型差别并不大,事实上还要差一些,因为这里使用了两个系统调用而阻塞I/O只是用了一个系统调用。但是I/O多路复用的优势是其可以同时处理多个连接。因此如果处理的连接数不是特别多的情况下使用I/O多路复用模型的web server不一定比使用多线程技术的阻塞I/O模型好。
select & poll
select()和poll()的原理基本相同:
- 注册待侦听的fd(这里的fd创建时最好使用非阻塞)
- 每次调用都去检查这些fd的状态,当有一个或者多个fd就绪的时候返回
- 返回结果中包括已就绪和未就绪的fd
select
int select(int nfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds, struct timeval *timeout);
nfds 指集合中所有文件描述符的范围,即所有文件描述符的最大值+1
readfds、writefds、errorfds 指向文件描述符集合的指针,分别检测输入、输出是否就绪和异常情况是否发生
readfds、writefds、errorfds所指结构体都是保存结果的地方,在调用select()之前,这些参数指向的结构体必须初始化以包含我们所感兴趣的文件描述符集合。之后select()会修改这些结构体,当其返回时他们包含的就是处于就绪态的文件描述符集合。
timeout 时select()的超时时间,控制着select()的阻塞行为
当timeout设为NULL或者其指向的结构体字段非零时,select()将阻塞到有下列事件发生
- readfds、writefds、errorfds 中指定的文件描述符中至少有一个成为就绪态(NULL)
- 该调用被信号处理程序中断
- timeout中指定的时间上限已超时
select()的返回值
当select()函数返回-1表示出错,错误码包括EBADF表示存在非法文件描述符,EINTR表示该调用被信号处理程序中断了(select不会自动恢复)。返回0表示超时,此时每个文件描述符集合都会被清空。返回一个正整数表示准备就绪的文件描述符个数,如果同一个文件描述符在返回的描述符集中出现多次,select会将其统计多次。
一个文件描述符是否阻塞并不影响select()是否阻塞,也就是说如果希望读一个非阻塞文件描述符,并且以5s为超时值调用select(),则select()最多阻塞5s。同理若是指定超时值为NULL,则在该描述符就绪或者捕捉到一个信号之前select()会一直阻塞。
所有关于文件描述符集合的操作都是通过以下四个宏完成,除此之外,常量FD_SETSIZE规定了文件描述符的最大容量。
void FD_ZERO(fd_set *fdset); //将fdset所指集合初始化为空
void FD_SET(int fd, fd_set *fdset); //将文件描述符fd添加到由fdset指向的集合中
void FD_CLR(int fd, fd_set *fdset); //将文件描述符fd从fdset所指集合中移出
void FD_ISSET(int fd, fd_set *fdset); //检测fd是否是fdset所指集合成员
select:效率低,性能不太好。不能解决大量并发请求的问题。
它把1000个fd加入到fd_set(文件描述符集合),通过select监控fd_set里的fd是否有变化。如果有一个fd满足读写事件,就会依次查看每个文件描述符,那些发生变化的描述符在fd_set对应位设为1,表示socket可读或者可写。
Select通过轮询的方式监听,对监听的FD数量 t通过FD_SETSIZE限制。
两个问题:
1、select初始化时,要告诉内核,关注1000个fd, 每次初始化都需要重新关注1000个fd。前期准备阶段长。
2、select返回之后,要扫描1000个fd。 后期扫描维护成本大,CPU开销大。
poll
int poll(struct c *fds, nfds_t nfds, int timeout);
poll和select的任务很相似,主要区别在于我们如何指定待检查的文件描述符(程序接口不同)。poll不为每个条件构造一个描述符集合,而是构造了一个pollfd结构的数组,每个数组元素指定一个描述符编号以及我们对该描述符感兴趣的条件。
struct pollfd {
int fd; //文件描述符
short events; //等待的事件
short revents; //实际发生了的事件
}
pollfd
每个pollfd结构体指定了一个被监视的文件描述符,可以传递多个结构体,指示poll()监视多个文件描述符。每个结构体的events域是监视该文件描述符的事件掩码,由用户来设置这个域的属性。revents域是文件描述符的操作结果事件掩码,内核在调用返回时设置这个域,并且events中请求的任何事件都可能在revents中返回。
timeout
参数timeout的设置与select()中有所不同(poll的timeout参数是一个整型而select是一个结构体)。
- 当timeout等于-1时,表示无限超时。poll会一直阻塞到fds数组中列出的文件描述符有一个达到就绪态(定义在对应的events字段中)或者捕捉到一个信号
- 当timeout等于0时,poll不会阻塞——只执行一次检查看看哪个文件描述符已经就绪
- 当timeout大于0时,poll至多阻塞timeout毫秒数,无论IO是否准备好,poll都会返回
poll的返回值
当poll()函数返回-1表示出错,错误码包括EBADF表示存在非法文件描述符,EINTR表示该调用被信号处理程序中断了(poll不会自动恢复)。返回0表示超时。返回一个正整数表示准备就绪的文件描述符个数,与select不同,poll返回的就是就绪文件描述符的个数每个文件描述符只统计一次。
select()和poll()的区别
Linux实现层面
select()和poll()都使用了相同的内核轮询(poll)程序集合,与系统调用poll()本身不同,内核的每个poll例程都返回有关单个文件描述符就绪的信息,这个信息以位掩码的形式返回,其值同poll()系统调用返回的revent字段中的比特值相关。poll()系统调用的实现包括为每个文件描述符调用内核poll例程,并将结果信息填入到对应的revents字段中。对于系统调用select()则可以使用一组宏将内核poll例程返回的信息转化为由select()返回的与之对应的事件集合。
#define POLLIN_SET (POLLIN | POLLRDNORM | POLLRDBAND | POLLHUP | POLLERR) /*读就绪*/ #define POLLOUT_SET (POLLOUT | POLLWRNORM | POLLWRBAND | POLLERR) /*写就绪*/ #define POLLEX_SET (POLLPRI) /*异常*/
以上宏定义展现了select()和poll()返回信息间的语义关系,唯一一点不同是如果被检查的文件描述符中有一个关闭了,poll()在revent字段中返回POLLNVAL,而select()返回-1并把错误码置为EBADF。
API设计层面
- select()使用的数据类型fd_set对于被检查的文件描述数量有一个上限(FD_SETSIZE)。相对也较小(1024/2048),如果要修改这个默认值需要重新编译内核。与之相反,poll()没有对于被检查文件描述符的数量限制。
- 由于select()的参数fd_set同时也是保存结果的地方,在select()返回之后会发生变化,所以每当在下一次进入select()之前需要重新初始化fd_set。poll()通过两个独立的字段events和revents将监控的输入输出分开,允许被监控的文件数组被复用而不需要重新初始化。
- select()提供的超时精度(微妙)比poll()提供的超时精度(毫秒)高。
- select()的超时参数在返回时也是未定义的,考虑到可移植性,每次在超时之后在下一次进入到select()之前都需要重新设置超时参数。
- poll()不要求开发者计算最大文件描述符时进行+1操作
性能层面
在待检查文件描述符范围较小(最大文件描述符较低),或者有大量文件描述符待检查,但是其分布比较密集时poll()和select()性能相似。
在被检查文件描述符集合很稀疏的情况,poll()要优于select()。
select()和poll()的不足
- IO效率随着文件描述符的数量增加而线性下降。每次调用select()或poll()内核都要检查所有的被指定的文件描述符的状态(但是实际上只有部分的文件描述符会是活跃的),当有文件描述符集合增大时,IO的效率也随之下降。
- 当检查大量文件描述符时,用户空间和内核空间消息传递速度较慢。每次调用select()或poll()时,程序都必须传递一个表示所有需要被检查的文件描述符的数据结构到内核,在内核完成检查之后,修个这个数据结构并返回给程序。(此外select()每次调用之前还需要初始化该数据结构)对于poll()调用需要将用户传入的pollfd数组拷贝到内核空间,这是一个O(n)的操作。当事件发生后,poll()将获得的数据传送到用户空间,并执行释放内存和剥离等待队列等工作同样是O(n)的操作。因此随着文件描述符的增加消息传递速度会逐步下降。对于select()来说,传递的数据结构大小固定为FD_SETSIZE,与待检查的文件描述符数量无关。
- select()或poll()调用完成之后,程序必须检查返回的数据结构中每个元素,已确定那个文件描述符处于就绪态
- select()对一个进程打开的文件描述符数目有上限值,而且较少(1024/2048)。
epoll
中断
中断 是为了解决外部设备完成某些工作后通知CPU的一种机制(譬如硬盘完成读写操作后通过中断告知CPU已经完成)。早期没有中断机制的计算机就不得不通过轮询来查询外部设备的状态,由于轮询是试探查询的(也就是说设备不一定是就绪状态),所以往往要做很多无用的查询,从而导致效率非常低下。由于中断是由外部设备主动通知CPU的,所以不需要CPU进行轮询去查询,效率大大提升。
从物理学的角度看,中断是一种电信号,由硬件设备产生,并直接送入中断控制器(如 8259A)的输入引脚上,然后再由中断控制器向处理器发送相应的信号。处理器一经检测到该信号,便中断自己当前正在处理的工作,转而去处理中断。此后,处理器会通知 OS 已经产生中断。这样,OS 就可以对这个中断进行适当的处理。不同的设备对应的中断不同,而每个中断都通过一个唯一的数字标识,这些值通常被称为中断请求线。
epoll 的实现原理
epoll API是Linux专有的特性,相较于select和poll,epoll更加灵活且没有描述符限制。epoll设计也与select和poll不同,主要包含以下三个接口:epoll_create、epoll_ctl、epoll_wait。
-
epoll_create
int epoll_create(int size);
参数size指定内核需要监听的文件描述符个数,但该参数与select中的maxfdp不同,并非一个上限(Linux 2.6.8以后该参数被忽略不用)。此外函数返回代表新创建的epoll句柄的文件描述符(在Linux下查看/proc/进程的id/fd/可看到该fd的值),因此当不再使用该文件描述符时应该通过close()关闭,当所用与epoll句柄相关的文件描述符都关闭时,该句柄被销毁并被系统回收其资源。
-
epoll_ctl
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *ev)
与select()的在监听事件时告诉内核需要监听的事件类型不同,epoll()需要先注册要监听的事件类型。参数op表示要执行的动作通过三个宏表示:
-
EPOLL_CTL_ADD注册新的fd到epfd中;
-
EPOLL_CTL_MOD修改已经注册的fd的监听事件;
-
EPOLL_CTL_DEL从epfd中删除一个fd。参数fd表示需要监听的fd。
-
最后一个参数ev指向结构体epoll_event则是告诉内核需要监听的事件类型,定义如下:
struct epoll_event { uint32_t events; //epoll events (bit mask) epoll_data_t data; //user data variable }
其中data的类型为:
typedef union epoll_data { void *ptr; //pointer to user defined data int fd; //file descriptor uint_32 u32; //32-bit integer uint_64 u64; //64-bit integer } epoll_data_t;
其中字段event表示事件掩码指定待监听的文件描述符fd上所感兴趣的事件集合,除了增加了一个前缀E外,这些掩码的名称与poll中对应名称相同(两个例外EPOLLET表示设置为边缘触发、EPOLLONESHOT表示只监听一次)。data字段是一个联合体,当描述符fd就绪后,联合体成员可以用来指定传回给调用进程的信息。data字段是唯一可以获知同这个事件相关的文件描述符的途径,因此调用epoll_ctl()将文件描述符添加到兴趣列表中时,应该要么将ev.data.fd设为文件描述符,要么将ev.data.ptr设为指向包含该文件描述的结构体。
-
-
epoll_wait
int epoll_wait(int epfd, struct epoll_event *evlist, int maxevents, int timeout);
等待事件的产生,参数evlist所指向的结构体数组中返回就需文件描述的信息,数组evlist的空间由调用者负责申请,所包含的元素个数由参数maxevents指定。
原理
在linux,一切皆文件.所以当调用epoll_create时,内核给这个epoll分配一个文件描述符,但是这个不是普通的文件,而是只服务于epoll.
所以当内核初始化epoll时,会开辟一块内核高速cache区,用于安置我们监听的socket,这些socket会以红黑树的形式保存在内核的cache里,以支持快速的查找,插入,删除.同时,建立了一个list链表,用于存储准备就绪的事件.所以调用epoll_wait时,在timeout时间内,只是简单的观察这个list链表是否有数据,如果没有,则睡眠至超时时间到返回;如果有数据,则在超时时间到,拷贝至用户态events数组中.
那么,这个准备就绪list链表是怎么维护的呢?
当我们执行epoll_ctl()时,除了把socket放到epoll文件系统里file对象对应的红黑树上之外,还会给内核中断处理程序注册一个回调函数,告诉内核,如果这个句柄的中断到了,就把它放到准备就绪list链表里。所以,当一个socket上有数据到了,内核在把网卡上的数据copy到内核中后就来把socket插入到准备就绪链表里了。epoll支持两种模式LT(水平触发)和ET(边缘触发),LT模式下,主要缓冲区数据一次没有处理完,那么下次epoll_wait返回时,还会返回这个句柄;而ET模式下,缓冲区数据一次没处理结束,那么下次是不会再通知了,只在第一次返回.所以在ET模式下,一般是通过while循环,一次性读完全部数据.epoll默认使用的是LT.
这件事怎么做到的呢?当一个socket句柄上有事件时,内核会把该句柄插入上面所说的准备就绪list链表,这时我们调用epoll_wait,会把准备就绪的socket拷贝到用户态内存,然后清空准备就绪list链表,最后,epoll_wait干了件事,就是检查这些socket,如果不是ET模式(就是LT模式的句柄了),并且这些socket上确实有未处理的事件时,又把该句柄放回到刚刚清空的准备就绪链表了。所以,非ET的句柄,只要它上面还有事件,epoll_wait每次都会返回。而ET模式的句柄,除非有新中断到,即使socket上的事件没有处理完,也是不会次次从epoll_wait返回的.
经常看到比较ET和LT模式到底哪个效率高的问题.有一个回答是说ET模式下减少epoll系统调用.这话没错,也可以理解,但是在ET模式下,为了避免数据饿死问题,用户态必须用一个循环,将所有的数据一次性处理结束.所以在ET模式下下,虽然epoll系统调用减少了,但是用户态的逻辑复杂了,write/read调用增多了.所以这不好判断,要看用户的性能瓶颈在哪.
epoll 的设计特点
-
功能分离
socket低效的原因之一便是将“维护等待队列”和“阻塞进程”两个功能不加分离,每次调用 select 都需要这两步操作,然而大多数应用场景中,需要监视的 socket 相对固定,并不需要每次都修改。epoll 将这两个操作分开,先用epoll_ctl()维护等待队列,再调用 epoll_wait 阻塞进程。显而易见地,效率就能得到提升。
而epoll则是实现了功能分离,通过epoll_create()创建一个 epoll 对象 epfd,再通过epoll_ctl()将需要监视的 socket 添加到 epfd 中,最后调用 epoll_wait() 等待数据使得epoll有了优化的可能。
-
就绪列表
select 低效的另一个原因在于程序不知道哪些 socket 收到数据,只能一个个遍历。如果内核维护一个“就绪列表”,引用收到数据的 socket,就能避免遍历。如下图所示,计算机共有三个 socket,收到数据的 sock2 和 sock3 被就绪列表 rdlist 所引用。当进程被唤醒后,只要获取 rdlist 的内容,就能够知道哪些 socket 收到数据。
epoll 的优点
- 没有最大打开文件描述符限制
epoll支持的最大打开文件数与系统内存相关,可通
过cat /proc/sys/fs/file-max查看具体数目- IO效率不随文件描述符数目增加而线性下降
传统的select/poll在拥有较大的一个socket集合时,不过由于网络延迟,任意时间只有部分socket是活跃的,但是select/poll每次调用都会线性扫描全部的集合,导致效率呈线性下降。而epoll通过在内核中实现的根据每个文件描述符上的回调函数callback函数实现了每次只对“活跃的”的socket进行操作,从而使epoll实现了一个伪AIO,使其效率不会随文件描述符的增加而先行下降。- 使用mmap加速内核与用户空间的消息传递
select、poll和epoll都需要内核把fd消息通知给用户空间,但是epoll采用了内核与用户空间mmap处于同一块内存来实现,具有较高的效率。
总结
Tips
这张图就代表了传统 IO 传输文件的流程。读取文件的时候,会从用户态切换为内核态,同时基于 DMA 引擎将磁盘文件拷贝到内核缓冲区。
DMA(DirectMemoryAccess,直接内存存取)其实就是因为 CPU 老哥太累了,所以找了个小弟,就是 DMA 替他完成一部分的拷贝工作,这样 CPU 就能去做其他事情了。
第一步我们将文件从磁盘文件读到了用户缓冲区,此时经历了一次上下文切换和一次拷贝。
由内核态切换为用户态,基于 CPU 把内核缓冲区的数据拷贝到用户缓冲区。
调用 socket 的输出流的 write 方法的话,此时会从用户态切换到内核态,同时基于 CPU 把用户缓冲区里的数据拷贝到 Socket 缓冲区里去,接着会有一个异步化的过程,基于 DMA 引擎从 Socket 缓冲区里把数据拷贝到网络协议引擎里发送出去。
当 IO 操作完成之后,又从内核态切换为用户态。通过上面的步骤可以发现传统的 IO 操作执行,有 4 次上下文的切换和 4 次拷贝,是不是很繁琐。零拷贝的话,一般有 mmap 和 sendFile 两种,一个一个来说。
mmap
mmap 是一种内存映射技术,mmap 相比于传统的 IO 来说,其实就是少了 1 次 CPU 拷贝而已。
传统 IO 里面从内核缓冲区到用户缓冲区有一次 CPU 拷贝,从用户缓冲区到 Socket 缓冲区又有一次 CPU 拷贝。mmap 则一步到位,直接基于 CPU 将内核缓冲区的数据拷贝到了 Socket 缓冲区。
之所以能够减少一次拷贝,就是因为 mmap 直接将磁盘文件数据映射到内核缓冲区,这个映射的过程是基于 DMA 拷贝的,同时用户缓冲区是跟内核缓冲区共享一块映射数据的,建立共享映射之后,就不需要从内核缓冲区拷贝到用户缓冲区了。
虽然减少了一次拷贝,但是上下文切换的次数还是没变。
RocketMQ 中就是使用的 mmap 来提升磁盘文件的读写性能。
sendFile
在 Linux 中,提供 sendFile 函数,实现了零拷贝。
可以看到在图中,已经没有了用户缓冲区,因为用户缓冲区是在用户空间的,所以没有了用户缓冲区也就意味着不需要上下文切换了,就省略了这一步的从内核态切换为用户态。
同时也不需要基于 CPU 将内核缓冲区的数据拷贝到 Socket 缓冲区了,只需要从内核缓冲区拷贝一些 offset 和 length 到 Socket 缓冲区。接着从内核态切换到用户态,从内核缓冲区直接把数据拷贝到网络协议引擎里去;同时从 Socket 缓冲区里拷贝一些 offset 和 length 到网络协议引擎里去,但是这个 offset 和 length 的量很少,几乎可以忽略。
sendFile 整个过程只有两次上下文切换和两次 DMA 拷贝,很重要的一点是这里完全不需要 CPU 来进行拷贝了,所以才叫做零拷贝,这里的拷贝指的就是操作系统的层面。
那你肯定会问,那 mmap 里面有一次 CPU 拷贝为啥也算零拷贝,只能说那不算是严格意义上的零拷贝,但是他确实是优化了普通 IO 的执行流程,就像老婆饼里也没有老婆嘛。Kafka 和 Tomcat 内部使用就是 sendFile 这种零拷贝。
总结
传统 IO 执行的话需要 4 次上下文切换(用户态 -> 内核态 -> 用户态 -> 内核态 -> 用户态)和 4 次拷贝(磁盘文件 DMA 拷贝到内核缓冲区,内核缓冲区 CPU 拷贝到用户缓冲区,用户缓冲区 CPU 拷贝到 Socket 缓冲区,Socket 缓冲区 DMA 拷贝到协议引擎)。
mmap 将磁盘文件映射到内存,支持读和写,对内存的操作会反映在磁盘文件上,适合小数据量读写,需要 4 次上下文切换(用户态 -> 内核态 -> 用户态 -> 内核态 -> 用户态)和 3 次拷贝(磁盘文件 DMA 拷贝到内核缓冲区,内核缓冲区 CPU 拷贝到 Socket 缓冲区,Socket 缓冲区 DMA 拷贝到协议引擎)。sendfile 是将读到内核空间的数据,转到 socket buffer,进行网络发送,适合大文件传输,只需要 2 次上下文切换(用户态 -> 内核态 -> 用户态)和 2 次拷贝(磁盘文件 DMA 拷贝到内核缓冲区,内核缓冲区 DMA 拷贝到协议引擎)。