网络的模型

1、用户空间和内核空间

本质在说用户态和内核态

任何Linux发行版,其系统内核都是Linux。我们的应用都需要通过Linux内核与硬件交互。

 为了避免用户应用导致冲突甚至内核崩溃,用户应用与内核是分离的:

1、进程的寻址空间会划分为两部分:内核空间用户空间

2用户空间只能执行受限的命令(Ring3),而且不能直接调用系统资源,必须通过内核提供的接口来访问

3、内核空间可以执行特权命令(Ring0),调用一切系统资源

 Linux系统为了提高IO效率,会在用户空间和内核空间都加入缓冲区:

1、写数据时,要把用户缓冲数据拷贝到内核缓冲区,然后写入设备

2、读数据时,要从设备读取数据到内核缓冲区,然后拷贝到用户缓冲区

2、阻塞IO

 2.1概述

《UNIX网络编程一书中,总结归纳了5IO模型:

1、阻塞IOBlocking IO

2、非阻塞IONonblocking IO

3、IO多路复用(IO Multiplexing

4、信号驱动IOSignal Driven IO

5、异步IOAsynchronous IO

 2.2、什么是阻塞IO

顾名思义,阻塞IO就是两个阶段都必须阻塞等待:

阶段一:

用户进程尝试读取数据(比如网卡数据)

此时数据尚未到达,内核需要等待数据

此时用户进程也处于阻塞状态

阶段二:

数据到达并拷贝到内核缓冲区,代表已就绪

将内核数据拷贝到用户缓冲区

拷贝过程中,用户进程依然阻塞等待

拷贝完成,用户进程解除阻塞,处理数据

可以看到,阻塞IO模型中,用户进程在两个阶段都是阻塞状态。

3、非阻塞IO

顾名思义,非阻塞IOrecvfrom操作立即返回结果不是阻塞用户进程。

阶段一:

用户进程尝试读取数据(比如网卡数据)
此时数据尚未到达,内核需要等待数据
返回异常给用户进程
用户进程 拿到error后 再次尝试读取
循环往复 ,直到数据就绪

阶段二:

将内核数据拷贝到用户缓冲区
拷贝过程中,用户进程依然阻塞等待
拷贝完成,用户进程解除阻塞,处理数据

可以看到,非阻塞IO模型中,用户进程在第一个阶段是非阻塞第二个阶段是阻塞状态虽然是非阻塞,但性能并没有得到提高。而且忙等机制会导致CPU空转CPU使用率暴增

 4、IO多路复用

4.1、为什么需要IO多路复用

无论是阻塞IO还是非阻塞IO用户应用一阶段需要调用recvfrom来获取数据,差别在于无数据时的处理方案:

1、如果调用recvfrom时,恰好没有数据,阻塞IO使CPU阻塞非阻塞IO使CPU空转都不能充分发挥CPU的作用

2、如果调用recvfrom时,恰好数据,则用户进程可以直接进入第二阶段,读取并处理数据

而在单线程情况下,只能依次处理IO事件,如果正在处理的IO事件恰好未就绪(数据不可读或不可写),线程就会被阻塞,所有IO事件都必须等待,性能自然会很差。

就比如服务员给顾客点餐,分两步:

顾客思考要吃什么(等待数据就绪)
顾客想好了,开始点餐(读取数据)

要提高效率有几种办法?

方案一:增加更多服务员(多线程)
方案二:不排队,谁想好了吃什么(数据就绪了),服务员就给谁点餐(用户应用就去读取数据)

那么问题来了:用户进程如何知道内核中数据是否就绪呢?

4.2、引入IO多路复用

文件描述符File Descriptor):简称FD,是一个从0 开始的无符号整数,用来关联Linux中的一个文件。在Linux中,一切皆文件,例如常规文件、视频、硬件设备等,当然也包括网络套接字(Socket)。(也就是说一个符号,代表一个文件,一个文件对应一个东西,比如网络套接字,这也说明了linux的万物皆文件的道理,而FD则是从0 开始的无符号整数构成的一个数字

IO多路复用:是利用单个线程来同时监听多个FD,并在某个FD可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。

阶段一:

用户进程调用 select ,指定要 监听的FD集合
内核监听 FD 对应的多个 socket
任意一个或多个socket数据就绪则返回readable
此过程中用户进程阻塞

阶段二:

用户进程找到就绪的 socket
依次调用 recvfrom 读取数据
内核将数据拷贝到用户空间
用户进程处理数据

 上图解释

1、select函数调用,会同时监听多个sockets也就是多个FD,而调用recvfrom函数则只会针对一个socket也就是一个FD

2、当我调用了select函数后,内核态就会检查这些select对应的FD集合,如果有就绪的,就会返回给用户态,(如果内核态查看一个都没有就绪,那么就是真的阻塞了)

3、随后直接调用recvfrom,此时直接从内核态拷贝数据到用户态。因为select函数监听多个FD,所以会重复的调用recvfrom函数。

4、然后在进行下一批的select函数调用重复上面操作。

4.3、IO多路复用的三种实现方式

IO多路复用是利用单个线程来同时监听多个FD,并在某个FD可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。不过监听FD的方式、通知的方式又有多种实现,常见的有:

1、select

2、poll

3、epoll

差异:

 1、selectpoll只会通知用户进程有FD就绪,但不确定具体是哪个FD,需要用户进程逐个遍历FD来确认

2、epoll则会在通知用户进程FD就绪的同时,把已就绪的FD写入用户空间

 4.4、IO多路复用之select

  4.4.1、源码展示

selectLinux最早是由的I/O多路复用技术:

1、int nfds:表示FD的最大的FD+1,主要方便遍历

2、fd_set *readfds:表示要监听读事件的FD集合

3、 fd_set *writefds:表示 要监听写事件的FD集合

4、fd_set *exceptfds:表示要监听异常事件的FD集合

5、timeval *timeout:表示监听FD的超时时间,null-用不超时;0-不阻塞等待;大于0-固定等待时间

6、fd_set:表示一个结构体,就是FD集合,最大可以容纳1024个FD

 4.4.2、动画展示

1、创建fd_set集合用于存放多个FD,设置读监听的FD集合,这里以8个集合为例

2、假设监听FD 有 1,2,5对应的FD集合编号变为1

3、执行select函数,传递参数

    3.1、5+1:表示最大的FD是5在加一,和上面源码相对应

    3.2、rfds: 表示的是读事件

    3.3、后面的两个null分别对应写事件和异常事件

    3.4、超时时间为3秒

 1、在执行select的那一刻,就需要用户态的fd_set集合拷贝到内核态,让其内核态进行监听

1.开始对其内核态的fd_set进行遍历,遍历的位置是从最大的FD+1的位置开始遍历,查看有没有就绪的

1、可以看到没有就绪的那么就需要等待,休眠

 

1、等待被唤醒,只要有任意一个FD被唤醒

 

1、正如下图,有一个FD=1可读了,此时内核就会将结果写到对应fd_set集合中去,还是一样先遍历,找到已经标记的和现在的FD进行一个比较。就绪的保留,未就绪的删除,和上图对比可以看到fd_set只剩下一个,并且select函数会返回有几个就绪了,但是不知道是谁就绪了

所以背地会有一个隐含的操作,会把内核态的fd_set再拷贝到用户态,覆盖掉之前的,但是还是不知道谁是就绪的

 

1、紧接着,找不到谁是就绪的,所以用户态只能进行遍历,找到就绪的fd,读取其中的数据

 

 4.4.3、总结

select模式存在的问题:

1、需要将整个fd_set从用户空间拷贝到内核空间,select结束还要再次拷贝回用户空间

2、select无法得知具体是哪个fd就绪,需要遍历整个fd_set

3、fd_set监听的fd数量不能超过1024

4.5、IO多路复用之poll

4.5.1、概述

poll模式对select模式做了简单改进,但性能提升不明显,部分关键代码如下:

IO流程:

创建pollfd数组,向其中添加关注的fd信息(也就是添加FD到pollfd数组中)数组大小自定义

调用poll函数,将pollfd数组拷贝到内核空间,转链表存储,无上限

内核遍历fd,判断是否就绪

数据就绪或超时后,拷贝pollfd数组到用户空间,返回就绪fd数量n

用户进程判断n是否大于0

大于0则遍历pollfd数组,找到就绪的fd

与select对比:

 1、select模式中的fd_set大小固定为1024,而pollfd在内核中采用链表,理论上无上限

 2、监听FD越多,每次遍历消耗时间也越久,性能反而会下降

4.5.2、源码

 1、 struct pollfd *fds, // pollfd数组,可以自定义大小,用于装FD集合的

 2、nfds_t nfds, // 数组元素个数

 3、int timeout // 超时时间

4、 int fd;         /* 要监听的fd 直接监听值,相对于select来说而是使用二进制数进行保存的 */

5、short int events; /* 要监听的事件类型:读、写、异常 */

6、short int revents;/* 实际发生的事件类型 */

4.6、IO多路复用epoll

4.6.1、三个函数

epoll模式是对selectpoll的改进,它提供了三个函数:epoll_create,epoll_ctl,epoll_wait,

1、epoll_create:创建epoll实例

内部构造有一个eventpoll,而eventpoll有两部分组成,一个是红黑树用于记录所有监听的FD,一个是链表,用于记录就绪的FD

下面的就是开始调用epoll_create,每次调用该函数,就会创建eventpoll,并且返回一个标识epfd,一个epfd对应一个eventpoll.

 2、epoll_ctl:添加FD到红黑树结构中

1、epoll_ctl相当于select函数的其中一个环节

2、 int epfd,  // epoll实例的句柄,也就epoll_create函数创建的实例对象

3、 int op,    // 要执行的操作,包括:ADDMODDEL,对其FD进行什么操作添加,删除等

4、  int fd,    // 要监听的FD

5、 struct epoll_event *event // 要监听的事件类型:读、写、异常等,对FD进行读还是写

 

会给每一个监听的FD和对应的事件类型添加ep_poll_callback函数,当callback触发时,就把对应的FD加入到rdlist这个就绪列表中,也就是list_head中

 3、epoll_wait:等待FD就绪

1、 int epfd,                   // epoll实例的句柄,也就epoll_create函数创建的实例对象

2、 struct epoll_event *events // event数组,用于接收就绪的FD

3、 int maxevents             // events数组的最大长度

4、 int timeout   // 超时时间,-1用不超时;0不阻塞;大于0为阻塞时间

5、epoll_wait返回值是FD就绪的数量

 

 

epoll_wait函数返回的是就绪的数量,但是该函数有一个数组,专门存放list_head中就绪的FD,list_head将其就绪的FD,从内核态复制到用户态,那这个数组全部都是就绪的FD,相对于select函数和poll函数,就好了很多,不用全部复制所有的FD,里面还包含未就绪的

 4.6.2、总结

1、select模式存在的三个问题:

1、能监听的 FD 个数最大不 超过1024
2、 每次 select 都需 要把所有要监听的FD 拷贝 内核空间

3、每次都要遍历所有FD来判断就绪状态

2、poll模式的问题:

1、poll利用链表解决了select中监听FD上限的问题,但依然要遍历所有FD,如果监听较多,性能会下降

3、epoll模式中如何解决这些问题的?

1、基于epoll实例中的红黑树保存要监听的FD,理论上无上限,而且增删改查效率都非常高2、每个FD只需要执行一次epoll_ctl添加到红黑树,以后每次epol_wait无需传递任何参数,无需重复拷贝FD到内核空间

3、利用ep_poll_callback机制来监听FD状态,无需遍历所有FD,因此性能不会随监听的FD数量增多而下降

4.7、IO多路复用-事件通知机制

4.7.1、概述

FD有数据可读时,我们调用epoll_wait(或者selectpoll)可以得到通知。但是事件通知的模式有两种:

1、LevelTriggered:简称LT,也叫做水平触发。只要某个FD中有数据可读,每次调用epoll_wait都会得到通知。

2、EdgeTriggered:简称ET,也叫做边沿触发。只有在某个FD有状态变化时,调用epoll_wait才会被通知。只会通知一次,不管数据是否处理完成

举列子:

假设一个客户端socket对应的FD已经注册到了epoll实例中

客户端socket发送了2kb的数据

服务端调用epoll_wait,得到通知说FD就绪

服务端从FD读取了1kb数据

回到步骤3(再次调用epoll_wait,形成循环)

两种模式对应结果如下:

1、如果我们采用LT模式,因为FD中仍有1kb数据,则第⑤步依然会返回结果,并且得到通知2、如果我们采用ET模式,因为第③步已经消费了FD可读事件,第⑤步FD状态没有变化,因此epoll_wait不会返回,数据无法读取,客户端响应超时。

4.7.2、总结

1、LT:事件通知频率较高,会有重复通知,影响性能

2、ET(推荐使用):仅通知一次,效率高。可以基于非阻塞IO循环读取解决数据读取不完整问题

selectpoll仅支持LT模式epoll可以自由选择LTET两种模式

4.8、IO多路复用-web服务流程

基于epoll模式的web服务的基本流程如图:

1、调用epoll_create函数创建红黑树和链表

2、创建ServserSocket,也就是对应FD,通过函数epoll_ctl向其红黑树中进行注册

 3、给在红黑树中的每一个监听的FD和对应的事件类型添加ep_poll_callback函数,如果FD就绪,就将其记录到list_head链表中

 4、调用epoll_wait函数,判断list_head链表中有没有就绪的FD

 5、判断没有重新进行等待判断list_head中有没有就绪的FD

 6、找到对应的ServerSocket的FD就绪,判断FD是什么事件类型,是不是可读的,是可读,再看看对应的ServerSocket的FD是不是可读,是的话进行accept()接收客户端socket,得到对应的客户端的FD,在通过epoll_ctl监听客户端的FD

 7、如果对应的serverSocekt的FD不是可读的,可能是客户端的socket,也就是客户端FD可读,那么客户端继续读取数据,除此之外在判断事件类型的地方如果是异常事件,那么存在异常情况,随后将其异常情况写出响应数据。

 5、信号驱动IO

5.1、概述

信号驱动IO是与内核建立SIGIO的信号关联并设置回调,当内核有FD就绪时,会发出SIGIO信号通知用户,期间用户应用可以执行其它业务,无需阻塞等待。

阶段一:

用户进程调用sigaction,注册信号处理函数

内核返回成功,开始监听FD

用户进程不阻塞等待,可以执行其它业务

当内核数据就绪后,回调用户进程的SIGIO处理函数

阶段二:

收到SIGIO回调信号

调用recvfrom,读取

内核将数据拷贝到用户空间

用户进程处理数据

 

上图解释:

1、用户进行首先调用sigaction函数,会向内核中指定一个FD,并且绑定一个SIGIO信号处理函数,随后立即结束不用阻塞等待,如果没有数据,内核会帮其监听,如果有数据,就会被唤醒,并递交一个SIGIO信号给用户进程,随后SIGIO信号处理函数处理这个SIGIO信号,说明有数据就绪了。

2、知道FD就绪了,就会调用recvfrom读取数据,从内核态拷贝数据到用户态

5.2、缺点:

当有大量IO操作时,信号较多,SIGIO处理函数不能及时处理可能导致信号队列溢出,而且内核空间与用户空间的频繁信号交互性能也较低。

 6、异步IO

6.1、概述

异步IO的整个过程都是非阻塞的,用户进程调用完异步API后就可以去做其它事情,内核等待数据就绪并拷贝到用户空间后才会递交信号,通知用户进程。

阶段一:

用户进程调用aio_read,创建信号回调函数

内核等待数据就绪

用户进程无需阻塞,可以做任何事情

阶段二:

内核数据就绪

内核数据拷贝到用户缓冲区

拷贝完成,内核递交信号触发aio_read中的回调函数

用户进程处理数据

 上图说明:

1、用户态调用aio_read函数,告诉内核,想要哪一个FD,然后读到哪里去,就独自干其他的了,

内核会监听FD,等待就绪,直接把数据从内核拷贝到用户态, 在通知用户。

6.2、总结

1、虽然全程不需要用户态管理,但是对于高并发场景是存在问题的,导致内核部分承受压力过大,所以需要在用户态进行一个限流操作。

7、同步和异步

IO操作是同步还是异步,关键看数据在内核空间与用户空间的拷贝过程(数据读写的IO操作),也就是阶段二是同步还是异步

猜你喜欢

转载自blog.csdn.net/qq_36437693/article/details/127189229