从零实现Web服务器(一): EPOLL, Reactor和Proactor, 五种IO模型,LT和ET

文章目录

  • 一、Web服务器如何和客户端进行通信
  • 二、Web服务器如何接收客户端发来的HTTP请求报文
  • 三、Reactor和Proactor
  • 四、五种IO模型
  • 五、select,poll和epoll
  • 六、LT(电平触发)和ET(边缘触发)


一、Web服务器如何和客户端进行通信

通常用户使用Web浏览器与相应服务器进行通信。在浏览器中键入“域名”或“IP地址:端口号”,浏览器则先将你的域名解析成相应的IP地址或者直接根据你的IP地址向对应的Web服务器发送一个HTTP请求。这一过程首先要通过TCP协议的三次握手建立与目标Web服务器的连接,然后HTTP协议生成针对目标Web服务器的HTTP请求报文,通过TCP、IP等协议发送到目标Web服务器上。

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

模版如下:

#include
#include
//创建监听的socket文件描述符
int listenfd = socket(PF_INET,SOCK_STREAM,0);
//创建监听socket的IPV4地址并且允许端口被重复使用
struct sockaddr_in address;
bzero(&address,sizeof(address));
address.sin_family = AF_INET;
address.sin_addr.s_addr = htonl(INADDR_ANY);
address.sin_port = htons(port);
int flag = 1;
setsockopt(listenfd,SOL_SOCKET,SO_REUSEADDR,&flag,sizeof(flag));
//将socket和上述IPV4地址进行绑定
ret = bind(listenfd,(struct sockaddr*)&address,sizeof(address));
//创建监听队列用来存放待处理的客户连接,在这些客户连接被accept之前
ret = listen(listenfd,5);

远端的很多用户会尝试去connect()这个Web Server上正在listen的这个port,而监听到的这些连接会排队等待被accept()。

由于用户连接请求是随机到达的异步事件,每当监听socket(listenfd)listen到新的客户连接并且放入监听队列,我们都需要告诉我们的Web服务器有连接来了,accept这个连接,并分配一个逻辑单元来处理这个用户请求。而且,我们在处理这个请求的同时,还需要继续监听其他客户的请求并分配其另一逻辑单元来处理(并发,同时处理多个事件,后面会提到使用线程池实现并发)。

这里,服务器通过epoll这种I/O复用技术(还有select和poll)来实现对监听socket(listenfd)和连接socket(客户请求)的同时监听。

注意到当有多个文件描述符同时就绪的时候,如果不采取额外措施,程序则只能按顺序处理其中就绪的每一个文件描述符,所以为提高效率,我们将在这部分通过线程池来实现并发(多线程并发),为每个就绪的文件描述符分配一个逻辑单元(线程)来处理。

代码如下:

#include
//将listenfd(fd)上的EPOLLIN和EPOLLET事件注册到epollfd指示的epoll内核事件中
void addfd(int epollfd,int fd,bool one_shot)
{
     epoll_event event;
     event.data.fd = fd;
     event.events = EPOLLIN | EPOLLET | EPOLLRDHUP;
     //针对connfd,我们开启EPOLLONESHOT,因为我们希望每一个socket在任意时刻都只被一个线程处理
     if(one_shot)
        event.events |= EPOLLONESHOT;
     epoll_ctl(epollfd,EPOLL_CTL_ADD,fd,&event);
     setnonblocking(fd);
}
//创建一个额外的文件描述符来唯一标识内核中的epoll事件表
int epollfd = epoll_create(5);
//创建一个用于存储epoll事件表中就绪事件的event数组
epoll_event events[max_event_number];
//主线程往epoll内核事件表中首先注册监听socket事件,当listen到新的客户连接的时候,就把listenfd变为就绪事件。
addfd(epollfd,listenfd,false);

//随后,主线程将当前所有就绪的epoll_events复制到events数组中
int number = epoll_wait(epollfd,events,max_event_number,-1);

//然后我们遍历数组处理这些已经就绪的事件,注意只要处理就绪的事件就行,select是遍历所有的fd
for(int i = 0;i<number;i++)
{
   int sockfd = events[i].data.fd;
   if(sockfd == listenfd){
       //这里呼应上面说的,当listen到新的用户连接,listenfd上就产生相应的就绪事件
       struct sockaddr_in client_address;
       socklen_t client_addrlength = sizeof(client_address);
       //ET
       while(1)
       {
          /*accept()返回一个新的socket文件描述符用于send()和revcv()
          int connfd = accept(listenfd,(struct sockaddr*)&client_address,&client_addrlength);
          //并且将connfd注册到内核事件表中
          users[connfd].init(connfd,client_address);
         }
    }
    else if(events[i].events & (EPOLLRDHUP | EPOLLHUP | EPOLLERR)){
       //如果有异常,就直接关闭客户连接,并且删除该用户的timer
    }
    else if(events[i].events & EPOLLIN){
        //当这一sockfd上有可读事件的时候,epoll_wait通知主线程
        if(users[sockfd].read()){
            //主线程从这一个sockfd循环读取数据,直到没有更多数据可以读
            pool->append(users + sockfd); //将读取到的数据封装成一个请求对象并插入请求队列pool中
        }
        else{
        /* ... */
        }
    }
    else if(events[i].events & EPOLLOUT)
    {
      /*当这一sockfd上有可写事件的时候,epoll_wait通知主线程,主线程王socket上写入服务器处理客户请求的结果
      if(users[sockfd].write())
      {
         /* ... */
      }
      else
    }
   

三、Reactor和Proactor

服务器程序通常需要处理三类事件:I/O事件,信号及定时事件。有两种事件处理模式:

  • Reactor模式:要求主线程只负责监听文件描述符上是否有事件发生(可读、可写),若有,则立即通知工作线程(逻辑单元),将socket可读可写事件放入请求队列,交给工作线程处理。(同步IO)

  • Proactor模式:将所有的I/O操作都交给主线程和内核来处理(进行读、写),工作线程仅负责处理逻辑,如主线程读完成后users[sockfd].read(),选择一个工作线程来处理客户请求pool->append(users + sockfd)。(异步IO)

四、五种IO模型

五种IO模型分别是阻塞IO模型、 非阻塞IO模型、 IO复用模型、 信号驱动IO模型、 异步IO模型。前4种为同步IO操作,只有异步IO模型是异步IO操作。

  • 同步IO操作: 在一个线程中,CPU执行代码的速度极快,然而,一旦遇到IO操作,如读写文件、发送网络数据时,就需要等待IO操作完成,才能继续进行下一步操作。这种情况称为同步IO。

  • 异步IO操作:当代码需要执行一个耗时的IO操作时,它只发出IO指令,并不等待IO结果,然后就去执行其他代码了。一段时间后,当IO返回结果时,再通知CPU进行处理。

五、select,poll和epoll

上文提到,IO复用模型是五种IO模型中的一种,且属于同步IO操作。在Linux下,有三种IO复用方式: epoll, select 和 poll

  • 对于select和poll来说,所有文件描述符都是在用户态被加入其文件描述符集合的,每次调用都需要将整个集合拷贝到内核态;epoll则将整个文件描述符集合维护在内核态,每次添加文件描述符的时候都需要执行一个系统调用。系统调用的开销是很大的,而且在有很多短期活跃连接的情况下,epoll可能会慢于select和poll由于这些大量的系统调用开销。
  • select使用线性表描述文件描述符集合,文件描述符有上限;poll使用链表来描述;epoll底层通过红黑树来描述,并且维护一个ready list,将事件表中已经就绪的事件添加到这里,在使用epoll_wait调用时,仅观察这个list中有没有数据即可。
  • select和poll的最大开销来自内核判断是否有文件描述符就绪这一过程:每次执行select或poll调用时,它们会采用遍历的方式,遍历整个文件描述符集合去判断各个文件描述符是否有活动;epoll则不需要去以这种方式检查,当有活动产生时,会自动触发epoll回调函数通知epoll文件描述符,然后内核将这些就绪的文件描述符放到之前提到的ready list中等待epoll_wait调用后被处理。
  • select和poll都只能工作在相对低效的LT模式下,而epoll同时支持LT和ET模式。
    综上,当监测的fd数量较小,且各个fd都很活跃的情况下,建议使用select和poll;当监听的fd数量较多,且单位时间仅部分fd活跃的情况下,使用epoll会明显提升性能。

六、LT(电平触发)和ET(边缘触发)

select和poll都只提供了一个函数:select或者poll函数。
而epoll提供了三个函数,epoll_create,epoll_ctl和epoll_wait,epoll_create是创建一个epoll句柄;epoll_ctl是注册要监听的事件类型;epoll_wait则是等待事件的产生。

epoll除了提供select/poll那种IO事件的水平触发(Level Triggered)外,还提供了边缘触发(Edge Triggered),这就使得用户空间程序有可能缓存IO状态,减少epoll_wait/epoll_pwait的调用,提高应用程序效率。

  • LT(水平触发/电平触发)
    只要文件描述符关联的读内核缓冲区非空,有数据可以读取,就一直发出可读信号进行通知,
    当文件描述符关联的内核写缓冲区不满,有空间可以写入,就一直发出可写信号进行通知
    LT模式支持阻塞和非阻塞两种方式。epoll默认的模式是LT。

  • ET(边缘触发)
    当文件描述符关联的读内核缓冲区由空转化为非空的时候,则发出可读信号进行通知,当文件描述符关联的内核写缓冲区由满转化为不满的时候,则发出可写信号进行通知两者的区别在哪里呢?水平触发是只要读缓冲区有数据,就会一直触发可读信号,而边缘触发仅仅在空变为非空的时候通知一次。

注意到,使用ET的时候必须要保证该文件描述符是非阻塞的,并且每次调用read 和 write的时候都必须等他们返回EWOULDBLOCK(确保所有数据都已经读完或者已经写完)


你可能感兴趣的:(服务端开发,服务器,前端,网络)