一、前言

作为一个轻量级的Web服务器,高效地处理和接受客户端发送请求并进行处理是非常关键的。

本章节主要围绕两部分来讲:

  • 如何接收客户端发来的HTTP请求报文呢?
  • 如何高效处理HTTP请求报文?

二、如何接收客户端发来的HTTP请求报文

C++ LinuxWebServer项目(1)如何接收请求_网络

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请求报文的!