io等待为什么引发cpu过高_一文搞懂,网络IO模型

理解网络IO模型:从同步阻塞到epoll
本文深入探讨了网络IO,特别是socket的运作机制和优化。介绍了CPU处理速度远超IO速度的问题,阐述了socket作为应用层与传输层之间的抽象层的角色。文章详细讲解了TCP通信时序图,分析了同步阻塞、非阻塞IO及I/O多路复用(select、poll、epoll)的区别。重点讨论了epoll的高效性和优化措施,如功能分离和就绪列表,以解决大量socket连接时的性能问题。

08a01d6d47fa07dc6abcdf5e90897921.png

IO(input output)主要指:文件IO,网络IO。今天我们重点讨论的是网络IO

首先我们要对这两种IO操作有个宏观上的认识:

565b9cfaa7df31f04aa6d4b9e3f43b5b.png

aacb3a4287d0fc2e85904fb8d780df29.png

上图描述的是文件IO的大致耗时,查阅资料可知:网络IO的耗时也是在微秒级别,跟磁盘IO差不多。

从中我们能得出一些结论:CPU处理数据的速度远远大于IO准备数据的速度。因此网络IO性能优化是工程师们一直在努力的方向

Socket是什么呢?

可能很多人对socket的认识是:通过socket可以实现一个简单的网络通信,并不知道它的设计思路和使用它时到底干了啥。

socket是在应用层和传输层中间的抽象层,它把传输层(TCP/UDP)的复杂操作抽象成一些简单的接口,供应用层调用实现进程在网络中的通信。Socket起源于UNIX,在Unix一切皆文件的思想下,进程间通信就被冠名为文件描述符(file desciptor),Socket是一种“打开—读/写—关闭”模式的实现,服务器和客户端各自维护一个“文件”,在建立连接打开后,可以向文件写入内容供对方读取或者读取对方内容,通讯结束时关闭文件。

20a1857d9c89724ea4eed4971f229991.png

举个具体的列子:socket一般被翻译为“套接字”,其实它在英文的含义为“插座”。

Socket就像一个电话插座,负责连通两端的电话,进行点对点通信,让电话可以进行通信,端口就像插座上的孔,端口不能同时被其他进程占用。而我们建立连接就像把插头插在这个插座上,创建一个Socket实例开始监听后,这个电话插座就时刻监听着消息的传入,谁拨通我这个“IP地址和端口”,我就接通谁。

Socket通信过程

TCP通信时序图

3d030d7a34beba349b7b620779212dc4.png

总结:

一次完整的网络通信会经过多层的传输,需要经过物理传输层的网线和网卡,网络传输层的IP协议,经过这两层之后网络数据通过Ip地址可以知道传输到那台计算机了,传输到目标计算机后,操作系统内核通过网卡读取网络数据,将网络数据存储在内存中。计算机中会运行不同的网络程序,他们可能对应于系统中的不同进程,那要如何把网卡中的网络数据识别出来是给哪个进程的,要如何持续和稳定地给到对应的应用进程呢?我想这也就是socket设计的目的和想要解决的问题了,提供一些API接口来实现应用层通过操作系统内核读取网卡数据,并且将网络数据正确地分发到对应的应用层程序。

面试官为啥这么喜欢问IO模型?

​ 服务端处理网络请求的大致流程:

a87ec09661a9b621fab4e7b75cf2a916.png

核心过程:

(1)成功建立连接。

(2)内核等待网卡数据到位。(这里涉及DMA技术)(这一步控制IO是否阻塞)

(2)内核缓冲区数据拷贝到用户空间(这里涉及mmap内存映射技术)。(这一步控制IO是否同步)

大致解释下DMA和mmap:网卡和磁盘数据拷贝到内存这个过程,如果需要CPU参与的话会占用大量的CPU运行时间,因为IO操作相对耗时非常高。而且CPU主要适用于进行运算,磁盘数据拷贝到内存这个过程并不涉及到运算操作而且流程固定,因此设计了DMA来专门进行上述拷贝操作,相当于在磁盘嵌入了一个DMA芯片(类似简易版的IO cpu)让它来专门负责上述拷贝操作,从而使得CPU不参与上述拷贝操作,使之专注于运算操作。

mmap的设计理念是:用户空间和内核空间映射同一块内存空间,从而达到省略将数据从内核缓冲区拷贝到用户空间的操作,用户空间通过映射直接操作内核缓冲区的数据。

同步阻塞IO

f8267d7af3bddab3a910e76d5197ea53.png

调用recv()后进程一直阻塞,等待内核数据到位后,进程继续阻塞,直到内核数据拷贝到用户空间。缺点:高并发时,服务端与客户端对等连接,线程多带来的问题:

  • CPU资源浪费,上下文切换。
  • 内存成本几何上升,JVM一个线程的成本约1MB。

同步非阻塞IO

e01a39e7422914d180679c7f87a4443b.png

应用进程一直轮训调用recv()查看内核缓冲区的数据是否准备好,内核立即给予答复,如果回复结果通知数据还未准备好,则接着轮训进行询问。缺点:当进程有1000fds,代表用户进程轮询发生系统调用1000次kernel,来回的用户态和内核态的切换,成本几何上升。

I/O 多路复用 - IO multiplexing

466e7f712d6b4e78a3420571a43369eb.png

单个线程就可以同时处理多个网络连接。内核负责轮询所有socket,当某个socket有数据到达了,就通知用户进程。多路复用在Linux内核代码迭代过程中依次支持了三种调用,即SELECT、POLL、EPOLL三种多路复用的网络I/O模型。

select,poll,epoll有啥不同?epoll高效的原因是什么?

在深入了解epoll之前,我们先来了解几个前置知识点

网卡数据接收大致流程:网线->网卡->I/O南桥芯片->内存

cpu如何知道接收了网络数据?

​ 当网卡把数据写入到内存后,网卡向 CPU 发出一个中断信号,操作系统便能得知有新数据到来,再通过网卡中断程序去处理数据

进程阻塞为什么不占用 CPU 资源?

​ 阻塞是进程调度的关键一环,指的是进程在等待某个事件发生前的等待状态,recv,select,epoll都是阻塞方法。

//创建socket 
int s = socket(AF_INET, SOCK_STREAM, 0);    
//绑定 
bind(s, ...) 
//监听 
listen(s, ...) 
//接受客户端连接 
int c = accept(s, ...) 
//接收客户端数据 
recv(c, ...); 
//将数据打印出来 
printf(...)

这是一段基础的网络编程代码,先创建socket对象,依次绑定ip,端口和接受连接。当执行recv()时,进程会被阻塞,直到接收到数据才往下走。

工作队列和等待队列

进程A执行recv()前:

37f6803cdc76b9b9aa37254a87e09961.png

进程A执行recv()后:

ba4c9adea19791621c592e32809b2af6.png

cpu会不断地切换调度工作队列里的进程,进程A调用recv()方法后,会从工作队列移除到等待队列,之后CPU只会调度进程B和进程C,因此进程A不再占用CPU资源。

内核接收数据全过程

如上图所示,进程在 Recv 阻塞期间:

  • 计算机收到了对端传送的数据(步骤 ①)
  • 数据经由网卡传送到内存(步骤 ②)
  • 然后网卡通过中断信号通知 CPU 有数据到达,CPU 执行中断程序(步骤 ③)

此处的中断程序主要有两项功能,先将网络数据写入到对应 Socket 的接收缓冲区里面(步骤 ④),再唤醒进程 A(步骤 ⑤),重新将进程 A 放入工作队列中。

唤醒进程的过程如下图所示:

28ff14c5d36098c276ee83cf955be1c6.png

通过上述流程的分析,我们能引申出来两个问题:

(1)如何知道接收的网络数据时属于哪个socket?

(2)如何同时监控多个socket?

socket数据包格式(源ip,源端口,协议,目的ip,目的端口),一般通过这几个信息就可以识别出来接收到的网络数据属于哪个socket。

有一种情况引发了我的思考:某个进程只启动了一个socket服务端,该服务端只监听了8080端口,有多个客户端连接这一个服务端时,内核这里是否会对应多个socket?各个socket的目的端口是否都为8080?

其实这也是我们最常见的NIO网络编程方式,多线程socket编程。其实多个客户端与同一个服务端建立了连接,这个时候内核就会有多个socket,并且为它们分配多个fd文件描述符。它们收到网络数据后无法通过目的端口来直接匹配socket,还需要再通过源ip和端口来确定属于哪个socket。

如何同时监控多个socket就是epoll核心想解决的问题。

举个例子来说明epoll到时是干什么的?为了解决什么问题?

回到开始把socket通信描述为电话机插入插座之后,双方建立了连接通路,紧接着我们要怎么知道对方打电话给我们了呢?如果有很多个电话机并排在一起(可以想象成大学时的公共电话间),要怎么准确的接听所有的来电呢?

方案1:所有可能接收来电的人,坐在电话间,等待着来电的到来。(电话间坐了一堆的人等在那里,大家不能离开去做别的事)

方案2:分配一个电话间管理员,监听所有电话。有来电时,电话机会响(相当于epoll的回调机制,后面会再介绍下),管理员听到响声进行接听,并且通过对方告知是打给谁的,紧接着去宿舍告诉王某某(你妈妈给你打来了一个电话,你得过来接一下)。(由管理员来统一监听来电,并且将来电通知正确的接电话者。这个模式就有点像epoll,来电接收者无需在电话间一直等待,有来电时统一有管理员进行接听和分配。)

同时监听多个socket的简单方法(select实现)

​ 服务端需要管理多个客户端连接,而 Recv 只能监视单个 Socket,这种矛盾下,人们开始寻找监视多个 Socket 的方法。Epoll 的要义就是高效地监视多个 Socket。

​ 预先传入一个 Socket 列表,如果列表中的 Socket 都没有数据,挂起进程,直到有一个 Socket 收到数据,唤醒进程。这种方法很直接,也是 Select 的设计思想。

select实现流程

Select 的实现思路很直接,假如程序同时监视如下图的 Sock1、Sock2 和 Sock3 三个 Socket,那么在调用 Select 之后,操作系统把进程 A 分别加入这三个 Socket 的等待队列中。操作系统把进程 A 分别加入这三个 Socket 的等待队列中,当任何一个 Socket 收到数据后,中断程序将唤起进程。下图展示了 Sock2 接收到了数据的处理流程:

4c16da80111208d50d0e2fe05574ce04.png

81c514d9b972d491687183e6123ba3a9.png

所谓唤起进程,就是将进程从所有的等待队列中移除,加入到工作队列里面,如上图所示:

将进程 A 从所有等待队列中移除,再加入到工作队列里面。

经由这些步骤,当进程 A 被唤醒后,它知道至少有一个 Socket 接收了数据。程序只需遍历一遍 Socket 列表,就可以得到就绪的 Socket。

这种简单方式行之有效,在几乎所有操作系统都有对应的实现。但是简单的方法往往有缺点,主要是:

(1)每次select都需要将进程加入到监视socket的等待队列,每次唤醒都要将进程从socket等待队列移除。这里涉及两次遍历操作,而且每次都要将FDS列表传递给内核,有一定的开销。

(2)进程被唤醒后,只能知道有socket接收到了数据,无法知道具体是哪一个socket接收到了数据,所以需要用户进程进行遍历,才能知道具体是哪个socket接收到了数据。

那么,有没有减少遍历的方法?有没有保存就绪 Socket 的方法?这两个问题便是 Epoll 技术要解决的。

epoll的设计思路

措施一:功能分离

select的添加等待队列和阻塞进程是合并在一起的,每次调用select()操作时都得执行一遍这两个操作,从而导致每次都要将fd[]传递到内核空间,并且遍历fd[]的每个fd的等待队列,将进程放入各个fd的等待队列中。

epoll优化的方案是:将添加等待队列和阻塞进程拆分成两个独立的操作,不用每次都去重新维护等待队列,先用 epoll_ctl 维护等待队列,再调用 epoll_wait 阻塞进程。显而易见地,效率就能得到提升。

措施二:就绪列表

Select 低效的另一个原因在于程序不知道哪些 Socket 收到数据,只能一个个遍历。如果内核维护一个“就绪列表”,引用收到数据的 Socket,就能避免遍历。

select会把整个fd[]返回给用户程序,让用户程序自己去遍历哪个fd有接收到网络数据。epoll只会把接收到网络数据的fd[]返回给用户程序,用户程序不用自己去进行遍历查询。

epoll实现代码大致结构

int s = socket(AF_INET, SOCK_STREAM, 0);    
bind(s, ...) 
listen(s, ...) 

int epfd = epoll_create(...); 
epoll_ctl(epfd, ...); //将所有需要监听的socket添加到epfd中 

while(1){ 
    int n = epoll_wait(...) 
    for(接收到数据的socket){ 
        //处理 
    } 
}

epoll的原理与工作流程

创建Epoll对象

调用epoll_create方式时,会创建一个eventpoll对象(),eventpoll 对象也是文件系统中的一员,和 Socket 一样,它也会有等待队列。

创建一个代表该 Epoll 的 eventpoll 对象是必须的,因为内核要维护“就绪列表”等数据,“就绪列表”可以作为 eventpoll 的成员。

805b6d44509e3cfdd38d2a40f0aa82e2.png

维护监控对象

创建 Epoll 对象后,可以用 epoll_ctl 添加或删除所要监听的 Socket。eventpoll会通过一个红黑树来存储所有被监视的socket对象,实现快速查找,删除和添加。

52bee6b6002d73e0d08fe8bf7c679f0d.png

接收数据

当 Socket 收到数据后,中断程序会给 eventpoll 的“就绪列表”添加 Socket 引用。

981e31d21f50cde272188709902a4587.png

给就绪列表添加引用

如上图展示的是 Sock2 和 Sock3 收到数据后,中断程序让 Rdlist 引用这两个 Socket。

eventpoll 对象相当于 Socket 和进程之间的中介,Socket 的数据接收并不直接影响进程,而是通过改变 eventpoll 的就绪列表来改变进程状态。

当程序执行到 epoll_wait 时,如果 Rdlist 已经引用了 Socket,那么 epoll_wait 直接返回,如果 Rdlist 为空,阻塞进程。

阻塞和唤醒进程

假设计算机中正在运行进程 A 和进程 B,在某时刻进程 A 运行到了 epoll_wait 语句。

753f55f6061335aa1066671998dccaca.png

当 Socket 接收到数据,中断程序一方面修改 Rdlist,另一方面唤醒 eventpoll 等待队列中的进程,进程 A 再次进入运行状态(如下

26e76c860d6a567387a329c05c2a5c71.png

也因为 Rdlist 的存在,进程 A 可以知道哪些 Socket 发生了变化。

总结:

select为什么在socket连接很多的情况下性能不佳?

将维护socket监控列表和阻塞进程的操作合并在了一起,每次select()调用都会触发这两个操作,从而导致每次调用select()都需要把全量的fd[]列表从用户空间传递到内核空间,内核线程在阻塞进程前需要遍历fd[]将待阻塞的进程放入到每个fd的等待队列里(第一次遍历)。当有网络数据到来时,并不知道网络数据属于具体哪个socket,只知道收到过网络数据,因此需要遍历fd[]唤醒等待队列里的阻塞进程(第二次遍历),并且把fd[]从内核空间拷贝到用户空间,让用户程序自己去遍历fd[]判别哪个socket收到了网络数据(第三次遍历,发生在用户空间)。fd[]比较大的情况,大量的遍历操作会导致性能急剧下降,所以select会默认限制最大文件句柄数为1024,间接控制fd[]最大为1024。

poll其实内部实现基本跟select一样,区别在于它们底层组织fd[]的数据结构不太一样,从而实现了poll的最大文件句柄数量限制去除了

epoll是怎么优化select上述那些问题?

引入了eventpoll这个中间结构,它通过红黑树(rbr)来组织所有待监控的socket对象,实现高效的查找,删除和添加。当收到网络数据时,会触发对应的fd的回调函数,这时不是去遍历各个fd的等待队列进行唤醒进程的操作了,而是把收到数据的socket加入到就绪列表(底层是一个双向链表)。eventpoll有个单独的等待队列来维护待唤醒的进程,避免了像select那样每次需要遍历fd[]来查找各个fd的等待队列的进程。

epoll有这几个核心的数据结构:

红黑树:存放所有待监听的socket

双向链表:存放收到网络数据的socket

等待队列:待唤醒的等待线程

005184ef326b5e950b9950d2747e067c.png
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符  | 博主筛选后可见
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值