30天自制C++服务器day03-高并发还得用epoll

在上一天,我们写了一个简单的echo服务器,但只能同时处理一个客户端的连接。但在这个连接的生命周期中,绝大部分时间都是空闲的,活跃时间(发送数据和接收数据的时间)占比极少,这样独占一个服务器是严重的资源浪费。事实上所有的服务器都是高并发的,可以同时为成千上万个客户端提供服务,这一技术又被称为IO复用。

IO复用和多线程有相似之处,但绝不是一个概念。IO复用是针对IO接口,而多线程是针对CPU。

IO复用的基本思想是事件驱动,服务器同时保持多个客户端IO连接,当这个IO上有可读或可写事件发生时,表示这个IO对应的客户端在请求服务器的某项服务,此时服务器响应该服务。在Linux系统中,IO复用使用select, poll和epoll来实现。epoll改进了前两者,更加高效、性能更好,是目前几乎所有高并发服务器的基石。请读者务必先掌握epoll的原理再进行编码开发。

select, poll与epoll的详细原理和区别请参考《UNIX网络编程:卷1》第二部分第六章,游双《Linux高性能服务器编程》第九章

epoll主要由三个系统调用组成:

//int epfd = epoll_create(1024);  //参数表示监听事件的大小,如超过内核会自动调整,已经被舍弃,无实际意义,传入一个大于0的数即可
int epfd = epoll_create1(0);       //参数是一个flag,一般设为0,详细参考man epoll

创建一个epoll文件描述符并返回,失败则返回-1。

epoll监听事件的描述符会放在一颗红黑树上,我们将要监听的IO口放入epoll红黑树中,就可以监听该IO上的事件。

epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);    //添加事件到epoll
epoll_ctl(epfd, EPOLL_CTL_MOD, sockfd, &ev);    //修改epoll红黑树上的事件
epoll_ctl(epfd, EPOLL_CTL_DEL, sockfd, NULL);   //删除事件

其中sockfd表示我们要添加的IO文件描述符,ev是一个epoll_event结构体,其中的events表示事件,如EPOLLIN等,data是一个用户数据union:

typedef union epoll_data {
  void *ptr;
  int fd;
  uint32_t u32;
  uint64_t u64;
} epoll_data_t;
struct epoll_event {
  uint32_t events;	/* Epoll events */
  epoll_data_t data;	/* User data variable */
} __EPOLL_PACKED;

epoll默认采用LT触发模式,即水平触发,只要fd上有事件,就会一直通知内核。这样可以保证所有事件都得到处理、不容易丢失,但可能发生的大量重复通知也会影响epoll的性能。如使用ET模式,即边缘触法,fd从无事件到有事件的变化会通知内核一次,之后就不会再次通知内核。这种方式十分高效,可以大大提高支持的并发度,但程序逻辑必须一次性很好地处理该fd上的事件,编程比LT更繁琐。注意ET模式必须搭配非阻塞式socket使用。

非阻塞式socket和阻塞式有很大的不同,请参考《UNIX网络编程:卷1》第三部分第16章。

我们可以随时使用epoll_wait获取有事件发生的fd:

int nfds = epoll_wait(epfd, events, maxevents, timeout);

其中events是一个epoll_event结构体数组,maxevents是可供返回的最大事件大小,一般是events的大小,timeout表示最大等待时间,设置为-1表示一直等待。

接下来将day02的服务器改写成epoll版本,基本思想为:在创建了服务器socket fd后,将这个fd添加到epoll,只要这个fd上发生可读事件,表示有一个新的客户端连接。然后accept这个客户端并将客户端的socket fd添加到epoll,epoll会监听客户端socket fd是否有事件发生,如果发生则处理事件。

接下来的教程在伪代码中:

int sockfd = socket(...);   //创建服务器socket fd
bind(sockfd...);
listen(sockfd...);
int epfd = epoll_create1(0);
struct epoll_event events[MAX_EVENTS], ev;
ev.events = EPOLLIN;    //在代码中使用了ET模式,且未处理错误,在day12进行了修复,实际上接受连接最好不要用ET模式
ev.data.fd = sockfd;    //该IO口为服务器socket fd
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);    //将服务器socket fd添加到epoll
while(true){    // 不断监听epoll上的事件并处理
    int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);   //有nfds个fd发生事件
    for(int i = 0; i < nfds; ++i){  //处理这nfds个事件
        if(events[i].data.fd == sockfd){    //发生事件的fd是服务器socket fd,表示有新客户端连接
            int clnt_sockfd = accept(sockfd, (sockaddr*)&clnt_addr, &clnt_addr_len);
            ev.data.fd = clnt_sockfd;   
            ev.events = EPOLLIN | EPOLLET;  //对于客户端连接,使用ET模式,可以让epoll更加高效,支持更多并发
            setnonblocking(clnt_sockfd);    //ET需要搭配非阻塞式socket使用
            epoll_ctl(epfd, EPOLL_CTL_ADD, clnt_sockfd, &ev);   //将该客户端的socket fd添加到epoll
        } else if(events[i].events & EPOLLIN){      //发生事件的是客户端,并且是可读事件(EPOLLIN)
            handleEvent(events[i].data.fd);         //处理该fd上发生的事件
        }
    }
}

从一个非阻塞式socket fd上读取数据时:

while(true){    //由于使用非阻塞IO,需要不断读取,直到全部读取完毕
    ssize_t bytes_read = read(events[i].data.fd, buf, sizeof(buf));
    if(bytes_read > 0){
      //保存读取到的bytes_read大小的数据
    } else if(bytes_read == -1 && errno == EINTR){  //客户端正常中断、继续读取
        continue;
    } else if(bytes_read == -1 && ((errno == EAGAIN) || (errno == EWOULDBLOCK))){//非阻塞IO,这个条件表示数据全部读取完毕
        //该fd上数据读取完毕
        break;
    } else if(bytes_read == 0){  //EOF事件,一般表示客户端断开连接
        close(events[i].data.fd);   //关闭socket会自动将文件描述符从epoll树上移除
        break;
    } //剩下的bytes_read == -1的情况表示其他错误,这里没有处理
}

至此,day03的主要教程已经结束了,完整源代码请在code/day03文件夹,接下来看看今天的学习成果以及测试我们的服务器!

进入code/day03文件夹,使用make命令编译,将会得到serverclient,输入命令./server开始运行服务器。然后在一个新终端输入命令./client运行客户端,可以看到服务器接收到了客户端的连接请求,并成功连接。再新开一个或多个终端,运行client,可以看到这些客户端也同时连接到了服务器。此时我们在任意一个client输入一条信息,服务器都显示并发送到该客户端。如使用control+c终止掉某个client,服务器回显示这个client已经断开连接,但其他client并不受影响。

至此,我们已经完整地开发了一个echo服务器,并且支持多个客户端同时连接,为他们提供服务!

完整源代码:https://github.com/yuesong-feng/30dayMakeCppServer/tree/main/code/day03

你可能感兴趣的:(Linux,服务器,C/C++,服务器,c++,linux)