一、前言
作为一个轻量级的Web服务器,高效地处理和接受客户端发送请求并进行处理是非常关键的。
本章节主要围绕两部分来讲:
- 如何接收客户端发来的HTTP请求报文呢?
- 如何高效处理HTTP请求报文?
二、如何接收客户端发来的HTTP请求报文
Web服务器端通过socket监听
来自用户的请求。
远端的很多用户会尝试去connect()
这个Web Server正在listen
的这个port
,而监听到的这些连接会排队等待被accept()
.由于用户连接请求是随机到达的异步时间,所以监听socket(lisenfs)
lisen到的新的客户连接并且加入监听队列,当accept
这个连接时候,会分配一个逻辑单元来处理这个用户请求。
2.1 多路IO复用技术(Select、Poll与Epoll的区别)
- 调用函数
- select和poll都是一个函数,epoll是一组函数
- 文件描述符数量
- select通过线性表描述文件描述符集合,文件描述符有上限,一般是1024,但可以修改源码,重新编译内核,不推荐
- poll是链表描述,突破了文件描述符上限,最大可以打开文件的数目
- epoll通过红黑树描述,最大可以打开文件的数目,可以通过命令ulimit -n number修改,仅对当前终端有效
- 将文件描述符从用户传给内核
- select和poll通过将所有文件描述符拷贝到内核态,每次调用都需要拷贝
- epoll通过epoll_create建立一棵红黑树,通过epoll_ctl将要监听的文件描述符注册到红黑树上
- 内核判断就绪的文件描述符
- select 和 poll 每次调用都会对连接进行线性遍历,所以随着FD的增加会造成遍历速度慢的“线性下降性能问题”
- epoll 因为epoll内核种实现是根据每个fd上的callback函数来实现的,只有活跃的socket才会主动调用callback,所以在活跃socket较少的情况下,使用epoll没有前面两者的线性下降的性能问题,但是所有socket都很活跃,可能有性能问题。
- 应用程序索引就绪文件描述符
- select/poll只返回发生了事件的文件描述符的个数,若知道是哪个发生了事件,同样需要遍历
- epoll返回的发生了事件的个数和结构体数组,结构体包含socket的信息,因此直接处理返回的数组即可
- 工作模式
- select和poll都只能工作在相对低效的LT模式下
- epoll则可以工作在ET高效模式,并且epoll还支持EPOLLONESHOT事件,该事件能进一步减少可读、可写和异常事件被触发的次数。
一个socket连接在任一时刻都只被一个线程处理,可以使用 epoll 的 EPOLLONESHOT 事件实现。
- 应用场景
- 当所有的
fd
都是活跃连接,epoll需要建立文件系统,红黑树和链表对于此来说,效率反而不高,不如selece和poll - 当监测的fd数目较小,且各个fd都比较活跃,建议使用select或者poll
- 当监测的fd数目非常大,成千上万,且单位时间只有其中的一部分fd处于就绪状态,这个时候使用epoll能够明显提升性能
2.2 事件接收处理模式
并发:在处理这个请求的同时,还需要继续监听其他客户的请求并分配其另一逻辑单元来处理。
- 通过epoll 这种I/O复用技术来实现对监听socket(
listenfd
)和连接socket(客户请求)的同时监听。
虽然I/O复用可以同时监听多个文件描述符,但是它本身是
阻塞
的,并且多个文件描述符同时就绪
的时候,如果不采用额外措施,程序只能按照顺序处理其中就绪的每个文件描述符。
因此可以通过多线程并发,用线程池来实现并发,为每个就绪的文件描述符分配一个逻辑单元(线程)来处理。
服务器程序通常需要处理三类事件:I/O事件、信号、定时事件
有两种事件并发处理模式
-
Reactor模式
:要求主线程(I/O处理单元)只负责监听文件描述符上是否有事件发生(可读、可写),若有,则立即通知工作线程(逻辑单元),将socket可读可写事件放入请求队列,交给工作线程处理。 -
Proactor模式
:将所有的I/O操作都交给主线程和内核来处理(进行读、写),工作线程仅负责处理逻辑,如主线程读完成后users[sockfd].read()
,选择一个工作线程来处理客户请求pool->append(users + sockfd)
模拟Proactor模式
使用同步I/O方式模拟出Proactor模式的原理是:主线程执行数据读写操作,读写完成之后,主线程向工作线程通知这一“完成事件”。那么从工作线程的角度来看,它们就直接获得了数据读写的结果,接下来要做的只是对读写的结果进行逻辑处理。
通常使用同步I/O模型(如epoll_wait
)实现Reactor,使用异步I/O(如aio_read
和aio_write
)实现Proactor。但在此项目中,我们使用的是同步I/O模拟的Proactor事件处理模式。
使用模拟
proactor
模式,主线程负责监听,监听有事件之后,从socket中循环读取数据,然后将读取到的数据封装成一个请求对象观察入队列
以epoll_wait为例子
- 主线程往epoll内核事件表注册socket上的读就绪事件。
- 主线程调用epoll_wait等待socket上有数据可读
- 当socket上有数据可读,epoll_wait通知主线程,主线程从socket循环读取数据,直到没有更多数据可读,然后将读取到的数据封装成一个请求对象并插入请求队列。
- 睡眠在请求队列上某个工作线程被唤醒,它获得请求对象并处理客户请求,然后往epoll内核事件表中注册该socket上的写就绪事件
- 主线程调用epoll_wait等待socket可写。
- 当socket上有数据可写,epoll_wait通知主线程。主线程往socket上写入服务器处理客户请求的结果。
Linux下有三种IO复用方式:epoll
,select
、poll
,为什么使用epoll
,他和其他两个有什么区别(见上)
2.3 项目细节
(1)主线程调用不同函数进行注册,两次注册是否没有必要,直接主线程循环读取然后封装放请求队列不就行了么?
不对,如果数据一直没来,直接进行循环读取就会持续在这里发生阻塞,这就是同步IO的特点,所以一定要注册一下然后等通知,这样就可以避免长期阻塞等候数据。同步:它主线程使用epoll向内核注册读事件。但是这里内核不会负责将数据从内核读到用户缓冲区,最后还是要靠主线程也就是用户程序read()函数等负责将内核数据循环读到用户缓冲区。
Epoll对文件操作符的操作有两种模式: LT
(电平触发)、ET
(边缘触发),二者的区别在于当你调用epoll_wait
的时候内核里面发生了什么:
助理解
LT条件触发的特性:
条件触发方式中,只要输入缓冲有数据就会一直通知该事件
例如,服务器端输入缓冲收到 50 字节数据时,服务器端操作系统将通知该事件(注册到发生变化的文件描述符)。但是服务器端读取 20 字节后还剩下 30 字节的情况下,仍会注册事件。也就是说,条件触发方式中,只要输入缓冲中还剩有数据,就将以事件方式再次注册。
ET边缘触发特性:
边缘触发中输入缓冲收到数据时仅注册 1 次该事件。即使输入缓冲中还留有数据,也不会再进行注册。
默认是LT的方式
- LT(水平触发):类似
select
,LT会去遍历在epoll事件表中每个文件描述符,来观察是否有我们感兴趣的事件发生,如果有(触发了该文件描述符上的回调函数),epoll_wait
就会以非阻塞的方式返回。若该epoll事件没有被处理完(没有返回EWOULDBLOCK
),该事件还会被后续的epoll_wait
再次触发。 - ET(边缘触发):ET在发现有我们感兴趣的事件发生后,立即返回,并且
sleep
这一事件的epoll_wait
,不管该事件有没有结束。
sleep和wait的区别:
1、sleep是Thread的静态方法,wait是Object的方法,任何对象实例都能调用。
2、sleep不会释放锁,它也不需要占用锁。wait会释放锁,但调用它的前提是当前线程占有锁(即代码要在synchronized中)。当调用wait()方法的时候,线程会放弃对象锁,进入等待此对象的等待锁定池,只有针对此对象调用notify()方法后本线程才进入对象锁定池准备。
3、它们都可以被interrupted方法中断。
ET模式
缺点:应用层业务逻辑复杂,容易遗漏事件,很难用好。
优点:相对LT模式效率比较高。一触发立即处理事件。
LT模式:
优点:编程更符合用户直觉,业务层逻辑更简单。
缺点:效率比ET低。
(2)什么时候用ET,什么时候用LT?
LT适用于并发量小的情况,ET适用于并发量大的情况。
为什么?
ET在通知用户之后,就会将fd从就绪链表中删除,而LT不会,它会一直保留,这就会导致随着fd增多,就绪链表越大,每次都要从头开始遍历找到对应的fd,所以并发量越大效率越低。ET因为会删除所以效率比较高。
(3)怎么解决LT的缺点?
LT模式下,可写状态的fd会一直触发事件,该怎么处理这个问题
方法1:每次要写数据时,将fd绑定EPOLLOUT事件,写完后将fd同EPOLLOUT从epoll中移除。
方法2:方法一中每次写数据都要操作epoll。如果数据量很少,socket很容易将数据发送出去。可以考虑改成:数据量很少时直接send,数据量很多时在采用方法1.
(4)触发LT模式后,读一次还是循环读?
读一次。
(5)为什么ET模式下一定要设置非阻塞?
因为ET模式下是无限循环读,直到出现错误为EAGAIN或者EWOULDBLOCK,这两个错误表示socket为空,不用再读了,然后就停止循环了,如果是非阻塞,循环读在socket为空的时候就会阻塞到那里,主线程的read()函数一旦阻塞住,当再有其他监听事件过来就没办法读了,给其他事情造成了影响,所以必须要设置为非阻塞。
(6)epoll 和 阻塞IO 还是非阻塞IO 搭配使用
http://t.zoukankan.com/lawliet12-p-13508057.html
(7)在读取数据的时候怎么直到读取完了呢
非阻塞socket而言,EAGAIN不是一种错误。在VxWorks和Windows上,EAGAIN的名字叫做EWOULDBLOCK。
- LT水平触发模式
- epoll_wait检测到文件描述符有事件发生,则将其通知给应用程序,应用程序可以不立即处理该事件。
- 当下一次调用epoll_wait时,epoll_wait还会再次向应用程序报告此事件,直至被处理
- ET边缘触发模式
- epoll_wait检测到文件描述符有事件发生,则将其通知给应用程序,应用程序必须立即处理该事件
- 必须要一次性将数据读取完,使用非阻塞I/O,读取到出现eagain
三、小结
在Linux
下文件描述符不活跃但是大量存在的时候,优先采用epoll
这种多路复用技术,多路复用技术也是线程阻塞的,但是他可以同时阻塞多个I/O请求,这样可以极大的提高服务器的性能。
下一章 重点讲解一下服务器是如何处理HTTP请求报文的!