前言
由于笔者在之前发布的一文玩转NGINX中提到过I/O复用模型,在此另起一篇文章简述相关技术。
什么是I/O
I/O输入/输出(Input/Output),分为IO设备和IO接口两个部分。 在POSIX兼容的系统上,例如Linux系统 [1] ,I/O操作可以有多种方式,比如DIO(Direct I/O),AIO(Asynchronous I/O,异步I/O),Memory-Mapped I/O(内存映射I/O)等,不同的I/O方式有不同的实现方式和性能,在不同的应用中可以按情况选择不同的I/O方式。
在传统的网络服务器构建,IO模式按照Blocking/Non-Blocking、Synchronous/Asynchronous两个标准进行分类。其中Blocking与Synchronous基本上一个意思,而NIO与Async的区别在于NIO强调的是Polling(轮询),而Async强调的是Notification(通知)。
传统I/O
硬盘—>内核缓冲区—>用户缓冲区—>内核socket缓冲区—>协议引擎
- 在一个典型的单进程单线程Socket接口中,阻塞型的接口必须在上一个Socket连接关闭之后才能接入下一个Socket连接。
- 对于NIO的Socket而言,Server Application会从内核获取到一个特殊的"Would Block"错误信息,但是并不会阻塞到等待发起请求的Socket Client停止。
在Linux系统中可以通过调用独立的select
或者poll
方法来遍历所有读取好的数据,并且进行写操作。
而对于异步Socket而言(譬如Windows中的Sockets或者.Net中实现的Sockets模型),Server Application会告诉IO Framework去读取某个Socket数据,在数据读取完毕之后IO Framework会自动地调用你的回调(也就是通知应用程序本身数据已经准备好了)。
以IO多路复用中的Reactor与Proactor模型为例,非阻塞的模型是需要应用程序本身处理IO的,而异步模型则是由Kernel或者Framework将数据准备好读入缓冲区中,应用程序直接从缓冲区读取数据。
简单解释I/O就是输入和输出,指数据在内部存储器和外部存储器或其他周边设备之间的输入和输出。
同步IO:
优点:简单
缺点:IO阻塞,无法充分利用IO和CPU资源,效率低
Native AIO:
优点:AIO可支持一次发送多个不连续的异步IO请求,性能更好(同步IO需要发送多次)
缺陷:需要文件系统支持O_DIRECT选项,如果不支持,io_submit实际上是“退化”成同步操作。
POSIX AIO:
优点:不依赖O_DIRECT选项,有一定的合并能力(相邻地址的请求,可以做merge)。
缺点:并发的IO请求受限于线程数目;可能慢速磁盘,可能导致新的请求没有及时处理(工作线程数不足)。
Select
select函数仅仅知道有几个I/O事件发生了,但并不知道具体是哪几个socket连接有I/O事件,还需要轮询去查找,时间复杂度为O(n),处理的请求数越多,所消耗的时间越长。
Poll
poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态, 但是它没有最大连接数的限制,原因是它是基于链表来存储的
Epoll
epoll可以理解为event pool,不同与select、poll的轮询机制,epoll采用的是事件驱动机制,每个fd上有注册有回调函数,当网卡接收到数据时会回调该函数,同时将该fd的引用放入rdlist就绪列表中。
当调用epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可。如果rdlist不为空,则把发生的事件复制到用户态,同时将事件数量返回给用户。
对比select和poll机制,epoll通过事件表管理用户感兴趣的事件,无需反复传入用户感兴趣事件,处理事件通知的时间复杂度是O(1),而select,poll机制的时间复杂度是O(N)。
select/poll只能工作在LT模式(水平触发模式);而epoll不仅支持LT模式,还支持ET模式(边缘触发模式)。
区别:有数据可读时,LT模式会不停的通知,直到数据被获取,这种模式不用担心通知事件丢失;ET模式只会通知一次,因此对比LT少很多epoll系统调用,效率更高。
epoll对编程要求高,需要细致的处理每个请求,否则容易发生丢失事件的情况。从本质上讲,与LT相比,ET模型是通过减少系统调用来达到提高并行效率的。
Unix与I/O
根据阻塞IO,我们又分为以下三类:
- 同步阻塞
- 同步非阻塞
- 异步阻塞
阻塞:进程挂起。
非阻塞:进程补挂起,立即返回Ewouldblock。
对于以上三类:
同步阻塞:用户进程发起单个IO操作,必须等待IO操作完成后,用户进程才可以进行。
同步非阻塞:用户进程发起一个IO操作后课返回执行其它操作,但用户进程需询问跟进IO操作是否就绪。
异步非阻塞:用户进程发起一个IO操作后马上放回,等IO操作真正完成以后,应用程序得到IO完成的通知,用户进程仅对数据进行处理即可,不需要进行实际的IO读写操作。该IO读写操作已由内核完成。
Unix的5种IO模型:
阻塞式IO, 非阻塞式IO,IO复用模型,信号驱动式IO和异步IO。
阻塞式IO
对于阻塞式IO,可以划为两个对象。一是发起IO操作的进程/线程,二十内核对象。对于以上两个对象阶段都会阻塞,即线程挂起。
进程对象发起 Recv操作,这是一个系统调用,然后内核会看内核buf是否有数据,如果没有数据,那么进程将会被挂起,直到内核buf从硬件或者网络读取到数据之后,内核再把数据从内核buf拷贝到进程buf中,然后唤醒发起调用的进程,并且Recv操作将会返回数据。接下来进行可以对进程buf的数据进行处理。
非阻塞式IO
线程再BlockingIO发起IO调用后被挂起。再NonblockingIO内,若没有IO数据,那么所发起的系统调用会返回错误。函数返回后线程未被挂起。
进程发起非阻塞IO请求并返回Ewoulfblock后将再次发起非阻塞IO请求。而该行为仍然会使用CPU,称轮询,即polling。而当内核存在数据后,内核将buf的数据复制到应用buf,调用CPU,而对于NonblockingIO而言,线程仍然阻塞。
IO复用模型(select,poll)
因阻塞IO在阻塞时挂起线程,非阻塞IO则提供函数调用后返回的逻辑,而完成IO需要执行不同的轮询polling,而每一次轮询都是一次系统调用。因此,一定程度下的非阻塞IO性能可能不如阻塞IO。继而IO复用模型被提出。
常见的I/O多路复用主要用于网络IO场景,主要有select,poll和epoll机制。对比同步I/O,实际上对I/O请求加了一层代理,由这些代理去监听通知事件(是否网络包到来),然后再通知用户去读写数据。这种方式也是一种阻塞I/O,代理对通知事件阻塞,这里的代理一般指监听线程。对比select,poll提升了最大支持文件描述符数目,从1024提升到65535,MySQL中的半同步复制还因为使用select的这个限制,导致半同步中断的bug(链接)。
在网络IO与并发中,内核作代理进行轮询,进程准备数据后发起IO操作。内核监控应用指定docket文件,socket完成数据准备后,通知应用进程。当事件发时,通知应用进程,进程根据事件注册回调函数、
select,poll,epoll更多时候配合非阻塞使用。
IO多路复用通过把多个IO阻塞复用到同一个select阻塞上,使系统在单线程的情况可以同时处理多个客户端请求。
目前支持IO多路复用的系统调用有select,pselect,poll,epoll。
在linux网络编程过程中,很长一段时间都是用select做轮询和网络事件通知,然而select的一些固有缺陷导致了它的应用受到了很大的限制。
最终linux不得不载新的内核版本中寻找select的替代方案,最终选择了epoll。
信号驱动式IO
内核在描述符就绪时发送SIGIO信号通知进程,即信号驱动式IO。着和事件驱动类似,也是一种回调方式。与非阻塞不一样的式,发起信号驱动的系统调用,进程未挂起仍可运行。而信号返回可读写后,需要vpu将内核数据复制到buf,而该过程仍体现为阻塞。
异步IO
异步IO是指为 IO 操作提供回调的接口,该操作在操作完成时被调用。
此调用通常发生在与最初发出请求的线程完全不同的线程上,但情况不一定如此。 异步 IO 是“前摄器”模式的一种体现。
无论是哪个阶段的数据拷贝,发起系统调用的进程都不会被阻塞。异步IO会导致两个阶段对CPU资源的竞争。进程没有阻塞则抢占CPU,内核复制数据也需要占用CPU。因此应用和内核会竞争CPU资源,且步调不一致,因此为异步IO。而该模式性能可能不如其它IO模式,因此应用相对较少。
IO多路复用
IO多路复用技术通俗阐述,即是由一个线程轮询每个连接,如果某个连接有请求则处理请求,没有请求则处理下一个连接。首先来看下可读事件与可写事件:
当如下任一情况发生时,会产生套接字的可读事件:
- 该套接字的接收缓冲区中的数据字节数大于等于套接字接收缓冲区低水位标记的大小;
- 该套接字的读半部关闭(也就是收到了FIN),对这样的套接字的读操作将返回0(也就是返回EOF);
- 该套接字是一个监听套接字且已完成的连接数不为0;
- 该套接字有错误待处理,对这样的套接字的读操作将返回-1。
当如下任一情况发生时,会产生套接字的可写事件:
- 该套接字的发送缓冲区中的可用空间字节数大于等于套接字发送缓冲区低水位标记的大小;
- 该套接字的写半部关闭,继续写会产生SIGPIPE信号;
- 非阻塞模式下,connect返回之后,该套接字连接成功或失败;
- 该套接字有错误待处理,对这样的套接字的写操作将返回-1。
Reactor模型
Reactor模型在Linux系统中的具体实现即是select/poll/epoll/kqueue,像Redis中即是采用了Reactor模型实现了单进程单线程高并发。Reactor模型的理论基础可以参考reactor-siemens
核心组件
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Y3AEKSMf-1663415204036)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/3fd0866d5db244d59c63eb90aa08dee1~tplv-k3u1fbpfcp-zoom-1.image)]
- Handles:表示操作系统管理的资源,我们可以理解为fd。
- Synchronous Event Demultiplexer:同步事件分离器,阻塞等待Handles中的事件发生。
- Initiation Dispatcher:初始分派器,作用为添加Event handler(事件处理器)、删除Event handler以及分派事件给Event handler。也就是说,Synchronous Event Demultiplexer负责等待新事件发生,事件发生时通知Initiation Dispatcher,然后Initiation Dispatcher调用event handler处理事件。
- Event Handler:事件处理器的接口
- Concrete Event Handler:事件处理器的实际实现,而且绑定了一个Handle。因为在实际情况中,我们往往不止一种事件处理器,因此这里将事件处理器接口和实现分开,与C++、Java这些高级语言中的多态类似。
处理逻辑
Reactor模型的基本的处理逻辑为:
- 我们注册Concrete Event Handler到Initiation Dispatcher中。
- Initiation Dispatcher调用每个Event Handler的get_handle接口获取其绑定的Handle。
- Initiation Dispatcher调用handle_events开始事件处理循环。在这里,Initiation Dispatcher会将步骤2获取的所有Handle都收集起来,使用Synchronous Event Demultiplexer来等待这些Handle的事件发生。
- 当某个(或某几个)Handle的事件发生时,Synchronous Event Demultiplexer通知Initiation Dispatcher。
- Initiation Dispatcher根据发生事件的Handle找出所对应的Handler。
- Initiation Dispatcher调用Handler的handle_event方法处理事件。
抽象来说,Reactor有4个核心的操作:
- add添加socket监听到reactor,可以是listen socket也可以使客户端socket,也可以是管道、eventfd、信号等
- set修改事件监听,可以设置监听的类型,如可读、可写。可读很好理解,对于listen socket就是有新客户端连接到来了需要accept。对于客户端连接就是收到数据,需要recv。可写事件比较难理解一些。一个SOCKET是有缓存区的,如果要向客户端连接发送2M的数据,一次性是发不出去的,操作系统默认TCP缓存区只有256K。一次性只能发256K,缓存区满了之后send就会返回EAGAIN错误。这时候就要监听可写事件,在纯异步的编程中,必须去监听可写才能保证send操作是完全非阻塞的。
- del从reactor中移除,不再监听事件
- callback就是事件发生后对应的处理逻辑,一般在add/set时制定。C语言用函数指针实现,JS可以用匿名函数,PHP可以用匿名函数、对象方法数组、字符串函数名。
Reactor只是一个事件发生器,实际对socket句柄的操作,如connect/accept、send/recv、close是在callback中完成的。
Reactor模型还可以与多进程、多线程结合起来用,既实现异步非阻塞IO,又利用到多核。目前流行的异步服务器程序都是这样的方式:如
- Nginx:多进程Reactor
- Nginx+Lua:多进程Reactor+协程
- Golang:单线程Reactor+多线程协程
- Swoole:多线程Reactor+多进程Worker
协程从底层技术角度看实际上还是异步IO Reactor模型,应用层自行实现了任务调度,借助Reactor切换各个当前执行的用户态线程,但用户代码中完全感知不到Reactor的存在。
Proactor模型
Reactor和Proactor模式的主要区别就是真正的读取和写入操作是有谁来完成的,Reactor中需要应用程序自己读取或者写入数据,而 Proactor模式中,应用程序不需要进行实际的读写过程,它只需要从缓存区读取或者写入即可,操作系统会读取缓存区或者写入缓存区到真正的IO设备。Proactor模型的基本处理逻辑如下:
- 应用程序初始化一个异步读取操作,然后注册相应的事件处理器,此时事件处理器不关注读取就绪事件,而是关注读取完成事件,这是区别于Reactor的关键。
- 事件分离器等待读取操作完成事件。
- 在事件分离器等待读取操作完成的时候,操作系统调用内核线程完成读取操作(异步IO都是操作系统负责将数据读写到应用传递进来的缓冲区供应用程序操作,操作系统扮演了重要角色),并将读取的内容放入用户传递过来的缓存区中。这也是区别于Reactor的一点,Proactor中,应用程序需要传递缓存区。
- 事件分离器捕获到读取完成事件后,激活应用程序注册的事件处理器,事件处理器直接从缓存区读取数据,而不需要进行实际的读取操作。
Linux NIO
select,poll,epoll都是IO多路复用的机制。IO多路复用就通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但select,poll,epoll本质上都是同步IO,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步IO则无需自己负责进行读写,异步IO的实现会负责把数据从内核拷贝到用户空间。
select/poll
函数分析
select(int nfds, fd_set *r, fd_set *w, fd_set *e, struct timeval *timeout)
maxfdp1
表示该进程中描述符的总数。fd_set
则是配合select
模型的重点数据结构,用来存放描述符的集合。timeout
表示select
返回需要等待的时间。
对于select(),我们需要传3个集合,r,w和e。其中,r表示我们对哪些fd的可读事件感兴趣,w表示我们对哪些fd的可写事件感兴趣。每个集合其实是一个bitmap,通过0/1表示我们感兴趣的fd。例如,我们对于fd为6的可读事件感兴趣,那么r集合的第6个bit需要被 设置为1。这个系统调用会阻塞,直到我们感兴趣的事件(至少一个)发生。调用返回时,内核同样使用这3个集合来存放fd实际发生的事件信息。也就是说,调 用前这3个集合表示我们感兴趣的事件,调用后这3个集合表示实际发生的事件。
select为最早期的UNIX系统调用,它存在4个问题:1)这3个bitmap有大小限制(FD_SETSIZE,通常为1024);2)由于 这3个集合在返回时会被内核修改,因此我们每次调用时都需要重新设置;3)我们在调用完成后需要扫描这3个集合才能知道哪些fd的读/写事件发生了,一般情况下全量集合比较大而实际发生读/写事件的fd比较少,效率比较低下;4)内核在每次调用都需要扫描这3个fd集合,然后查看哪些fd的事件实际发生, 在读/写比较稀疏的情况下同样存在效率问题。
由于存在这些问题,于是人们对select进行了改进,从而有了poll。
poll(struct pollfd *fds, int nfds, int timeout)
struct pollfd {
int fd;
short events;
short revents;
}
poll调用需要传递的是一个pollfd结构的数组,调用返回时结果信息也存放在这个数组里面。
pollfd的结构中存放着fd、我们对该fd感兴趣的事件(events)以及该fd实际发生的事件(revents)。
poll传递的不是固定大小的 bitmap,因此select的问题1解决了;poll将感兴趣事件和实际发生事件分开了,因此select的问题2也解决了。
但select的问题3和问题4仍然没有解决。
处理逻辑
总的来说,Select模型的内核的处理逻辑为:
- 使用copy_from_user从用户空间拷贝fd_set到内核空间
- 注册回调函数__pollwait
- 遍历所有fd,调用其对应的poll方法(对于socket,这个poll方法是sock_poll,sock_poll根据情况会调用到tcp_poll,udp_poll或者datagram_poll)
- 以tcp_poll为例,其核心实现就是__pollwait,也就是上面注册的回调函数。
- __pollwait的主要工作就是把current(当前进程)挂到设备的等待队列中,不同的设备有不同的等待队列,对于tcp_poll 来说,其等待队列是sk->sk_sleep(注意把进程挂到等待队列中并不代表进程已经睡眠了)。在设备收到一条消息(网络设备)或填写完文件数 据(磁盘设备)后,会唤醒设备等待队列上睡眠的进程,这时current便被唤醒了。
- poll方法返回时会返回一个描述读写操作是否就绪的mask掩码,根据这个mask掩码给fd_set赋值。
- 如果遍历完所有的fd,还没有返回一个可读写的mask掩码,则会调用schedule_timeout是调用select的进程(也就是 current)进入睡眠。当设备驱动发生自身资源可读写后,会唤醒其等待队列上睡眠的进程。如果超过一定的超时时间(schedule_timeout 指定),还是没人唤醒,则调用select的进程会重新被唤醒获得CPU,进而重新遍历fd,判断有没有就绪的fd。
- 把fd_set从内核空间拷贝到用户空间。
多客户端请求服务端,服务端与各客户端保持长连接并且能接收到各客户端数据大体思路如下:
- 初始化
readset
,并且将服务端监听的描述符添加到readset
中去。 - 然后
select
阻塞等待readset
集合中是否有描述符可读。 - 如果是服务端描述符可读,那么表示有新客户端连接上。通过
accept
接收客户端的数据,并且将客户端描述符添加到一个数组client
中,以便二次遍历的时候使用。 - 执行第二次循环,此时通过
for
循环把client
中的有效的描述符都添加到readset
中去。 select
再次阻塞等待readset
集合中是否有描述符可读。- 如果此时已经连接上的某个客户端描述符有数据可读,则进行数据读取。
epoll/kqueue
select不足与epoll中的改进
[select、poll、epoll之间的区别总结[整理]](http://www.cnblogs.com/Anker/...
select与poll问题的关键在于无状态。对于每一次系统调用,内核不会记录下任何信息,所以每次调用都需要重复传递相同信息。
总结而言,select/poll模型存在的问题即是每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大并且每次都需要在内核遍历传递进来的所有的fd,这个开销在fd很多时候也很大。
讨论epoll对于select/poll改进的时候,epoll和select和poll的调用接口上的不同,select和poll都只提供了一个函数——select或者poll函数。而epoll提供了三个函数,epoll_create,epoll_ctl和epoll_wait,epoll_create是创建一个epoll句柄;
epoll_ctl是注册要监听的事件类型;epoll_wait则是等待事件的产生。对于上面所说的select/poll的缺点,主要是在epoll_ctl中解决的,每次注册新的事件到epoll句柄中时(在epoll_ctl中指定EPOLL_CTL_ADD),会把所有的fd拷贝进内核,而不是在epoll_wait的时候重复拷贝。epoll保证了每个fd在整个过程中只会拷贝一次。
epoll的解决方案不像select或poll一样每次都把current轮流加入fd对应的设备等待队列中,而只在epoll_ctl时把 current挂一遍(这一遍必不可少)并为每个fd指定一个回调函数,当设备就绪,唤醒等待队列上的等待者时,就会调用这个回调函数,而这个回调函数会 把就绪的fd加入一个就绪链表)。
epoll_wait的工作实际上就是在这个就绪链表中查看有没有就绪的fd(利用 schedule_timeout()实现睡一会,判断一会的效果,和select实现中的第7步是类似的)。
(1)select,poll实现需要自己不断轮询所有fd集合,直到设备就绪,期间可能要睡眠和唤醒多次交替。而epoll其实也需要调用epoll_wait不断轮询就绪链表,期间也可能多次睡眠和唤醒交替,但是它是设备就绪时,调用回调函数,把就绪fd放入就绪链表中,并唤醒在epoll_wait中进入睡眠的进程。虽然都要睡眠和交替,但是select和poll在“醒着”的时候要遍历整个fd集合,而epoll在“醒着”的时候只要判断一下就绪链表是否为空就行了,这节省了大量的CPU时间。这就是回调机制带来的性能提升。
(2)select,poll每次调用都要把fd集合从用户态往内核态拷贝一次,并且要把current往设备等待队列中挂一次,而epoll只要一次拷贝,而且把current往等待队列上挂也只挂一次(在epoll_wait的开始,注意这里的等待队列并不是设备等待队列,只是一个epoll内部定义的等待队列)。这也能节省不少的开销。
函数分析
int epoll_create(int size);
创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大。这个 参数不同于select()中的第一个参数,给出最大监听的fd+1的值。需要注意的是,当创建好epoll句柄后,它就是会占用一个fd值,在 linux下如果查看/proc/进程id/fd/,是能够看到这个fd的,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被 耗尽。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epoll的事件注册函数,它不同与select()是在监听事件时告诉内核要监听什么类型的事件,而是在这里先注册要监听的事件类型。第一个参数是epoll_create()的返回值,第二个参数表示动作,用三个宏来表示:
- EPOLL_CTL_ADD:注册新的fd到epfd中;
- EPOLL_CTL_MOD:修改已经注册的fd的监听事件;
- EPOLL_CTL_DEL:从epfd中删除一个fd;
events可以是以下几个宏的集合:
- EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
- EPOLLOUT:表示对应的文件描述符可以写;
- EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
- EPOLLERR:表示对应的文件描述符发生错误;
- EPOLLHUP:表示对应的文件描述符被挂断;
- EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
- EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
等待事件的产生,类似于select()调用。
参数events用来从内核得到事件的集合,maxevents告之内核这个events有多大,这个 maxevents的值不能大于创建epoll_create()时的size,参数timeout是超时时间(毫秒,0会立即返回,-1将不确定,也有 说法说是永久阻塞)。\该函数返回需要处理的事件数目,如返回0表示已超时。
处理逻辑
使用epoll 来实现服务端同时接受多客户端长连接数据时,的大体步骤如下:
(1)使用epoll_create创建一个 epoll 的句柄,下例中我们命名为epollfd。
(2)使用epoll_ctl把服务端监听的描述符添加到epollfd指定的 epoll 内核事件表中,监听服务器端监听的描述符是否可读。
(3)使用epoll_wait阻塞等待注册的服务端监听的描述符可读事件的发生。
(4)当有新的客户端连接上服务端时,服务端监听的描述符可读,则epoll_wait返回,然后通过accept获取客户端描述符。
(5)使用epoll_ctl把客户端描述符添加到epollfd指定的 epoll 内核事件表中,监听服务器端监听的描述符是否可读。
(6)当客户端描述符有数据可读时,则触发epoll_wait返回,然后执行读取。
几乎所有的epoll模型编码都是基于以下模板:
for( ; ; )
{
nfds = epoll_wait(epfd,events,20,500);
for(i=0;i<nfds;++i)
{
if(events[i].data.fd==listenfd) //有新的连接
{
connfd = accept(listenfd,(sockaddr *)&clientaddr, &clilen); //accept这个连接
ev.data.fd=connfd;
ev.events=EPOLLIN|EPOLLET;
epoll_ctl(epfd,EPOLL_CTL_ADD,connfd,&ev); //将新的fd添加到epoll的监听队列中
}
else if( events[i].events&EPOLLIN ) //接收到数据,读socket
{
n = read(sockfd, line, MAXLINE)) < 0 //读
ev.data.ptr = md; //md为自定义类型,添加数据
ev.events=EPOLLOUT|EPOLLET;
epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev);//修改标识符,等待下一个循环时发送数据,异步处理的精髓
}
else if(events[i].events&EPOLLOUT) //有数据待发送,写socket
{
struct myepoll_data* md = (myepoll_data*)events[i].data.ptr; //取数据
sockfd = md->fd;
send( sockfd, md->ptr, strlen((char*)md->ptr), 0 ); //发送数据
ev.data.fd=sockfd;
ev.events=EPOLLIN|EPOLLET;
epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev); //修改标识符,等待下一个循环时接收数据
}
else
{
//其他的处理
}
}
}
网络与IO并发
互联网应用中,多数架构是CS模式,即client发出请求,server接受请求。
这样的一次交互,伴随着client和server的IO操作。
对于常见的爬虫,client将尽可能提升其并发发送请求IO的能力。
对于角色类似被爬虫对象那些后端server,也需要尽可能提升其并发处理多client请求的能力。
- 应用进行发起read系统调用。
- 内核接受应用的请求,如果内核buf有数据,则把数据copy到应用buf中,调用结束。
- 如果内核buf中没有数据,会向io模块发送请求,io模块和硬件交互。
- 当NIC接收到协议栈的数据后, NIC 会通过DMA 技术将数据copy到内核 buf中。
- 内核将内核buf的数据copy到应用的buf中,调用结束。