io多路复用·零拷贝·while死循环&cpu

引用文章

原来 8 张图,就可以搞懂「零拷贝」了-建议原文背诵
如何高效实现文件传输:小文件采用零拷贝、大文件采用异步io+直接io

IO多路复用,几个动图我悟了!

while循环&CPU占用率高问题深入分析与解决方案

io多路复用是什么意思

select和epoll 原理概述&优缺点比较

select、poll、epoll对比总结

epoll内核源码注释

问题

io多路复用效率为什么这么高

内核去做遍历,就不用再多线程处理;多次系统调用变成了一次系统调用批量处理

epoll和select/poll什么时候用

select/poll:一般只能处理几千的并发连接(1万的连接,1000的活跃数,那么每次都要将这1万个fd的数据拷贝到内核,内核同步进行1万次的轮询(拷贝太多),然后又拷贝回用户态,用户态又需要进行1万次的fd的遍历,cpu开销太大(进行了很多无用的轮询),内存开销也大)

适用场景(优于epoll):假设现在有1024个fd ; select 和epoll 都同时维护他, 假设这些fd 都是活跃的, 这种情况下,select一次扫描 可以扫描1024个fd,空闲的fd很少,但是epoll 就有可能不一样了, epoll 是先注册等待回调, 有可能出现1024次回调;这样的情况下, 要是说epoll 效率比select 高—这就不好说了;或者在短连接多的时候, 一个连接使用epoll 会触发epoll_ctrl_add/del 两次系统调用,但是select 只有一次扫描 ,此时 也许select 效率性能更高

不适用场景:假设我们的服务器需要支持100万的并发连接,则在__FD_SETSIZE 为1024的情况下,每个进程监听1024个连接,则我们至少需要开辟1k个进程才能实现100万的并发连接。一般来说,我们不会开启这么多进程或线程,除非是协程

  • 除了进程间上下文切换的时间消耗外,从内核/用户空间大量的句柄结构内存拷贝、数组轮询等,是系统难以承受的。因此,基于select模型的服务器程序,要达到100万级别的并发访问,是一个很难完成的任务

epoll:1万并发以上尤佳

适用场景:1万以上的连接/并发
如果select 和epoll 同时维护1024个fd ,但是每次只有一个fd有事件,这种情况下 select 每次都会扫描所有的fd, 对比于epoll 每次只有一个fd 回调。 select 做了很多无用功, 此时应该epoll的效率高吧(大多数场景都是:大量连接,少数活跃:epoll 80%场景都大大优于select)

epoll的LT和ET

LT:多次消费
ET:一次消费(效率高,但可能丢数据)

从 jdk 的 nio 到 epoll 源码与实现内幕全面解析

https://my.oschina.net/u/3645114/blog/5276806
在这里插入图片描述
在 Java 中, Selector 这个类是 select/epoll/poll 的外包类, 在不同的平台上, 底层的实现可能有所不同, 但其基本原理是一样的, 其原理图如下所示:
在这里插入图片描述

所有的 Channel 都归 Selector 管理, 这些 channel 中只要有至少一个有IO动作, 就可以通过 Selector.select 方法检测到, 并且使用 selectedKeys 得到这些有 IO 的 channel, 然后对它们调用相应的IO操作.
在这里插入图片描述

io多路复用

io多路复用效率为什么这么高

好多文章说,多路复用之所以效率高,是因为用一个线程就可以监控多个文件描述符。这显然是知其然而不知其所以然,多路复用产生的效果,完全可以由用户态去遍历文件描述符并调用其非阻塞的 read 函数实现。而多路复用快的原因在于,操作系统提供了这样的系统调用,使得原来的 while 循环里多次系统调用,变成了一次系统调用 + 内核层遍历这些文件描述符。 就好比我们平时写业务代码,把原来 while 循环里调 http 接口进行批量,改成了让对方提供一个批量添加的 http 接口,然后我们一次 rpc 请求就完成了批量添加。

操作系统提供了这样一个接口:使得之前的多次系统调用变成一次系统调用批量处理

传统阻塞io

listenfd = socket();   // 打开一个网络通信端口
bind(listenfd);        // 绑定
listen(listenfd);      // 监听
while(1) {
  connfd = accept(listenfd);  // 阻塞建立连接
  int n = read(connfd, buf);  // 阻塞读数据
  doSomeThing(buf);  // 利用读到的数据做些什么
  close(connfd);     // 关闭连接,循环等待下一个连接
}

在这里插入图片描述
在这里插入图片描述

非阻塞io

在这里插入图片描述

select

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

  1. select 调用需要传入 fd 数组,需要拷贝一份到内核,高并发场景下这样的拷贝消耗的资源是惊人的。(可优化为不复制)
  2. select 在内核层仍然是通过遍历的方式检查文件描述符的就绪状态,是个同步过程,只不过无系统调用切换上下文的开销。(内核层可优化为异步事件通知)
  3. select 仅仅返回可读文件描述符的个数,具体哪个可读还是要用户自己遍历。(可优化为只返回给用户就绪的文件描述符,无需用户做无效的遍历)

select 实现多路复用的方式是,将已连接的 Socket 都放到一个文件描述符集合,然后调用 select 函数将文件描述符集合拷贝到内核里,让内核来检查是否有网络事件产生,检查的方式很粗暴,就是通过遍历文件描述符集合的方式,当检查到有事件产生后,将此 Socket 标记为可读或可写, 接着再把整个文件描述符集合拷贝回用户态里,然后用户态还需要再通过遍历的方法找到可读或可写的 Socket,然后再对其处理。所以,对于 select 这种方式,需要进行 2 次「遍历」文件描述符集合,一次是在内核态里,一个次是在用户态里 ,而且还会发生 2 次「拷贝」文件描述符集合,先从用户空间传入内核空间,由内核修改后,再传出到用户空间中。select 使用固定长度的 BitsMap,表示文件描述符集合,而且所支持的文件描述符的个数是有限制的,在 Linux 系统中,由内核中的 FD_SETSIZE 限制, 默认最大值为 1024,只能监听 0~1023 的文件描述符。poll 不再用 BitsMap 来存储所关注的文件描述符,取而代之用动态数组,以链表形式来组织,突破了 select 的文件描述符个数限制,当然还会受到系统文件描述符限制。但是 poll 和 select 并没有太大的本质区别,都是使用「线性结构」存储进程关注的 Socket 集合,因此都需要遍历文件描述符集合来找到可读或可写的 Socket,时间复杂度为 O(n),而且也需要在用户态与内核态之间拷贝文件描述符集合,这种方式随着并发数上来,性能的损耗会呈指数级增长。

epoll

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

epoll :操作系统提供了这三个函数。

第一步,创建一个 epoll 句柄

int epoll_create(int size);

第二步,向内核添加、修改或删除要监控的文件描述符。

int epoll_ctl(
  int epfd, int op, int fd, struct epoll_event *event);

第三步,类似发起了 select() 调用

int epoll_wait(
  int epfd, struct epoll_event *events, int max events, int timeout);

改进点

  1. 内核中保存一份文件描述符集合,无需用户每次都重新传入,只需告诉内核修改的部分即可。
  2. 内核不再通过轮询的方式找到就绪的文件描述符,而是通过异步 IO 事件唤醒。
  3. 内核仅会将有 IO 事件的文件描述符返回给用户,用户也无需遍历整个文件描述符集合。

在这里插入图片描述
网上文章不少说,epoll_wait 返回时,对于就绪的事件,epoll使用的是共享内存的方式,即用户态和内核态都指向了就绪链表,所以就避免了内存拷贝消耗。这是错的!看过 epoll 内核源码的都知道,压根就没有使用共享内存这个玩意。你可以从下面这份代码看到, epoll_wait 实现的内核代码中调用了 __put_user函数,这个函数就是将数据从内核拷贝到用户空间。
在这里插入图片描述

三、LT模式
水平触发:内核数据没被应用程序读完,就会一直上报

应用程序可以每次读一部分数据,没读完,下一次epoll_wait时内核还会上报。能够平均地处理每个socket上的读写事件,能够及时回到epoll_wait,让所有的socket都得到及时的处理,让客户端感觉服务器延时低

  1. 不会丢失数据或者消息:应用没有读取完数据,内核是会不断上报的
  2. 低延迟处理:每次读数据只需要一次系统调用;照顾了多个连接的公平性,不会因为某个连接上的数据量过大而影响其他连接处理消息

四、ET模式
边沿触发:从不可读到可读,或者从不可写到可写,这个状态的变化,内核只上报一次。如果应用程序不读完、不写完,就不会再触发了,数据会丢失

我们可能会认为ET模式只触发一次,效率高,LT模式就没有存在的价值,其实不是这样的

在极端情况下,epoll工作在ET模式下,一个socket上产生源源不断的数据,由于应用程序会循环读取这个socket上的数据,这就导致应用程序无法及时回到epoll_wait。假如此时又有其他请求到了,红黑树上有一些fd发生了事件被放到双向链表返回给应用程序,而此时应用程序还在读某个socket上的数据,无法及时回到epoll_wait,对于客户端来说,服务器就没有及时处理它的请求,客户端会觉得服务器的延时很高

C10K问题-1万并发

那如果服务器的内存只有 2 GB,网卡是千兆的,能支持并发 1 万请求吗?并发 1 万请求,也就是经典的 C10K 问题 ,C 是 Client 单词首字母缩写,C10K 就是单机同时处理 1 万个请求的问题。从硬件资源角度看,对于 2GB 内存千兆网卡的服务器,如果每个请求处理占用不到 200KB 的内存和 100Kbit 的网络带宽就可以满足并发 1 万个请求。不过,要想真正实现 C10K 的服务器,要考虑的地方在于服务器的网络 I/O 模型,效率低的模型,会加重系统开销,从而会离 C10K 的目标越来越远。

基于进程或者线程模型的,其实还是有问题的。新到来一个 TCP 连接,就需要分配一个进程或者线程,那么如果要达到 C10K,意味着要一台机器维护 1 万个连接,相当于要维护 1 万个进程/线程,操作系统就算死扛也是扛不住的

epoll和select的对比

实际数据举例说明:

设想一下如下场景:有100万个客户端同时与一个服务器进程保持着TCP连接。而每一时刻,通常只有几百个TCP连接是活跃的(事实上大部分场景都是这种情况),如何实现这样的高并发?

  • 在select / poll时代,服务器进程每次都把这100万个连接告诉操作系统(从用户态复制句柄数据结构到内核态),让操作系统内核去查询这些套接字上是否有事件发生,轮询完成后,再将句柄数据复制到用户态,让服务器应用程序轮询处理已发生的网络事件,这一过程资源消耗较大,因此select/poll一般只能处理几千的并发连接,
  • epoll的设计和实现与select完全不同,epoll通过在Linux内核中申请一个简易的文件系统(文件系统一般用B+树实现,磁盘IO消耗低,效率很高
    把原先的select/poll调用分成以下3个部分:
    调用epoll_create()建立一个epoll对象(在epoll文件系统中为这个句柄对象分配资源)
    调用epoll_ctl向epoll对象中添加这100万个连接的套接字,以红黑树的形式组织,增删查都是O(log2N)
    调用epoll_wait收集发生的事件的fd资源,发生事件的fd会从红黑树上拷贝到双向链表,用于返回给用户。用户拿到的就只有发生事件的文件描述符了
    如此一来,要实现上面说是的场景,只需要在进程启动时建立一个epoll对象,然后在需要的时候向这个epoll对象中添加或者删除事件。同时,epoll_wait的效率也非常高,因为调用epoll_wait时,并没有向操作系统复制这100万个连接的句柄数据,因为前面通过epoll_ctl把感兴趣的句柄数据放在了内核的红黑树上。红黑树遍历是对数复杂度,内核也不需要去遍历全部的连接
    epoll_create在内核上创建的eventpoll结构如下
struct eventpoll{
	....
	/*红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/
	struct rb_root rbr;
	/*双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件*/
	struct list_head rdlist;
	....
};

在这里插入图片描述

在这里插入图片描述

总结

最基础的 TCP 的 Socket 编程,它是阻塞 I/O 模型,基本上只能一对一通信,那为了服务更多的客户端,我们需要改进网络 I/O 模型。比较传统的方式是使用多进程/线程模型,每来一个客户端连接,就分配一个进程/线程,然后后续的读写都在对应的进程/线程,这种方式处理 100 个客户端没问题,但是当客户端增大到 10000 个时,10000 个进程/线程的调度、上下文切换以及它们占用的内存,都会成为瓶颈。为了解决上面这个问题,就出现了 I/O 的多路复用,可以只在一个进程里处理多个文件的 I/O,Linux 下有三种提供 I/O 多路复用的 API,分别是: select、poll、epoll。select 和 poll 并没有本质区别,它们内部都是使用「线性结构」来存储进程关注的 Socket 集合。在使用的时候,首先需要把关注的 Socket 集合通过 select/poll 系统调用从用户态拷贝到内核态,然后由内核检测事件,当有网络事件产生时,内核需要遍历进程关注 Socket 集合,找到对应的 Socket,并设置其状态为可读/可写,然后把整个 Socket 集合从内核态拷贝到用户态,用户态还要继续遍历整个 Socket 集合找到可读/可写的 Socket,然后对其处理。很明显发现,select 和 poll 的缺陷在于,当客户端越多,也就是 Socket 集合越大,Socket 集合的遍历和拷贝会带来很大的开销,因此也很难应对 C10K。epoll 是解决 C10K 问题的利器,通过两个方面解决了 select/poll 的问题。epoll 在内核里使用「红黑树」来关注进程所有待检测的 Socket,红黑树是个高效的数据结构,增删查一般时间复杂度是 O(logn),通过对这棵黑红树的管理,不需要像 select/poll 在每次操作时都传入整个 Socket 集合,减少了内核和用户空间大量的数据拷贝和内存分配。epoll 使用事件驱动的机制,内核里维护了一个「链表」来记录就绪事件,只将有事件发生的 Socket 集合传递给应用程序,不需要像 select/poll 那样轮询扫描整个集合(包含有和无事件的 Socket ),大大提高了检测的效率。而且,epoll 支持边缘触发和水平触发的方式,而 select/poll 只支持水平触发,一般而言,边缘触发的方式会比水平触发的效率高。

select/epoll举例、详细工作过程

例子
就像收本子的班长,以前得一个个学生地去问有没有本子,如果没有,它还得等待一段时间而后又继续问,现在好了,只走一次,如果没有本子,班长就告诉大家去那里交本子,当班长想起要取本子,就去那里看看或者等待一定时间后离开,有本子到了就叫醒他,然后取走。

select
如果所有设备返回的掩码都没有显示任何的事件触发,就去掉回调函数的函数指针,进入有限时的睡眠状态,再恢复和不断做poll,再作有限时的睡眠,直到其中一个设备有事件触发为止。
只要有事件触发,系统调用返回,将fd_set从内核空间拷贝到用户空间,回到用户态,用户就可以对相关的fd作进一步的读或者写操作了

epoll
epoll_create
内核帮我们在epoll文件系统里建了个file结点;在内核cache里建了个红黑树用于存储以后epoll_ctl传来的socket;建立一个list链表,用于存储准备就绪的事件。
epoll_ctl
把socket放到epoll文件系统里file对象对应的红黑树上;给内核中断处理程序注册一个回调函数,告诉内核,如果这个句柄的中断到了,就把它放到准备就绪list链表里。
epoll_wait
观察list链表里有没有数据。有数据就返回,没有数据就sleep,等到timeout时间到后即使链表没数据也返回。而且,通常情况下即使我们要监控百万计的句柄,大多一次也只返回很少量的准备就绪句柄而已,所以,epoll_wait仅需要从内核态copy少量的句柄到用户态而已。

一颗红黑树,一张准备就绪句柄链表,少量的内核cache,解决了大并发下的socket处理问题。执行epoll_create时,创建了红黑树和就绪链表; 执行epoll_ctl时,如果增加socket句柄,则检查在红黑树中是否存在,存在立即返回,不存在则添加到树干上,然后向内核注册回调函数,用于当中断事件来临时向准备就绪链表中插入数据; 执行epoll_wait时立刻返回准备就绪链表里的数据即可。

1 select:在网络编程中统一的操作顺序是创建socket->绑定端口->监听->accept->write/read,当有客户端连接到来时,select会把该连接的文件描述符放到fd_set(一组文件描述符(fd)的集合),然后select会循环遍历它所监测的fd_set内的所有文件描述符,当select循环遍历完所有fd_set内指定的文件描述符对应的poll函数后,如果没有一个资源可用(即没有一个文件可供操作),则select让该进程睡眠,一直等到有资源可用为止,fd_set是一个类似于数组的数据结构,由于它每次都要遍历整个数组,所有她的效率会随着文件描述符的数量增多而明显的变慢,除此之外在每次遍历这些描述符之前,系统还需要把这些描述符集合从内核copy到用户空间,然后再copy回去,如果此时没有一个描述符有事件发生(例如:read和write)这些copy操作和便利操作都是无用功,可见slect随着连接数量的增多,效率大大降低。可见如果在高并发的场景下select并不适用,况且select默认的最大描述符为1024,如果想要更多还要做响应参数的配置。

2 epoll:说到epoll都夸赞它的效率和并发量,那么她好在哪里呢。首先调用epoll_create时内核帮我们在epoll文件系统里建了个file结点;除此之外在内核cache里建立红黑树[红黑书介绍](http://www.cnblogs.com/v-July-v/archive/2010/12/29/1983707.html)用于存储以后epoll_ctl传来的socket,当有新的socket连接来时,先遍历红黑书中有没有这个socket存在,如果有就立即返回,没有就插入红黑数,然后给内核中断处理程序注册一个回调函数,每当有事件发生时就通过回调函数把这些文件描述符放到事先准备好的用来存储就绪事件的链表中,调用epoll_wait时,会把准备就绪的socket拷贝到用户态内存,然后清空准备就绪list链表,最后检查这些socket。在LT模式下,如果这些socket上确实有未处理的事件时,该句柄会再次被放回到刚刚清空的准备就绪链表,保证所有的事件都得到正确的处理。如果到timeout时间后链表中没有数据也立刻返回。因此在并发需求量高的场景中我们即使要监控数百万计的句柄,大多数一次也只返回很少量的准备就绪句柄。由此可见epoll仅需要从内核态copy少量的句柄到用户态,这样就避免了select模型中的无效便利和用户和内核之间的copy操作。

说道这里,可以看到epoll的优势非常明显,几乎没有描述符数量的限制,并发支持完美,不会随着socket的增加而降低效率,也不用在内核空间和用户空间之间做无效的copy操作。 但是是不是所有的场景都适合epoll呢

零拷贝

发展史

传统文件拷贝pio(cpu负责io,从不同设备间搬运数据,该方式现在基本看不到了,被DMA取代了)
在这里插入图片描述
DMA方式
在这里插入图片描述
传统方式:用户态与内核态的上下文切换和数据拷贝过程:4次拷贝(2次cpu),4次切换
在这里插入图片描述
mmap+write优化,4次切换,3次拷贝(1次cpu)
在这里插入图片描述
sendfile更进一步优化,2次切换,3次拷贝(1次cpu)
在这里插入图片描述
sendfile+SG-DMA更进一步优化,实现真正的零拷贝,2次切换,2次拷贝(0次cpu)
在这里插入图片描述

场景及pageCache

  • 不需要对数据进行加工(比如要压缩数据就不适用)
  • 小文件(小文件利用了linux系统的pageCache,内核缓存,通过内核缓存和磁盘预读取减少了读盘次数)—大文件的缓存命中很低,这样磁盘预读取就没有意义,反而因此多读了磁盘数据,而那些缓存命中高的小文件有因为大文件占了地方,而得不到加载到缓存

nginx,kafka就使用该项技术,可以设置

location /video/ { 
    sendfile on; 
    aio on; 
    directio 1024m; 
}

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

直接io(+异步io)实现大文件传输

int open(const char *pathname, int flags, mode_t mode);  //操作系统支持的

pathname是文件路径,flags打开文件的标志(O_RDWR 以读写的方式打开文件), mode是打开的模式(O_DIRECT对文件的访问模式为直接io),返回值应该是打开文件的句柄。

在这里插入图片描述

数据库服务器,它们为了充分提高性能,希望绕过内核缓冲区,由自己在用户态空间实现并管理I/O缓冲区(用户缓存),包括缓存机制和写延迟机制等,以支持独特的查询机制,比如数据库可以根据更加合理的策略来提高查询缓存命中率。另一方面,绕过内核缓冲区也可以减少系统内存的开销,因为内核缓冲区本身就在使用系统内存。

上下文切换

为什么要上下文切换,切换为什么耗时间
在这里插入图片描述

上下文切换:10G的内存,2G给内核态(1.调设备驱动程序(网络io,磁盘io)2. 线程切换),8G给用户态

A,B,C线程都在排队等待CPU叫号,A线程被CPU叫到了(获得了cpu的时间片),处理了0.5秒(碰到了耗时操作),时间到了(让出cpu),A线程保留现场,cpu去处理B线程,处理了一会又切回来处理A线程。

如何高效实现文件传输:小文件采用零拷贝、大文件采用异步io+直接io(拿数据举例这个过程)

服务器提供文件传输功能,需要将磁盘上的文件读取出来,通过网络协议发送到客户端。如果需要你自己编码实现这个文件传输功能,你会怎么实现呢?
通常,你会选择最直接的方法:从网络请求中找出文件在磁盘中的路径后,如果这个文件比较大,假设有 320MB,可以在内存中分配 32KB 的缓冲区,再把文件分成一万份,每份只有 32KB,这样,从文件的起始位置读入 32KB 到缓冲区,再通过网络 API 把这 32KB 发送到客户端。接着重复一万次,直到把完整的文件都发送完毕。如下图所示:
在这里插入图片描述
不过这个方案性能并不好,主要有两个原因。
上下文切换:
首先,它至少经历了 4 万次用户态与内核态的上下文切换。因为每处理 32KB 的消息,就需要一次 read 调用和一次 write 调用,每次系统调用都得先从用户态切换到内核态,等内核完成任务后,再从内核态切换回用户态。可见,每处理 32KB,就有 4 次上下文切换,重复 1 万次后就有 4 万次切换。
上下文切换的成本并不小,虽然一次切换仅消耗几十纳秒到几微秒,但高并发服务会放大这类时间的消耗。
内存拷贝:
其次,这个方案做了 4 万次内存拷贝,对 320MB 文件拷贝的字节数也翻了 4 倍,到了 1280MB。很显然,过多的内存拷贝无谓地消耗了 CPU 资源,降低了系统的并发处理能力。
所以要想提升传输文件的性能,需要从降低上下文切换的频率和内存拷贝次数两个方向入手。

零拷贝如何提升文件传输性能?
首先,我们来看如何降低上下文切换的频率。
为什么读取磁盘文件时,一定要做上下文切换呢?这是因为,读取磁盘或者操作网卡都由操作系统内核完成。内核负责管理系统上的所有进程,它的权限最高,工作环境与用户进程完全不同。只要我们的代码执行 read 或者 write 这样的系统调用,一定会发生 2 次上下文切换:首先从用户态切换到内核态,当内核执行完任务后,再切换回用户态交由进程代码执行。
因此,如果想减少上下文切换次数,就一定要减少系统调用的次数。解决方案就是把 read、write 两次系统调用合并成一次,在内核中完成磁盘与网卡的数据交换。
其次,我们应该考虑如何减少内存拷贝次数。
每周期中的 4 次内存拷贝,其中与物理设备相关的 2 次拷贝是必不可少的,包括:把磁盘内容拷贝到内存,以及把内存拷贝到网卡。但另外 2 次与用户缓冲区相关的拷贝动作都不是必需的,因为在把磁盘文件发到网络的场景中,用户缓冲区没有必须存在的理由。
如果内核在读取文件后,直接把 PageCache 中的内容拷贝到 Socket 缓冲区,待到网卡发送完毕后,再通知进程,这样就只有 2 次上下文切换,和 3 次内存拷贝。

实际上,这就是零拷贝技术。

它是操作系统提供的新函数,同时接收文件描述符和 TCP socket 作为输入参数,这样执行时就可以不需要用户层缓存,完全在内核态完成内存拷贝,既减少了内存拷贝次数,也降低了上下文切换次数。
而且,零拷贝取消了用户缓冲区后,不只降低了用户内存的消耗,还通过最大化利用 socket 缓冲区中的内存,间接地再一次减少了系统调用的次数,从而带来了大幅减少上下文切换次数的机会!
你可以回忆下,没用零拷贝时,为了传输 320MB 的文件,在用户缓冲区分配了 32KB 的内存,把文件分成 1 万份传送,然而,这 32KB 是怎么来的?为什么不是 32MB 或者 32 字节呢?这是因为,在没有零拷贝的情况下,我们希望内存的利用率最高。如果用户缓冲区过大,它就无法一次性把消息全拷贝给 socket 缓冲区;如果用户缓冲区过小,则会导致过多的 read/write 系统调用。
那用户缓冲区为什么不与 socket 缓冲区大小一致呢?这是因为,socket 缓冲区的可用空间是动态变化的,它既用于 TCP 滑动窗口,也用于应用缓冲区,还受到整个系统内存的影响(我在《Web 协议详解与抓包实战》第 5 部分课程对此有详细介绍,这里不再赘述)。尤其在长肥网络中,它的变化范围特别大。
零拷贝使我们不必关心 socket 缓冲区的大小。比如,调用零拷贝发送方法时,尽可以把发送字节数设为文件的所有未发送字节数,例如 320MB,也许此时 socket 缓冲区大小为 1.4MB,那么一次性就会发送 1.4MB 到客户端,而不是只有 32KB。这意味着对于 1.4MB 的 1 次零拷贝,仅带来 2 次上下文切换,而不使用零拷贝且用户缓冲区为 32KB 时,经历了 176 次(4 * 1.4MB/32KB)上下文切换。
综合上述各种优点,零拷贝可以把性能提升至少一倍以上!对文章开头提到的 320MB 文件的传输,当 socket 缓冲区在 1.4MB 左右时,只需要 4 百多次上下文切换,以及 4 百多次内存拷贝,拷贝的数据量也仅有 640MB,这样,不只请求时延会降低,处理每个请求消耗的 CPU 资源也会更少,从而支持更多的并发请求。
此外,零拷贝还使用了 PageCache 技术,通过它,零拷贝可以进一步提升性能,我们接下来看看 PageCache 是如何做到这一点的。
PageCache,磁盘高速缓存
回顾上文中的几张图,你会发现,读取文件时,是先把磁盘文件拷贝到 PageCache 上,再拷贝到进程中。为什么这样做呢?有两个原因所致。
第一,由于磁盘比内存的速度慢许多,所以我们应该想办法把读写磁盘替换成读写内存,比如把磁盘中的数据复制到内存中,就可以用读内存替换读磁盘。但是,内存空间远比磁盘要小,内存中注定只能复制一小部分磁盘中的数据。
选择哪些数据复制到内存呢?通常,刚被访问的数据在短时间内再次被访问的概率很高(这也叫“时间局部性”原理),用 PageCache 缓存最近访问的数据,当空间不足时淘汰最久未被访问的缓存(即 LRU 算法)。读磁盘时优先到 PageCache 中找一找,如果数据存在便直接返回,这便大大提升了读磁盘的性能。
第二,读取磁盘数据时,需要先找到数据所在的位置,对于机械磁盘来说,就是旋转磁头到数据所在的扇区,再开始顺序读取数据。其中,旋转磁头耗时很长,为了降低它的影响,PageCache 使用了预读功能。
也就是说,虽然 read 方法只读取了 0-32KB 的字节,但内核会把其后的 32-64KB 也读取到 PageCache,这后 32KB 读取的成本很低。如果在 32-64KB 淘汰出 PageCache 前,进程读取到它了,收益就非常大。这一讲的传输文件场景中这是必然发生的。
从这两点可以看到 PageCache 的优点,它在 90% 以上场景下都会提升磁盘性能,但在某些情况下,PageCache 会不起作用,甚至由于多做了一次内存拷贝,造成性能的降低。在这些场景中,使用了 PageCache 的零拷贝也会损失性能。
具体是什么场景呢?就是在传输大文件的时候。比如,你有很多 GB 级的文件需要传输,每当用户访问这些大文件时,内核就会把它们载入到 PageCache 中,这些大文件很快会把有限的 PageCache 占满。
然而,由于文件太大,文件中某一部分内容被再次访问到的概率其实非常低。这带来了 2 个问题:首先,由于 PageCache 长期被大文件占据,热点小文件就无法充分使用 PageCache,它们读起来变慢了;其次,PageCache 中的大文件没有享受到缓存的好处,但却耗费 CPU 多拷贝到 PageCache 一次。
所以,高并发场景下,为了防止 PageCache 被大文件占满后不再对小文件产生作用,大文件不应使用 PageCache,进而也不应使用零拷贝技术处理

从图中可以看到,异步 IO 并没有拷贝到 PageCache 中,这其实是异步 IO 实现上的缺陷。经过 PageCache 的 IO 我们称为缓存 IO,它与虚拟内存系统耦合太紧,导致异步 IO 从诞生起到现在都不支持缓存 IO。
绕过 PageCache 的 IO 是个新物种,我们把它称为直接 IO。对于磁盘,异步 IO 只支持直接 IO。
直接 IO 的应用场景并不多,主要有两种:第一,应用程序已经实现了磁盘文件的缓存,不需要 PageCache 再次缓存,引发额外的性能消耗。比如 MySQL 等数据库就使用直接 IO;第二,高并发下传输大文件,我们上文提到过,大文件难以命中 PageCache 缓存,又带来额外的内存拷贝,同时还挤占了小文件使用 PageCache 时需要的内存,因此,这时应该使用直接 IO。
当然,直接 IO 也有一定的缺点。除了缓存外,内核(IO 调度算法)会试图缓存尽量多的连续 IO 在 PageCache 中,最后合并成一个更大的 IO 再发给磁盘,这样可以减少磁盘的寻址操作;另外,内核也会预读后续的 IO 放在 PageCache 中,减少磁盘操作。直接 IO 绕过了 PageCache,所以无法享受这些性能提升。
有了直接 IO 后,异步 IO 就可以无阻塞地读取文件了。现在,大文件由异步 IO 和直接 IO 处理,小文件则交由零拷贝处理,至于判断文件大小的阈值可以灵活配置(参见 Nginx 的 directio 指令)。

while死循环cpu干了什么

直接上一个工作中碰到的问题,另外一个系统开启多线程调用我这边的接口,然后我这边会开启多线程批量查询第三方接口并且返回给调用方。使用的是两三年前别人遗留下来的方法,放到线上后发现确实是可以正常取到结果,但是一旦调用,CPU占用就直接100%(部署环境是win server服务器)。因此查看了下相关的老代码并使用JProfiler查看发现是在某个while循环的时候有问题。具体项目代码就不贴了,类似于下面这段代码。​​​​​​

while(flag) {
 
//your code;
 
}

这里的flag可能是true或者是某个需要很长时间才能跳出这个循环的条件。

先放上我的解决方法,上述代码修改如下:

while(flag) {
 
Thread.sleep(1);
 
//your code;
 
}

之后CPU占用率就正常了。其实这里面涉及到的知识点很多,我在网上也查了一些资料,现在分析下这个问题的原因以及相关解决方案。

while循环为什么会使得CPU占用率很高呢

这里就涉及到了操作系统的知识:

操作系统中,CPU竞争有很多种策略。Unix系统使用的是时间片算法,而Windows则属于抢占式的。

在时间片算法中,所有的进程排成一个队列。操作系统按照他们的顺序,给每个进程分配一段时间,即该进程允许运行的时间。如果在 时间片结束时进程还在运行,则CPU将被剥夺并分配给另一个进程。如果进程在时间片结束前阻塞或结束,则CPU当即进行切换。调度程 序所要做的就是维护一张就绪进程列表,,当进程用完它的时间片后,它被移到队列的末尾。

所谓抢占式操作系统,就是说如果一个进程得到了 CPU 时间,除非它自己放弃使用 CPU ,否则将完全霸占 CPU 。因此可以看出,在抢 占式操作系统中,操作系统假设所有的进程都是“人品很好”的,会主动退出 CPU 。在抢占式操作系统中,假设有若干进程,操作系统会根据他们的优先级、饥饿时间(已经多长时间没有使用过 CPU 了),给他们算出一 个总的优先级来。操作系统就会把 CPU 交给总优先级最高的这个进程。当进程执行完毕或者自己主动挂起后,操作系统就会重新计算一 次所有进程的总优先级,然后再挑一个优先级最高的把 CPU 控制权交给他。

我们用分蛋糕的场景来描述这两种算法。假设有源源不断的蛋糕(源源不断的时间),一副刀叉(一个CPU),10个等待吃蛋糕的人(10 个进程)。

如果是 Unix操作系统来负责分蛋糕,那么他会这样定规矩:每个人上来吃 1 分钟,时间到了换下一个。最后一个人吃完了就再从头开始。于是,不管这10个人是不是优先级不同、饥饿程度不同、饭量不同,每个人上来的时候都可以吃 1 分钟。当然,如果有人本来不太饿,或者饭量小,吃了30秒钟之后就吃饱了,那么他可以跟操作系统说:我已经吃饱了(挂起)。于是操作系统就会让下一个人接着来。

如果是 Windows 操作系统来负责分蛋糕的,那么场面就很有意思了。他会这样定规矩:我会根据你们的优先级、饥饿程度去给你们每个人计算一个优先级。优先级最高的那个人,可以上来吃蛋糕——吃到你不想吃为止。等这个人吃完了,我再重新根据优先级、饥饿程度来计算每个人的优先级,然后再分给优先级最高的那个人。

这样看来,这个场面就有意思了——可能有些人是PPMM(pretty pretty MM,意思是“相当漂亮的MM),因此具有高优先级,于是她就可以经常来吃蛋糕。可能另外一个人是个丑男,而且很ws(猥琐),所以优先级特别低,于是好半天了才轮到他一次(因为随着时间的推移,他会越来越饥饿,因此算出来的总优先级就会越来越高,因此总有一天会轮到他的)。而且,如果一不小心让一个大胖子得到了刀叉,因为他饭量大,可能他会霸占着蛋糕连续吃很久很久,导致旁边的人在那里咽口水。。。

而且,还可能会有这种情况出现:操作系统现在计算出来的结果,5号PPMM总优先级最高,而且高出别人一大截。因此就叫5号来吃蛋糕。5号吃了一小会儿,觉得没那么饿了,于是说“我不吃了”(挂起)。因此操作系统就会重新计算所有人的优先级。因为5号刚刚吃过,因此她的饥饿程度变小了,于是总优先级变小了;而其他人因为多等了一会儿,饥饿程度都变大了,所以总优先级也变大了。不过这时候仍然有可能5号的优先级比别的都高,只不过现在只比其他的高一点点——但她仍然是总优先级最高的啊。因此操作系统就会说:5号mm上来吃蛋糕……(5号mm心里郁闷,这不刚吃过嘛……人家要减肥……谁叫你长那么漂亮,获得了那么高的优先级)。

Thread.sleep(1),sleep(0) ,yeild()区别

相关的解决方案及分析:

除了这里使用的Thread.sleep(1),相关的还有Thread.sleep(0) Thread.yeild()

Thread.Sleep 函数是干吗的呢?还用刚才的分蛋糕的场景来描述。上面的场景里面,5号MM在吃了一次蛋糕之后,觉得已经有8分饱了,她觉得在未来的半个小时之内都不想再来吃蛋糕了,那么她就会跟操作系统说:在未来的半个小时之内不要再叫我上来吃蛋糕了。这样,操作系统在随后的半个小时里面重新计算所有人总优先级的时候,就会忽略5号mm。Sleep函数就是干这事的,他告诉操作系统“在未来的多少毫秒内我不参与CPU竞争”。

现在有两个问题:

1.如果我调用一下 Thread.sleep(1000) ,一秒后,这个线程会 不会被唤醒?
2.Thread.sleep(0) 。既然是 sleep 0 毫秒,那么他跟去掉这句代码相比,有什么区别?

对于第一个问题,答案是:不一定。因为你只是告诉操作系统:在未来的1000毫秒内我不想再参与到CPU竞争。那么1000毫秒过去之后,这时候也许另外一个线程正在使用CPU,那么这时候操作系统是不会重新分配CPU的,直到那个线程挂起或结束;况且,即使这个时候恰巧轮到操作系统进行CPU 分配,那么当前线程也不一定就是总优先级最高的那个,CPU还是可能被其他线程抢占去。与此相似的,Thread有个Resume函数,是用来唤醒挂起的线程的。好像上面所说的一样,这个函数只是“告诉操作系统我从现在起开始参与CPU竞争了”,这个函数的调用并不能马上使得这个线程获得CPU控制权。

对于第二个问题,答案是:有,而且区别很明显。假设我们刚才的分蛋糕场景里面,有另外一个PPMM 7号,她的优先级也非常非常高(因为非常非常漂亮),所以操作系统总是会叫道她来吃蛋糕。而且,7号也非常喜欢吃蛋糕,而且饭量也很大。不过,7号人品很好,她很善良,她没吃几口就会想:如果现在有别人比我更需要吃蛋糕,那么我就让给他。因此,她可以每吃几口就跟操作系统说:我们来重新计算一下所有人的总优先级吧。不过,操作系统不接受这个建议——因为操作系统不提供这个接口。于是7号mm就换了个说法:“在未来的0毫秒之内不要再叫我上来吃蛋糕了”。这个指令操作系统是接受的,于是此时操作系统就会重新计算大家的总优先级——注意这个时候是连7号一起计算的,因为“0毫秒已经过去了”嘛。因此如果没有比7号更需要吃蛋糕的人出现,那么下一次7号还是会被叫上来吃蛋糕。

因此,Thread.Sleep(0)的作用,就是“触发操作系统立刻重新进行一次CPU竞争”。竞争的结果也许是当前线程仍然获得CPU控制权,也许会换成别的线程获得CPU控制权。这也是我们在大循环里面经常会写一句Thread.sleep(0) ,因为这样就给了其他线程比如Paint线程获得CPU控制权的权力,这样界面就不会假死在那里。末了说明一下,虽然上面提到说“除非它自己放弃使用 CPU ,否则将完全霸占 CPU”,但这个行为仍然是受到制约的——操作系统会监控你霸占CPU的情况,如果发现某个线程长时间霸占CPU,会强制使这个线程挂起,因此在实际上不会出现“一个线程一直霸占着 CPU 不放”的情况。至于我们的大循环造成程序假死,并不是因为这个线程一直在霸占着CPU。实际上在这段时间操作系统已经进行过多次CPU竞争了,只不过其他线程在获得CPU控制权之后很短时间内马上就退出了,于是就又轮到了这个线程继续执行循环,于是就又用了很久才被操作系统强制挂起。因此反应到界面上,看起来就好像这个线程一直在霸占着CPU一样。

Thread.Yeild()
Yield 的中文翻译为 “放弃”,这里意思是主动放弃当前线程的时间片,并让操作系统调度其它就绪态的线程使用一个时间片。但是如果调用 Yield,只是把当前线程放入到就绪队列中,而不是阻塞队列。如果没有找到其它就绪态的线程,则当前线程继续运行。

优势:比 Thread.Sleep(0) 速度要快,可以让低于当前优先级的线程得以运行。可以通过返回值判断是否成功调度了其它线程。

劣势:只能调度同一个处理器的线程,不能调度其它处理器的线程。当没有其它就绪的线程,会一直占用 CPU 时间片,造成 CPU 100%占用率

Thread.sleep(0)
  sleep 的意思是告诉操作系统自己要休息 n 毫秒,这段时间就让给另一个就绪的线程吧。当 n=0 的时候,意思是要放弃自己剩下的时间片,但是仍然是就绪状态,其实意思和 Yield 有点类似。但是 sleep(0) 只允许那些优先级相等或更高的线程使用当前的CPU,其它线程只能等着挨饿了。如果没有合适的线程,那当前线程会重新使用 CPU 时间片。

优势:相比 Yield,可以调度任何处理器的线程使用时间片。
劣势:只能调度优先级相等或更高的线程,意味着优先级低的线程很难获得时间片,很可能永远都调用不到。当没有符合条件的线程,会一直占用 CPU 时间片,造成 CPU 100%占用率。

Thread.sleep(1)
该方法使用 1 作为参数,这会强制当前线程放弃剩下的时间片,并休息 1 毫秒(因为不是实时操作系统,时间无法保证精确,一般可能会滞后几毫秒或一个时间片)。但因此的好处是,所有其它就绪状态的线程都有机会竞争时间片,而不用在乎优先级。

优势:可以调度任何处理器的线程使用时间片。无论有没有符合的线程,都会放弃 CPU 时间,因此 CPU 占用率较低。
劣势:相比 Thread.sleep(0),因为至少会休息一定时间,所以速度要更慢。

一般我会用Thread.sleep(1)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值