目录
select
select 是 Linux 为我们提供的多路转接方案的一种。
上一篇中我们说过,IO = 等待 + 数据拷贝,select 做的工作就是一次性帮我们等待多个文件描述符,数据就绪后再调用recv/recvfrom/read等方法进行读取。
select 函数
#include <sys/select.h> int select(int nfds, fd_set* readfds, fd_set* writefds, fd_set* exceptfds, struct timeval* timeout);
参数:(除了第一个,后面的都是输入输出型参数)
- nfds:需要监视的文件描述符的最大值+1。
- readfds:调用时让内核监视哪些文件描述符的读事件就绪,返回时内核告知哪些文件描述符的读事件已经就绪。
- writefds:调用时让内核监视哪些文件描述符的写事件就绪,返回时内核告知哪些文件描述符的写事件已经就绪。
- exceptfds:调用时让内核监视哪些文件描述符的异常事件就绪,返回时内核告知哪些文件描述符的异常事件已经就绪。
- timeout:调用时设置select的等待时间,返回时表示timeout的剩余时间。
返回值:
- 函数调用成功,返回就绪的文件描述符个数。
- timeout时间耗尽,返回0,也就没有文件描述符就绪。
- 调用失败返回-1,错误码被设置。
我们先来看一看最后一个参数,这是一个时间戳。和我们使用的time函数是类似的,但是它多了一个微秒参数,使用gettimeofday就可以获取时间戳,参数一个是timeval,另个是timezone,就是时区,设置成nullptr就是东八区。
int main() { while (1) { std::cout << "time: " << (unsigned long)time(nullptr) << std::endl; struct timeval currtime = {0, 0}; int n = gettimeofday(&currtime, nullptr); assert(n == 0); (void)n; std::cout << "gettimeofday: " << currtime.tv_sec << "." << currtime.tv_usec << std::endl; sleep(1); } }
select等待多个fd也是可以选择策略的:
- 如果timeval为nullptr,那就是阻塞式的等。
- 如果timeval为{0, 0},那就是非阻塞式的。
- 如果timeval为{n, 0},那就是在n秒内阻塞,时间到,立马返回。
再来看中间三个参数,select可以将传入的文件描述符的作用分成这三类,想让操作系统帮我关心哪种事件,就传入哪种文件描述符,传入的是fd_set这个类型,这是一个文件描述符集,文件描述符本质是0、1、2这种下标,所以它也是位图结构,就像信号集一样,但是这个位图结构不能使用按位操作的方式添加文件描述符,必须使用操作系统提供的内置方法。
void FD_CLR(int fd, fd_set *set); // 在位图结构中清除某个文件描述符 int FD_ISSET(int fd, fd_set *set); // 判断一个文件描述符是否在位图中 void FD_SET(int fd, fd_set *set); // 将一个文件描述符设置进位图结构 void FD_ZERO(fd_set *set); // 将整个位图结构清空
我们挑一个readfds来说明:
- 在输入时,用户要告知内核,我传入的位图结构中的比特位的位置表示文件描述符的值,每个位置的值就表示是否关心,1为关心,0为不关心。
- 在输出时,内核要告知用户,我输出的位图结构中的比特位的位置表示文件描述符的值,每个位置的值就表示是否就绪,1为就绪,0为未就绪。
- 所以后续可以直接读取就绪的文件描述符,而不会被阻塞。
- 而且用户和内核后悔修改同一个位图结构,这个参数用了一次之后,一定要重新设定。
- 所以 writefds 和 exceptfds 的意思也是差不多的。
参数我们看完了,那我们再来看看这个fd_set结构是什么。
select 服务器
有了上面的知识,我们已经可以调用select了,那我们就可以把原来写过的服务器改为select版本。原来我们写过的套接字代码就不再重新写了,我们只写服务器的代码,这里的区别就是把Sock类中的方法变成了静态方法,就不用在服务器类中添加Sock对象了。
#ifndef _SELECT_SVR_H_ #define _SELECT_SVR_H_ #include <iostream> #include <sys/select.h> #include "Sock.hpp" using namespace std; class SelectServer { public: SelectServer(const uint16_t& port = 8080) : _port(port) { _listensock = Sock::Socket(); Sock::Bind(_listensock, _port); Sock::Listen(_listensock); logMessage(DEBUG, "create base socket success"); } void Start() { while (true) { // } } ~SelectServer() { if (_listensock >= 0) close(_listensock); } private: uint16_t _port; int _listensock; }; #endif
到这里我们完成了创建套接字,绑定和监听,下面就是Accept了,那我们就要重新理解一下listensock,除了默认打开的文件描述符,还有一个就是listensock,它用来帮我们获取新连接,这个操作也可以是一次IO,类似与listensock的读事件,但是如果没有连接到来,accept就会阻塞,那和之前的写法就没区别了。
所以我们不能直接调用accept,先把listensock添加到select中。
class SelectServer { // ... void Start() { fd_set rfds; // 读文件描述符集 FD_ZERO(&rfds); while (true) { FD_SET(listensock, &rfds); // 将listensock添加到文件描述符集 int n = select(_listensock + 1, &rfds, nullptr, nullptr, nullptr); switch(n) { case 0: logMessage(DEBUG, "time out..."); break; case -1: logMessage(WARNING, "select error: %d : %s", errno, strerror(errno)); break; default: // 成功,下面就是调用处理函数 break; } } } // ... };
这样就是阻塞的方式等待,当然也可以给select设置timeout时间,比如让他每五秒返回一次,如果有就处理,如过没有就继续select,就在这5s中就可以让服务器干一些其他事。
class SelectServer { public: void Start() { fd_set rfds; // 读文件描述符集 FD_ZERO(&rfds); while (true) { // ... switch(n) { // ... default: // 成功,下面就是调用处理函数 HandlerEvent(rfds); break; } } } private: void HandlerEvent(const fd_set& rfds) { string clientip; uint16_t clientport = 0; if (FD_ISSET(_listensock, &rfds)) { // _listensock获取了新的连接 int sock = Sock::Accept(_listensock, &clientip, &clientport); if (sock < 0) { logMessage(WARNING, "accept error"); return; } logMessage(DEBUG, "get a new link success: [%s:%d]: %d", clientip.c_str(), clientport, sock); } } }
现在我们就可以获取新连接了,之后就是我们可以读数据吗,答案是不可以,因为现在只是获取了连接,但是不清楚sock上的数据什么时候到,正所谓IO=等待+拷贝,所以还要select帮我们等待sock上的数据是否就绪。
现在又有一个问题了,以后从listensock获取的sock会越来越多,添加到select中的也会增加,fds一定每次都要变化,因为他是输入输出型参数,如果有timeout也要改变。
所以有了这些原因,我们需要将文件描述符单独保存起来,用来更新最大的fd,还要更新文图结构,这就需要说明一下select的讲解规则。
select 编写规则
下面就是select的编写规则:
- 先初始化服务器,创建套接字、绑定和监听。
- 需要有一个第三方数组,用来保存所有的合法的文件描述符。
- 将listensock添加到数组中,之后有新连接到来就添加到数组。
#define BITS 8 #define NUM sizeof(fd_set)*BITS #define FD_NONE -1 class SelectServer { public: SelectServer(const uint16_t& port = 8080) : _port(port) { // 初始化fd数组,将每个值都设置为无效 for (int i = 0; i < NUM; i++) _fd_array[i] = FD_NONE; // 并且规定数组0号下标就是listensock _fd_array[0] = _listensock; } void Start() { fd_set rfds; // 读文件描述符集 while (true) { DebugPrint(); // 打印有效的文件描述符 FD_ZERO(&rfds); int maxfd = 0; for (int i = 0; i < NUM; i++) { if (_fd_array[i] == FD_NONE) continue; FD_SET(_fd_array[i], &rfds); if (maxfd < _fd_array[i]) maxfd = _fd_array[i]; } int n = select(maxfd + 1, &rfds, nullptr, nullptr, nullptr); switch(n) { case 0: logMessage(DEBUG, "time out..."); break; case -1: logMessage(WARNING, "select error: %d : %s", errno, strerror(errno)); break; default: // 成功,下面就是调用处理函数 HandlerEvent(rfds); break; } } } private: void HandlerEvent(const fd_set& rfds) { string clientip; uint16_t clientport = 0; if (FD_ISSET(_listensock, &rfds)) { // _listensock获取了新的连接 int sock = Sock::Accept(_listensock, &clientip, &clientport); if (sock < 0) { logMessage(WARNING, "accept error"); return; } logMessage(DEBUG, "get a new link success: [%s:%d]: %d", clientip.c_str(), clientport, sock); int pos = 0; // 找到可以插入的位置 while (_fd_array[pos] != FD_NONE) pos++; if (pos == NUM) { // 说明数组中fd已经满了 logMessage(WARNING, "%s:%d", "select server already full, close: %d", sock); close(sock); } else { _fd_array[pos] = sock; } } } void DebugPrint() { cout << "fd_array[]: "; for (int i = 0; i < NUM; i++) { if (_fd_array[i] == FD_NONE) continue; cout << _fd_array[i] << " "; } cout << endl; } private: int _fd_array[NUM]; };
现在我们就可以保存所有的有效的文件描述符,但是还有一个问题,获取的sock有两类:
- 一类是listensock,用来获取连接。
- 一类是普通的sock,用来接收和读取客户端信息的。
所以在HandlerEvent中就要区分一下。
void HandlerEvent(const fd_set& rfds) { for (int i = 0; i < NUM; i++) { if (_fd_array[i] == FD_NONE) continue; // 去掉不合法的fd if (FD_ISSET(_fd_array[i], &rfds)) // 判断文件描述符是否就绪 { if (_fd_array[i] == _listensock) { // listensock就绪,连接事件到来 Accepter(); } else { // 其他sock就绪,读事件到来,本次fd上的数据就绪,不会被阻塞 Recver(i); } } } } void Accepter() { string clientip; uint16_t clientport = 0; // _listensock获取了新的连接 int sock = Sock::Accept(_listensock, &clientip, &clientport); if (sock < 0) { logMessage(WARNING, "accept error"); return; } logMessage(DEBUG, "get a new link success: [%s:%d]: %d", clientip.c_str(), clientport, sock); int pos = 0; while (_fd_array[pos] != FD_NONE) pos++; if (pos == NUM) { // 说明数组中fd已经满了 logMessage(WARNING, "%s:%d", "select server already full, close: %d", sock); close(sock); } else { _fd_array[pos] = sock; } } void Recver(int pos) { char buffer[1024]; int n = recv(_fd_array[pos], buffer, sizeof(buffer) - 1, 0); if (n > 0) { buffer[n] = 0; logMessage(DEBUG, "client[%d]# %s", _fd_array[pos], buffer); } else if (n == 0) { logMessage(DEBUG, "client[%d]: quit, me too...", _fd_array[pos]); // 对端已经关闭,关闭对应文件描述符,把该文件描述符从数组中去掉 close(_fd_array[pos]); _fd_array[pos] = FD_NONE; } else { logMessage(WARNING, "%d sock recv error, %d : %s", _fd_array[pos], errno, strerror(errno)); // 读取错误,关闭对应文件描述符,把该文件描述符从数组中去掉 close(_fd_array[pos]); _fd_array[pos] = FD_NONE; } }
- 如果有连接就获取连接。
- 如果有其他sock就绪就调用recv等接口。
- 如果对端关闭或读取错误就要先关闭文件描述符,再把该文件描述符从数组中去掉。
所以这个代码没有使用多线程和多进程,依然可以实现并发访问,但是还有问题要注意的就是:
- 这个服务器使用的是TCP协议,TCP是面向字节流的,所以就会出现丢包、粘包等问题,在应用层协议的篇章已经详细讲解了如何处理这种问题,如何序列化和反序列化,如何检验是一个完整的报文,所有在读取数据时,要先检查一下,这里我们就不处理,等到后面会解决这个问题的。
- 服务器可以读取数据了,但是还要解决给客户端发的问题,不可以直接使用writer这样的接口,如果对方没有把数据读上去,就会阻塞住,所以需要再定义一个输入数组和输入文件描述符集。
select 的优点
它的优点是与我们之前写过的代码相比的,也是任何一个多路转接方案的优点。
- 它可以同时等待多个文件描述符,而读取或写入的操作都是系统调用接口,这些接口并不会被阻塞,将等的时间重叠,提高了IO的效率。
- 在单执行流中,如果有大量的连接,其中只有少量活跃的,那么它的效率是很高的;如果使用多执行流,每一个文件描述符就是一个执行流,少量的活跃执行流,那么其他资源就会被浪费,所以它还可以省资源。
select 的缺点
- 为了维护文件描述符数组,select 会有大量的遍历操作,所以如果有大量的连接,而且很活跃,这时 select 的效率还会变低。
- 每一次都要重新设定 select 的参数。
- 使用的fd_set是一个类型,只要是类型就会有上限,能接收的文件描述符就有限。
- 它的参数几乎都是输入输出型参数,那就一定会频繁的进行用户和内核之间的拷贝。
- 基于以上几个缺点,导致了它的编码比较复杂。
有了这些缺点,才会有其他的方案,下面要介绍的方案就是poll。
poll
poll也是一种多路转接的方式,和 select 一样,它只负责等待,函数调用的时候,用户告诉内核要等待哪些文件描述符的哪些事件,返回的时候,内核告诉用户哪些fd的哪些事件已经就绪。
poll 函数
#include <poll.h> int poll(struct pollfd *fds, nfds_t nfds, int timeout);
参数:
- fds:poll函数监视的结构体数组,fds为首元素的地址,每一个元素包含三部分内容:文件描述符、监视的事件集合、就绪的事件集合。
- nfds:fds数组的长度。
- timeout:表示poll的超时时间,单位为毫秒。传入n就等待n毫秒返回,传入0就代表非阻塞式,传入-1就代表阻塞式。
返回值:
- 大于0,就代表就绪几个文件描述符。
- 等于0,就代表timeout超时了。
- 小于0,就代表poll失败了。
我们再来看一看struct pollfd结构体中有什么参数。
struct pollfd { int fd; /* file descriptor */ short events; /* requested events */ short revents; /* returned events */ };
- fd:就是特定的文件描述符。
- events:需要监视该文件描述符上的哪些事件。
- revents:poll返回时告知用户该fd上的哪些事件已经就绪了。
这是个结构体,不像fd_set是一个类型,它里面有不同成员要解决不同的问题。不管是传参还是返回时,都不会去修改fd,因为下面两个参数都是关于这个fd的,而events是让内核看的也不会修改这个变量,而内核等待这个fd的事件就绪后,写到revents中,所以解决了 select 每次都要重新设置参数的缺点。
而fd_set是一个类型,最大传入的值就是1024个fd,但是如果用户想,也可以给nfds设置更大的数值,因为它没有文件描述符上限。
其他的都说完了,我再看看events怎么设置。
事件 描述 可否作为输入 可否作为输出 POLLIN 数据(包括普通数据和优先数据)可读 是 是 POLLRDNORM 普通数据可读 是 是 POLLRDBAND 优先级带数据可读(Linux不支持) 是 是 POLLPRI 高优先级数据可读,比如TCP带外数据,urg标志位 是 是 POLLOUT 数据(包括普通数据和优先数据)可写 是 是 POLLWRNORM 普通数据可写 是 是 POLLWRBAND 优先级带数据可写 是 是 POLLRDHUP TCP连接被对方关闭,或者对方关闭了写操作,它由GNU引入 是 是 POLLERR 错误 否 是 POLLHUP 挂起。比如管道的写端被关闭后,读端描述符上将收到POLLHUP事件 否 是 POLLNVAL 文件描述符没有打开 否 是
常用的还是加黑的这几个,而且这些events都是大写,也是位图结构,传参的时候使用按位或传参。
poll 服务器
现在我们只需要修改一下 select服务器 就可以让它变成 poll服务器。
#include <poll.h> #define FD_NONE -1 class PollServer { public: static const int nfds = 100; public: PollServer(const uint16_t& port = 8080) : _port(port) , _nfds(nfds) { _listensock = Sock::Socket(); Sock::Bind(_listensock, _port); Sock::Listen(_listensock); logMessage(DEBUG, "create base socket success"); // 申请_nfds个struct pollfd结构体 _fds = new struct pollfd[_nfds]; // 初始化_fds for (int i = 0; i < _nfds; i++) { _fds[i].fd = FD_NONE; _fds[i].events = _fds[i].revents = 0; } // 将_listensock设置到_fds,并设置events为POLLIN,关心读事件 _fds[0].fd = _listensock; _fds[0].events = POLLIN; _timeout = 1000; } ~PollServer() { if (_listensock >= 0) close(_listensock); if (_fds) delete[] _fds; } private: uint16_t _port; int _listensock; struct pollfd* _fds; int _nfds; int _timeout; };
接下来就是Start函数和HandlerEvent函数。
void Start() { while (true) { int n = poll(_fds, _nfds, _timeout); switch(n) { case 0: logMessage(DEBUG, "time out..."); break; case -1: logMessage(WARNING, "poll error: %d : %s", errno, strerror(errno)); break; default: // 成功,下面就是调用处理函数 HandlerEvent(); break; } } } void HandlerEvent() { for (int i = 0; i < _nfds; i++) { if (_fds[i].fd == FD_NONE) continue; // 去掉不合法的fd if (_fds[i].revents & POLLIN) // 判断文件描述符是否就绪 { if (_fds[i].fd == _listensock) { // listensock就绪,连接事件到来 Accepter(); } else { // 其他sock就绪,读事件到来,本次fd上的数据就绪,不会被阻塞 Recver(i); } } }
因为和select一样,只处理的读事件,所以再来写一下 Accepter 和 Recver 这两个函数。
void Accepter() { string clientip; uint16_t clientport = 0; // _listensock获取了新的连接 int sock = Sock::Accept(_listensock, &clientip, &clientport); if (sock < 0) { logMessage(WARNING, "accept error"); return; } logMessage(DEBUG, "get a new link success: [%s:%d]: %d", clientip.c_str(), clientport, sock); int pos = 1; while (_fds[pos].fd != FD_NONE) pos++; if (pos == _nfds) { // 说明数组中fd已经满了,这里也可以设置成动态的,可以扩容 logMessage(WARNING, "%s:%d", "poll server already full, close: %d", sock); close(sock); } else { _fds[pos].fd = sock; _fds[pos].events = POLLIN; } } void Recver(int pos) { char buffer[1024]; int n = recv(_fds[pos].fd, buffer, sizeof(buffer) - 1, 0); if (n > 0) { buffer[n] = 0; logMessage(DEBUG, "client[%d]# %s", _fds[pos].fd, buffer); } else if (n == 0) { logMessage(DEBUG, "client[%d]: quit, me too...", _fds[pos].fd); // 对端已经关闭,关闭对应文件描述符,把该文件描述符从数组中去掉 close(_fds[pos].fd); _fds[pos].fd = FD_NONE; _fds[pos].events = _fds[pos].revents = 0; } else { logMessage(WARNING, "%d sock recv error, %d : %s", _fds[pos].fd, errno, strerror(errno)); // 读取错误,关闭对应文件描述符,把该文件描述符从数组中去掉 close(_fds[pos].fd); _fds[pos].fd = FD_NONE; _fds[pos].events = _fds[pos].revents = 0; } }
poll 的优点
- 效率高也是多路转接方案共同的优点。
- 适应的场景就是有大量的连接,只有少量是活跃的,节省了资源。
- 输入输出参数分离,不需要重新设置。
- poll的参数可以设置大量的struct pollfd,没有上限。
poll 的缺点
- poll 依旧需要多次遍历,在用户层检测时间就绪,在内核检测fd就绪,在连接多的时候效率也会降低。
- poll 也需要用户到内核之间的拷贝,这个操作也是少不了的。
- poll 的代码还是有些复杂。
epoll
epoll 是一个多路转接接口,与其他多路转接方案一样,它也可以同时等待多个文件描述符,e 可以理解为extend扩展的意思,相比于poll扩展了一些功能。
epoll 的系统调用
第一个系统调用就是epoll_create,用于创建一个epoll模型。
#include <sys/epoll.h> int epoll_create(int size);
参数:如今的服务器,size参数是被忽略的,设置成256等即可。
返回值:epoll 模型创建成功返回对应的文件描述符,失败返回-1,错误码被设置。
【注意】:不再使用时,必须调用close关闭epoll模型对应的文件描述符。
第二个系统调用就是epoll_ctl,作用就是对epoll模型操作。
#include <sys/epoll.h> int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
参数:
- epfd:对指定的epoll模型进行修改。
- op:表示具体的动作。
- fd:需要添加到epoll模型中的文件描述符。
- event:需要监视该文件描述符上的哪些事件。
参数op的选项:
- EPOLL_CTL_ADD:注册新的文件描述符到指定的epoll模型中。
- EPOLL_CTL_MOD:修改已经注册的文件描述符的监听事件。
- EPOLL_CTL_DEL:从epoll模型中删除指定的文件描述符。
返回值:
- 函数成功返回0,失败返回-1,错误码被设置。
event参数:
先看一下struct epoll_event的结构:
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 */ };
events的常用值:
事件 描述 EPOLLIN 表示对应的文件描述符可以读。 EPOLLOUT 表示对应的文件描述符可以写。 EPOLLPRI 表示对应的文件描述符有紧急的数据可读。(TCP中URG标志位) EPOLLERR 表示对应的文件描述符发送错误。 EPOLLHUP 表示对应的文件描述符被挂断,即对端将文件描述符关闭了。 EPOLLET 将epoll的工作方式设置为边缘触发(Edge Triggered)模式。 EPOLLONESHOT 只监听一次事件,当监听完这次事件之后,如果还需要继续监听该文件描述符的话,需要重新将该文件描述符添加到epoll模型中。
第三个系统调用就是epoll_wait,用于收集监视的事件中已经就绪的事件。
#include <sys/epoll.h> int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
参数:
- epfd:指定的epoll模型。
- events:输出型参数,内核将已经就绪的事件拷贝到events数组中,events数组一定是一个已经分配内存和初始化的一块空间。
- maxevents:events数组中的元素个数,该值不能大于创建epoll模型时传入的size值。
- timeout:epoll_wait的超时时间,单位是毫秒,且参数意义与poll一样。
返回值:
- 函数调用成功返回有事件就绪的文件描述符个数。
- timeout时间耗尽返回0。
- 函数调用失败返回-1,错误码被设置。
- 错误码可被设置为:
- EBADF:传入的epoll模型对应的文件描述符无效。
- EFAULT:events指向的数组空间无法通过写入权限。
- EINTR:此调用被信号中断。
- EINVAL:epfd不是epoll模型对应的文件描述符,或者maxevents<0
epoll 的工作原理
我们知道了网络的知识,网路中的数据是从网卡中来的,网卡是个硬件,向上是网卡驱动,但是OS是怎么知道网卡中有数据到来呢,OS如何知道底层硬件或者外设有数据到来呢?
实际上OS采用的是硬件中断的方式。
当我们创建一个epoll模型时,OS会帮我们创建一棵红黑树,如何操作OS会帮我们,数中的每个结点都是结构体struct rb_node。
这棵树中的每个结点就相当于select和poll我们自己维护的数组,除了将结点插入红黑树中,OS还要向网卡驱动注册一个回调方法。
select和poll都要遍历数组中所有的结点,等待某个文件描述符,本质上是进程或线程在等待,等待时OS就会把执行流的PCB链入文件描述符匹配的struct file中,一旦数据就绪,OS就会唤醒对应的执行流,就这样依次遍历数组。
回调函数就不一样,当底层数据因中断等情况到达时,会自动调用回到函数。
下面就是就绪队列。
这个队列中都是已经就绪的结构体,所以可以以O(1)的时间复杂度获取就绪结点,不需要查找红黑树。
所以epoll的工作原理:
- 调用epoll_create,创建epoll模型,之后就是创建红黑树,建立底层回调机制,构建就绪队列。
- 调用epoll_ctl,对epoll模型中的红黑树结点关心的事件进行增删改的操作。
- 调用epoll_wait,从底层的就绪队列拿到数据。
所以一个进程创建epoll的时候也要分配一个文件描述符,这个文件描述符对应的struct file中就有epoll模型对应的指针,该指针中有不同的参数指向不同的结构,不同的系统调用就会修改对应的结构。
在调用epoll_ctl的时候,需要对epoll模型中的红黑树做修改,向红黑树中插入的时候也是有key值的,要不然如何确定节点的顺序,所以fd就可以作为key值。
用户在使用epoll的时候只需要使用对应的系统调用。而不需要关心fd和events了。
底层只要有fd就绪,OS会帮我们构建结点,链入就绪队列,我们只需要从就绪队列中拿数据即可。所以底层帮我们向就绪队列中放数据,我们拿数据,这不就是一个生产者消费者模型吗,而且epoll也保证了线程安全。
epoll 服务器
首先就要有一个EpollServer服务器。
static const uint16_t default_port = 8080; class EpollServer { public: EpollServer(const uint16_t& port = default_port) : _port(port) { // 创建socket _listensock = Sock::Socket(); Sock::Bind(_listensock, _port); Sock::Listen(_listensock); // 创建epoll模型 _epfd = Epoll::CreateEpoll(); logMessage(DEBUG, "init success, listensock: %d, epfd: %d", _listensock, _epfd); // 将listensock设置进epoll用来获取已建立好连接的sock if (!Epoll::CtlEpoll(_epfd, EPOLL_CTL_ADD, _listensock, EPOLLIN)) exit(6); logMessage(DEBUG, "add listensock to epoll success"); } ~EpollServer() { if (_listensock > 0) close(_listensock); if (_epfd > 0) close(_epfd); } private: int _listensock; uint16_t _port; int _epfd; };
在这之中,我把epoll的接口也封装了一下。
class Epoll { public: static const int gsize = 256; public: static int CreateEpoll() { int epfd = epoll_create(gsize); if (epfd > 0) return epfd; exit(5); } static bool CtlEpoll(int epfd, int op, int fd, uint32_t events) { struct epoll_event ev; ev.events = events; ev.data.fd = fd; int n = epoll_ctl(epfd, op, fd, &ev); return n == 0; } };
服务器的初始化已经做完了,接下来就是等待listensock有连接到来,使用的系统调用是epoll_wait,需要注意的就是它的参数,需要有一块空间保存从就绪队列中拿到的数据。
static const int gnum = 64; class EpollServer { public: EpollServer(const uint16_t& port = default_port) : _port(port) , _revs_num(gnum) { // 申请struct epoll_event的空间 _revs = new struct epoll_event[_revs_num]; // 创建socket // 创建epoll模型 // 将listensock设置进epoll用来获取已建立好连接的sock } ~EpollServer() { // ... if (_revs) delete[] _revs; } private: // ... struct epoll_event* _revs; int _revs_num; };
再封装一下epoll_wait。
static int WaitEpoll(int epfd, struct epoll_event revs[], int num, int timeout) { return epoll_wait(epfd, revs, num, timeout); }
如果底层就绪的sock很多,revs的空间装不下怎么办呢?这是没有影响的,一次拿不完,下一次拿也可以。
而且epoll_wait返回值大于0就表示有n个文件描述符就绪,遍历n次就可以,而且这个n个在revs中是按序排好的。
epoll_wait写好后,剩下的和select和poll的逻辑是差不多的。
class EpollServer { void Start() { while (true) { int n = Epoll::WaitEpoll(_epfd, _revs, _revs_num, 5000); switch(n) { case 0: logMessage(DEBUG, "timeout..."); break; case -1: logMessage(WARNING, "epoll wait error: %s", strerror(errno)); break; default: HandlerEvent(n); break; } } } void HandlerEvent(int n) { for (int i = 0; i < n; i++) { uint32_t revents = _revs[i].events; int fd = _revs[i].data.fd; // 读事件就绪 if (revents & EPOLLIN) { // listensock就绪 if (fd == _listensock) Accepter(); // 其他sock就绪 else Recver(fd); } } } void Accepter() { std::string clientip; uint16_t clientport; int sock = Sock::Accept(_listensock, &clientip, &clientport); if (sock < 0) { logMessage(WARNING, "accept error!"); return; } // 将连接号的sock添加到epoll中 if (!Epoll::CtlEpoll(_epfd, EPOLL_CTL_ADD, sock, EPOLLIN)) return; logMessage(DEBUG, "add new sock: %d to epoll success", sock); } void Recver(int fd) { char buffer[10240]; ssize_t n = recv(fd, buffer, sizeof(buffer) - 1, 0); if (n > 0) { // 假设读到了完整的数据 buffer[n] = 0; logMessage(DEBUG, "client[%d]: %s", fd, buffer); } else if (n == 0) { // 对端关闭 logMessage(NORMAL, "client %d quit, me too...", fd); // 一定是先从epoll中删除该fd bool res = Epoll::CtlEpoll(_epfd, EPOLL_CTL_DEL, fd, 0); // 删除event设置为0即可 // 再关闭文件描述符 close(fd); } else { logMessage(NORMAL, "recv errro: %s", strerror(errno)); bool res = Epoll::CtlEpoll(_epfd, EPOLL_CTL_DEL, fd, 0); close(fd); } } };
epoll 的优点
- 使用方便,不需要我们自己去维护数组,只需要调用对应的函数就可以。
- 不需要遍历确定事件是否就绪,事件就绪就会执行对应的回调函数,将数据添加到就绪队列,从队列中拿就可以。
- 拷贝较轻量,只有在新增的事件的时候调用epoll_ctl将数据拷贝到内核,epoll_wait获取的也是就绪的事件,不进行不必要的拷贝。
- 没有数量限制,可以一直向红黑树插入新的结点。
epoll 的工作方式
现在有一个点,就是如果在底层对应的文件描述符就绪了,上层没有把数据拿走,那么数据就会一直处于就绪状态,不管是select、poll还是epoll都有这个特点,这和 epoll 的工作模式是有关系的,有两种工作方式:水平触发(LT)和边缘触发(ET)。
水平触发模式(Level Triggered)。
- 只要底层有就绪的数据,epoll就会一直通知用户。
- 就像数电中的高电平触发,只要数据到来就一直处于高电平,就会一直触发。
- 如果只处理一部分,底层数据没有处理完,epoll 下次还会通知。
epoll 默认的工作方式就是水平触发。
边缘触发模式(Edge Triggered)。
- 底层有数据就绪,epoll 只会通知一次。
- 如果没有拿完时,又有数据到来,这时epoll 也会通知一次。
- 就像数电中的边缘触发,只有电平变高的一瞬间才触发。
在epoll_ctl中设置events的参数设置为EPOLLET就可以把LT模式变为ET模式。
所以以上的多路转接方案默认都是LT模式,那这两种工作模式那个更高效呢?
LT这种工作模式会重复通知上层,ET这种工作模式只有在新增的时候才会通知上层,所以ET这种工作模式效率更高。
如果使用ET模式,对端只发了一次数据,底层后续后通知上层,而上层不管,对端也不会再发送数据了,所以也就无法再获取该文件描述符的就绪事件了,也就无法调用recv等接口了,这就会导致数据的丢失,所以就要让用户必须取走全部的就绪数据。
如果使用LT模式,数据不取也不会消失,因为底层会一直通知我,但是用户还是取走全部的就绪数据,这就看出LT和ET这两种工作方式在效率上没有区别。
但为什么还说ET模式高效呢?其中一个原因上面已经说过了,还有一个原因就是网络层面的了,ET需要让用户尽快将数据拷贝到应用层缓冲区,所以就可以返回一个更大的TCP响应报文中的窗口大小,所以对方的滑动窗口就会变大,一次性发送更多的数据,提高了IO的吞吐量。
但是如果LT模式也做一样的操作呢,所以才说这两个的效率是差不多的,主要区别还是对数据如何处理。
现在我们知道了需要一次把底层就绪数据全部拿完,那么如何区分底层数据已经拿完了呢?假如我们上层的缓冲区设置的是1024字节,调用recv参数为1024 - 1,如果返回值为1023,那就证明底层至少有1023字节数据,所以我们就还可以再读,直到读取的数据不足1023了,那就证明底层已经没有数据了,所以需要循环读取。
但是最后一次读取后,一定还要进行一次读取,因为无法保证数据已经读取完毕,如果数据已经没有了,那么此次调用就会阻塞,那么该进程就会挂起,为了避免这个问题,ET模式下的sock必须设置为非阻塞,需要循环读取,直到读取失败(EAGAIN 或 EWOULDBLOCK)。