目录
1. I/O多路转接之select
1.1.select初识
select是系统提供的一个多路转接接口。
• select系统调用可以让我们的程序同时监视多个文件描述符的上的事件是否就绪。
• select的核心工作就是等,当监视的多个文件描述符中有一个或多个事件就绪时,select才会成功返回并将对应文件描述符的就绪事件告知调用者。
• 事件就绪也就是文件描述符的状态发生变化,即可读与不可读之间转变(读事件就绪)、可写与不可写之间转变(写事件就绪)、正常与异常之间转变(异常事件)。
1.2.select函数
参数:
nfds:需要监视的文件描述符中,最大的文件描述符值+1。
readfds:输入输出型参数,调用时用户告知内核需要监视哪些文件描述符的读事件是否就绪,返回时内核告知用户哪些文件描述符的读事件已经就绪。如果不关心文件读事件就绪设置为NULL即可。
writefds:输入输出型参数,调用时用户告知内核需要监视哪些文件描述符的写事件是否就绪,返回时内核告知用户哪些文件描述符的写事件已经就绪。如果不关心文件写事件就绪设置为NULL即可。
exceptfds:输入输出型参数,调用时用户告知内核需要监视哪些文件描述符的异常事件是否就绪,返回时内核告知用户哪些文件描述符的异常事件已经就绪。如果不关心文件异常事件就绪设置为NULL即可。
timeout:输入输出型参数,调用时由用户设置select的等待时间,返回时表示timeout的剩余时间。
参数timeout的取值:NULL/nullptr:select调用后进行阻塞等待,直到被监视的某个文件描述符上的某个事件就绪。
0:selec调用后t进行非阻塞等待,无论被监视的文件描述符上的事件是否就绪,select检测后都会立即返回。
特定的时间值:select调用后在指定的时间内进行阻塞等待,如果被监视的文件描述符上一直没有事件就绪,则在该时间后select进行超时返回。
返回值:如果函数调用成功,则返回有事件就绪的文件描述符个数。
如果timeout时间耗尽,则返回0。
如果函数调用失败,则返回-1,同时错误码会被设置。
select调用失败时,错误码可能被设置为:EBADF:文件描述符为无效的或该文件已关闭。
EINTR:此调用被信号所中断。
EINVAL:参数nfds为负值。
ENOMEM:核心内存不足。
注:使用select函数需要包含<sys/select.h>头文件。
fd_set结构:
fd_set结构与sigset_t结构类似,fd_set本质也是一个位图,用位图中对应的位来表示要监视的文件描述符。
位图中比特位的位置代表fd的编号,比特位的内容代表“是否”的概念(输入时比特位为1/0代表是/否监视对应文件描述符事件就绪情况,输出时比特位为1/0代表对应文件描述符事件是/否就绪)。
调用select函数之前就需要用fd_set结构定义出对应的文件描述符集,然后将需要监视的文件描述符添加到文件描述符集当中,这个添加的过程本质就是在进行位操作,但是这个位操作不需要用户自己进行,系统提供了一组专门的接口,用于对fd_set类型的位图进行各种操作。
如下:
void FD_CLR(int fd, fd_set *set); //用来清除描述词组set中相关fd的位 int FD_ISSET(int fd, fd_set *set); //用来测试描述词组set中相关fd的位是否为真 void FD_SET(int fd, fd_set *set); //用来设置描述词组set中相关fd的位 void FD_ZERO(fd_set *set); //用来清除描述词组set的全部位
注:
1.因为select函数输入和输出用的是同一张位图,输出时会将该位图清零然后将事件就绪的文件描述符对应位置1,那么select下次再等时也就失去了输入位图,不知道该监视哪些文件的就绪情况,因此我们每次调用select函数都需要重新添加输入位图。
2.fd_set位图的大小为128字节,即1024个比特位,fd_set位图最多可以表示1024个文件描述符。
timeval结构:
传入select函数的最后一个参数timeout,就是一个指向timeval结构的指针,timeval结构用于描述一段时间长度,该结构当中包含两个成员,其中tv_sec表示的是秒,tv_usec表示的是微秒。
注:
1.timeval结构体初始化方式如下图所示,将秒tv_sec设置为5、微妙tv_usec设置为0。
struct timeval timeout = {5,0};
2.如果要调用select函数进行阻塞式等待那么select函数的timeout参数直接设置成NULL即可。
3.如果要调用select函数进行非阻塞式等待那么select函数的timeout参数tv_sec和tv_usec成员变量都应该设置为0。
4.因为timeout和readfds、writefds与exceptfds一样,它们都是输入输出型参数,因此如果要使用timeout参数,那么在每次调用select函数之前也都需要对timeout的值进行重新设置。
1.3.socket就绪条件
读就绪:
• socket内核中,接收缓冲区中的字节数,大于等于低水位标记
SO_RCVLOWAT
,此时可以无阻塞的读取该文件描述符,并且返回值大于0。• socket TCP通信中,对端关闭连接,此时对该socket读,则返回0。
• 监听的socket上有新的连接请求。
• socket上有未处理的错误。
写就绪:
• socket内核中,发送缓冲区中的可用字节数,大于等于低水位标记SO_SNDLOWAT,此时可以无阻塞的写,并且返回值大于0。
• socket的写操作被关闭(close或者shutdown),对一个写操作被关闭的socket进行写操作,会触发SIGPIPE信号。
• socket使用非阻塞connect连接成功或失败之后。
• socket上有未读取的错误。
异常就绪:
• socket上收到带外数据。
注:带外数据和TCP的紧急模式相关,TCP报头当中的URG标志位和16位紧急指针搭配使用,就能够发送/接收带外数据。
1.4.select基本工作流程
如果我们要实现一个简单的select服务器,该服务器要做的就是读取客户端发来的数据并进行打印,那么这个select服务器的工作流程应该是这样的:
• 先初始化服务器,完成套接字的创建、绑定和监听。
• 定义一个fd_array数组用于保存监听套接字和已经与客户端建立连接的套接字,刚开始时就将监听套接字添加到fd_array数组当中。
• 然后服务器开始循环调用select函数,检测读事件是否就绪,如果就绪则执行对应的操作。
• 每次调用select函数之前,都需要定义一个读文件描述符集readfds,并将fd_array当中的文件描述符依次设置进readfds当中,表示让select帮我们监视这些文件描述符的读事件是否就绪。
• 当select检测到数据就绪时会将读事件就绪的文件描述符设置进readfds当中,此时我们就能够得知哪些文件描述符的读事件就绪了,并对这些文件描述符进行对应的操作。
• 如果读事件就绪的是监听套接字,则调用accept函数从底层全连接队列获取已经建立好的连接,并将该连接对应的套接字添加到fd_array数组当中。
• 如果读事件就绪的是与客户端建立连接的套接字,则调用read函数读取客户端发来的数据并进行打印输出。
• 当然,服务器与客户端建立连接的套接字读事件就绪,也可能是因为客户端将连接关闭了,此时服务器应该调用close关闭该套接字,并将该套接字从fd_array数组当中清除,因为下一次不需要再监视该文件描述符的读事件了。注:
1.因为传入select函数的readfds、writefds和exceptfds都是输入输出型参数,当select函数返回时这些参数当中的值已经被修改了,因此每次调用select函数时都需要对其进行重新设置,timeout也是类似的道理。
2.因为每次调用select函数之前都需要对readfds进行重新设置,所以需要定义一个fd_array数组保存与客户端已经建立的若干连接和监听套接字,实际fd_array数组当中的文件描述符就是需要让select监视读事件的文件描述符。
3.我们的select服务器只是读取客户端发来的数据,因此只需要让select帮我们监视特定文件描述符的读事件,如果要同时让select帮我们监视特定文件描述符的读事件和写事件,则需要分别定义readfds和writefds,并定义两个数组分别保存需要被监视读事件和写事件的文件描述符,便于每次调用select函数前对readfds和writefds进行重新设置。
4.服务器刚开始运行时,fd_array数组当中只有监听套接字,因此select第一次调用时只需要监视监听套接字的读事件是否就绪,但每次调用accept获取到新连接后,都会将新连接对应的套接字添加到fd_array当中,因此后续select调用时就需要监视监听套接字和若干连接套接字的读事件是否就绪。
5.由于调用select时还需要传入被监视的文件描述符中最大文件描述符值+1,因此每次在遍历fd_array对readfds进行重新设置时,还需要记录最大文件描述符值。
这其中还有很多细节,下面我们就来实现这样一个select服务器。
1.5.select服务器实现
创建Sock.hpp文件,写入下图一所示的代码,创建SelectServer.cc文件,写入下图二所示的代码,创建Makefile文件,写入下图三所示的代码,使用make命令生成SelectServer可执行程序,使用./SelectServer命令运行SelectServer可执行程序,创建两个新选项卡作为客户端,分别使用telnet 127.0.0.1 8080命令连接服务端,输入ctrl+]进入telnet行,然后回车并输入消息内容发送给服务端,服务端收到消息后进行打印,客户端发送完消息后输入ctrl+]进入telnet行,然后输入quit退出,如下图四所示。
Sock.hpp文件:
#pragma once #include <iostream> #include <fstream> #include <string> #include <vector> #include <cstdio> #include <cstring> #include <signal.h> #include <unistd.h> #include <sys/socket.h> #include <sys/stat.h> #include <arpa/inet.h> #include <netinet/in.h> #include <sys/types.h> #include <sys/wait.h> #include <pthread.h> #include <cerrno> #include <cassert> class Sock { public: static const int gbacklog = 20; static int Socket() { int listenSock = socket(PF_INET, SOCK_STREAM, 0); if (listenSock < 0) { exit(1); } int opt = 1; setsockopt(listenSock, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt)); return listenSock; } static void Bind(int socket, uint16_t port) { struct sockaddr_in local; // 用户栈 memset(&local, 0, sizeof local); local.sin_family = PF_INET; local.sin_port = htons(port); local.sin_addr.s_addr = INADDR_ANY; // 2.2 本地socket信息,写入sock_对应的内核区域 if (bind(socket, (const struct sockaddr *)&local, sizeof local) < 0) { exit(2); } } static void Listen(int socket) { if (listen(socket, gbacklog) < 0) { exit(3); } } static int Accept(int socket, std::string *clientip, uint16_t *clientport) { struct sockaddr_in peer; socklen_t len = sizeof(peer); int serviceSock = accept(socket, (struct sockaddr *)&peer, &len); if (serviceSock < 0) { // 获取链接失败 return -1; } if(clientport) *clientport = ntohs(peer.sin_port); if(clientip) *clientip = inet_ntoa(peer.sin_addr); return serviceSock; } };
SelectServer.cc文件:
#include <iostream> #include <sys/select.h> #include "Sock.hpp" int fdsArray[sizeof(fd_set) * 8] = {0}; // 保存历史上所有的合法fd int gnum = sizeof(fdsArray) / sizeof(fdsArray[0]); #define DFL -1 using namespace std; static void showArray(int arr[], int num) { cout << "当前合法sock list# "; for (int i = 0; i < num; i++) { if (arr[i] == DFL) continue; else cout << arr[i] << " "; } cout << endl; } static void usage(std::string process) { cerr << "\nUsage: " << process << " port\n" << endl; } // readfds: 现在包含就是已经就绪的sock static void HandlerEvent(int listensock, fd_set &readfds) { for (int i = 0; i < gnum; i++) { if (fdsArray[i] == DFL) continue; if (i == 0 && fdsArray[i] == listensock) { // 我们是如何得知哪些fd,上面的事件就绪呢? if (FD_ISSET(listensock, &readfds)) { // 具有了一个新链接 cout << "已经有一个新链接到来了,需要进行获取(读取/拷贝)了" << endl; string clientip; uint16_t clientport = 0; int sock = Sock::Accept(listensock, &clientip, &clientport); // 不会阻塞 if (sock < 0) return; cout << "获取新连接成功: " << clientip << ":" << clientport << " | sock: " << sock << endl; // read/write -- 不能,因为你read不知道底层数据是否就绪!!select知道! // 想办法把新的fd托管给select?如何托管?? int i = 0; for (; i < gnum; i++) { if (fdsArray[i] == DFL) break; } if (i == gnum) { cerr << "我的服务器已经到了最大的上限了,无法在承载更多同时保持的连接了" << endl; close(sock); } else { fdsArray[i] = sock; // 将sock添加到select中,进行进一步的监听就绪事件了! showArray(fdsArray, gnum); } } } else { // 处理普通sock的IO事件! if (FD_ISSET(fdsArray[i], &readfds)) { // 一定是一个合法的普通的IO类sock就绪了 // read/recv读取即可 char buffer[1024]; ssize_t s = recv(fdsArray[i], buffer, sizeof(buffer), 0); // 不会阻塞 if (s > 0) { buffer[s] = 0; cout << "client[" << fdsArray[i] << "]# " << buffer << endl; } else if (s == 0) { cout << "client[" << fdsArray[i] << "] quit, server close " << fdsArray[i] << endl; close(fdsArray[i]); fdsArray[i] = DFL; // 去除对该文件描述符的select事件监听 showArray(fdsArray, gnum); } else { cout << "client[" << fdsArray[i] << "] error, server close " << fdsArray[i] << endl; close(fdsArray[i]); fdsArray[i] = DFL; // 去除对该文件描述符的select事件监听 showArray(fdsArray, gnum); } } } } } // ./SelectServer 8080 // 只关心读事件 int main(int argc, char *argv[]) { if (argc != 2) { usage(argv[0]); exit(1); } int listensock = Sock::Socket(); Sock::Bind(listensock, atoi(argv[1])); Sock::Listen(listensock); for (int i = 0; i < gnum; i++) fdsArray[i] = DFL; fdsArray[0] = listensock; while (true) { // 在每次进行select的时候进行我们的参数重新设定 int maxFd = DFL; fd_set readfds; FD_ZERO(&readfds); for (int i = 0; i < gnum; i++) { if (fdsArray[i] == DFL) continue; // 1. 过滤不合法的fd FD_SET(fdsArray[i], &readfds); // 2. 添加所有的合法的fd到readfds中,方便select统一进行就绪监听 if (maxFd < fdsArray[i]) maxFd = fdsArray[i]; // 3. 更新出最大值 } struct timeval timeout = {100, 0}; // accept: 等 + "数据拷贝" // 编写多路转接代码的时候,必须先保证条件就绪了,才能调用IO类函数! int n = select(maxFd + 1, &readfds, nullptr, nullptr, &timeout); switch (n) { case 0: cout << "time out ... : " << (unsigned long)time(nullptr) << endl; break; case -1: cerr << errno << " : " << strerror(errno) << endl; break; default: // 等待成功 // select 就绪的时候,可能是listen 就绪,也可能是普通的IO sock就绪啦!! HandlerEvent(listensock, readfds); break; } } return 0; }
Makefile文件:
SelectServer:SelectServer.cc g++ -o $@ $^ -std=c++11 .PHONY:clean clean: rm -f SelectServer
注:
1.调用accept函数从监听套接字中获取新连接,其本质也是IO,底层建立好一个新连接,我们称之为读事件就绪。因此accept函数与read等函数一样也是IO类函数,也需要等(等待新连接)+“数据拷贝”(将新连接拿上来),而编写多路转接代码时,必须先保证条件就绪了,才能调用IO类函数,因此在调用accept函数之前,需要调用select函数帮我们执行等的操作。
2.服务器运行后就应该周期性的执行某种动作了,而select服务器要做的就是不断调用select函数,当事件就绪时对应执行某种动作即可。
• 首先,在select服务器开始死循环调用select函数之前,需要先定义一个fdsArray数组,先把数组中所有的位置初始化为无效,并将监听套接字添加到该数组当中,fdsArray数组当中保存的就是需要被select监视读事件是否就绪的文件描述符。
• 此后,select服务器就不断调用select函数监视读事件是否就绪,每次调用select函数之前都需要重新设置readfds,具体设置过程就是遍历fdsArray数组,将fdsArray数组当中的文件描述符添加到readfds当中,并同时记录最大的文件描述符值maxfd,因为后续调用select函数时需要将maxfd+1作为第一个参数传入。
• 当select函数返回后,如果返回值为0,则说明timeout时间耗尽,此时直接准备进行下一次select调用即可。如果select的返回值为-1,则说明select调用失败,此时也让服务器准备进行下一次select调用,但实际应该进一步判断错误码,根据错误码来判断是否应该继续调用select函数。
• 如果select的返回值大于0,则说明select函数调用成功,此时已经有文件描述符的读事件就绪,接下来就应该调用HandlerEvent函数对就绪事件进行处理。3.当select检测到有文件描述符的读事件就绪并成功返回后,接下来就应该对就绪事件进行处理了,这里编写一个HandlerEvent函数,当读事件就绪后就调用该函数进行事件处理。
• 在进行事件处理时需要遍历fdsArray数组当中的文件描述符,依次判断各个文件描述符对应的读事件是否就绪,如果就绪则需要进行事件处理。
• 当一个文件描述符的读事件就绪后,还需要进一步判断该文件描述符是监听套接字读事件就绪(有新连接),还是与客户端建立的连接对应的读事件就绪(之前的连接有数据发送过来)。• 如果是监听套接字的读事件就绪,那么就应该调用accept函数将底层的连接获取上来。但是光光调用accept将连接获取上来还不够,为了下一次调用select函数时能够让select帮我们监视新连接的读事件是否就绪,在连接获取上来后还应该将该连接对应的文件描述符(会话套接字)添加到fdsArray数组当中,这样在下一次调用select函数前对readfds重新设置时就能将该文件描述符添加进去了。
• 如果是与客户端建立的连接对应的读事件就绪,那么就应该调用read函数读取客户端发来的数据,如果读取成功则将读到的数据在服务器端进行打印。如果调用read函数读取失败或者客户端关闭了连接,那么select服务器也应该调用close函数关闭对应的连接,但此时光光关闭连接也是不够的,还应该将该连接对应的文件描述符从fdsArray数组当中清除,否则后续调用的select函数还会帮我们监视该连接的读事件是否就绪,但实际已经不需要了。4.当调用accept函数从底层获取上来连接后,不能立即调用read函数读取该连接当中的数据,因为此时新连接当中的数据可能并没有就绪,如果直接调用read函数可能需要进行阻塞等待,我们应该将这个等待过程交给select函数来完成,因此在获取完连接后直接将该连接对应的文件描述符添加到fdsArray数组当中就行了,当该连接的读事件就绪时select函数会告知我们,那个时候我们再进行数据读取就不会被阻塞住了。
添加文件描述符到fdsArray数组当中,本质就是遍历fdsArray数组,找到一个没有被使用的位置将该文件描述符添加进去即可。但有可能fdsArray数组中全部的位置都已经被占用了,那么文件描述符就会添加失败,此时就只能将刚刚获取上来的连接对应的套接字进行关闭,因为此时服务器已经没有能力处理这个连接了。
5.虽然当前的select服务器是一个单进程的服务器,但它却可以同时为多个客户端提供服务,根本原因就是因为select函数调用后会告知select服务器是哪个客户端对应的连接事件就绪了,此时select服务器就可以读取对应客户端发来的数据,读取完后又会调用select函数等待某个客户端连接的读事件就绪。
当前的select服务器实际还存在一些问题:
• 服务器没有对客户端发进行响应。select服务器如果要向客户端发送数据,不能直接调用write函数,因为调用write函数时实际也分为“等”和“拷贝”两步,我们也应该将“等”的这个过程交给select函数,因此在每次调用select函数之前,除了需要重新设置readfds还需要重新设置writefds,并且还需要一个数组来保存需要被监视写事件是否就绪的文件描述符,当某一文件描述符的写事件就绪时我们才能够调用write函数向客户端发送数据。
• 没有定制协议。代码中读取数据时并没有按照某种规则进行读取,此时就可能造成粘包问题,根本原因就是因为我们没有定制协议,比如HTTP协议规定在读取底层数据时读取到空行就表明读完了一个HTTP报头,此时再根据HTTP报头当中的Content-Length属性得知正文的长度,最终就能够读取到一个完整的HTTP报文,HTTP协议通过这种方式就避免了粘包问题。
• 没有对应的输入输出缓冲区。代码中直接将读取的数据存储到了字符数组buffer当中,这是不严谨的,因为本次数据读取可能并没有读取到一个完整的报文,此时服务器就不能进行数据的分析处理,应该将读取到的数据存储到一个输入缓冲区当中,当读取到一个完整的报文后再让服务器进行处理。此外,如果服务器要能够对客户端进行响应,那么服务器的响应数据也不应该直接调用write函数发送给客户端,应该先存储到一个输出缓冲区当中,因为响应数据可能很庞大,无法一次发送完毕,可能需要进行分批发送。
1.6.select的优缺点
select的优点:
• 可以同时等待多个文件描述符,并且只负责等待,实际的IO操作由accept、read、write等接口来完成,这些接口在进行IO操作时不会被阻塞。
• select同时等待多个文件描述符,因此可以将“等”的时间重叠,提高了IO的效率。
当然,这也是所有多路转接接口的优点。select的缺点:
• 每次调用select,都需要手动设置fd集合(select的readfds、writefds、exceptfds位图参数),从接口使用角度来说也非常不便。
• 每次调用select,都需要把fd集合(select的readfds、writefds、exceptfds位图参数)从用户态拷贝到内核态(传参的过程),数据拷贝工作开销很大。
• 同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大。
• select可监控的文件描述符数量太少。
select的特点:
• select之前要进行所有参数的重置,select之后要遍历所有的合法fd进行事件检测。
• select需要用户自己维护第三方数组,来保存所有的合法fd,方便select进行批量处理。
• 一旦特定的fd事件就绪,本次读取或写入不会被阻塞。
select可监控的文件描述符个数:
调用select函数时传入的readfds、writefds以及exceptfds都是fd_set结构的,fd_set结构本质是一个位图,它用每一个比特位来标记一个文件描述符,因此select可监控的文件描述符个数是取决于fd_set类型的比特位个数的。
我们可以通过以下代码来看看fd_set类型有多少个比特位。
#include <iostream> #include <sys/types.h> int main() { std::cout << sizeof(fd_set)* 8 << std::endl; return 0; }
运行代码后可以看到,其实select可监控的文件描述符个数就是1024个。
因此我们实现的select服务器当中将fd_array数组的大小设置为1024是足够的,因为readfds当中最多就只能添加1024个文件描述符,但不同环境下fd_set的大小可能是不同的,并且fd_set的大小也是可以调整的(涉及重新编译内核),因此之前select服务器当中fdsArray数组的大小我们表示为sizeof(fd_set) * 8。
一个进程能打开的文件描述符个数:
进程控制块task_struct当中有一个files指针,该指针指向一个struct files_struct结构,进程的文件描述符表fd_array就存储在该结构当中,其中文件描述符表fd_array的大小定义为NR_OPEN_DEFAULT,NR_OPEN_DEFAULT的值实际就是32。
但并不意味着一个进程最多只能打开32个文件描述符,进程能打开的文件描述符个数实际是可以扩展的,比如我当前使用的云服务器默认就是把进程能打开的文件描述符设置得很高的,通过ulimit -a命令就可以看到进程能打开的文件描述符上限。
因此select可监控的文件描述符个数太少是一个很大的问题,比如select可监控的文件描述符个数是1024,除去其中的一个监听套接字,那么select服务器最多只能连接1023个客户端。
1.7.select的适用场景
多路转接接口select、poll和epoll,需要在一定的场景下使用,如果场景选择的不适宜,可能会适得其反。
• 多路转接接口一般适用于多连接,且多连接中只有少部分连接比较活跃。因为少量连接比较活跃,也就意味着几乎所有的连接在进行IO操作时,都需要花费大量时间来等待事件就绪,此时使用多路转接接口就可以将这些等的事件进行重叠,提高IO效率。
• 对于多连接中大部分连接都很活跃的场景,其实并不适合使用多路转接。因为每个连接都很活跃,也就意味着任何时刻每个连接上的事件基本都是就绪的,此时根本不需要动用多路转接接口来帮我们进行等待,毕竟使用多路转接接口也是需要花费系统的时间和空间资源的。
多连接中只有少量连接是比较活跃的,比如聊天工具,我们登录QQ后大部分时间其实是没有聊天的,此时服务器端不可能调用一个read函数阻塞等待读事件就绪。多连接中大部分连接都很活跃,比如企业当中进行数据备份时,两台服务器之间不断在交互数据,这时的连接是特别活跃的,几乎不需要等的过程,也就没必要使用多路转接接口了。
2. I/O多路转接之poll
2.1.poll初识
poll也是系统提供的一个多路转接接口。
• poll系统调用也可以让我们的程序同时监视多个文件描述符上的事件是否就绪,和select的定位是一样的,适用场景也是一样的。
2.2.poll函数
参数:
fds:一个poll函数监视的结构列表,每一个元素包含三部分内容:文件描述符、监视的事件集合、就绪的事件集合。
nfds:表示fds数组的长度。
timeout:表示poll函数的超时时间,单位是毫秒(ms)。
参数timeout的取值:-1:poll调用后进行阻塞等待,直到被监视的某个文件描述符上的某个事件就绪。
0:poll调用后进行非阻塞等待,无论被监视的文件描述符上的事件是否就绪,poll检测后都会立即返回。
特定的时间值:poll调用后在指定的时间内进行阻塞等待,如果被监视的文件描述符上一直没有事件就绪,则在该时间后poll进行超时返回。
返回值:如果函数调用成功,则返回有事件就绪的文件描述符个数。
如果timeout时间耗尽,则返回0。
如果函数调用失败,则返回-1,同时错误码会被设置。
poll调用失败时,错误码可能被设置为:EFAULT:fds数组不包含在调用程序的地址空间中。
EINTR:此调用被信号所中断。
EINVAL:nfds值超过RLIMIT_NOFILE值。
ENOMEM:核心内存不足。
注:使用poll函数需要包含<poll.h>头文件。
struct pollfd结构:
struct pollfd结构当中包含三个成员:
• fd:特定的文件描述符,若设置为负值则忽略events字段并且revents字段返回0。
• events:需要监视该文件描述符上的哪些事件。
• revents:poll函数返回时告知用户该文件描述符上的哪些事件已经就绪。
注:
1.用户通过fd和revents告诉内核需要监视哪个文件描述符的什么事件就绪,内核通过fd和revents告诉用户哪个文件描述符的什么事件已经就绪。
2.poll函数的第一个参数struct pollfd结构体指针类型的fds可以看作是一个struct pollfd类型的数组,如下图所示,里面存储了很多个struct pollfd结构体,每个结构体对应一个需要监视的文件描述符与要监视的事件类型。
这就是为什么poll函数要有第二个nfds参数的原因,告诉内核有几个struct pollfd结构体。
events和revents的取值:
这些取值实际都是以宏的方式进行定义的,它们的二进制序列当中有且只有一个比特位是1,且为1的比特位是各不相同的。
• 因此在调用poll函数之前,可以通过或运算符将要监视的事件添加到events成员当中。
• 在poll函数返回后,可以通过与运算符检测revents成员中是否包含特定事件,以得知对应文件描述符的特定事件是否就绪。
2.3.poll服务器实现
创建Sock.hpp文件,写入下图一所示的代码,创建PollServer.cc文件,写入下图二所示的代码,创建Makefile文件,写入下图三所示的代码,使用make命令生成SelectServer可执行程序,使用./SelectServer命令运行SelectServer可执行程序,创建两个新选项卡作为客户端,分别使用telnet 127.0.0.1 8080命令连接服务端,输入ctrl+]进入telnet行,然后回车并输入消息内容发送给服务端,服务端收到消息后进行打印,客户端发送完消息后输入ctrl+]进入telnet行,然后输入quit退出,如下图四所示。
Sock.hpp文件:
#pragma once #include <iostream> #include <fstream> #include <string> #include <vector> #include <cstdio> #include <cstring> #include <signal.h> #include <unistd.h> #include <sys/socket.h> #include <sys/stat.h> #include <arpa/inet.h> #include <netinet/in.h> #include <sys/types.h> #include <sys/wait.h> #include <pthread.h> #include <cerrno> #include <cassert> class Sock { public: static const int gbacklog = 20; static int Socket() { int listenSock = socket(PF_INET, SOCK_STREAM, 0); if (listenSock < 0) { exit(1); } int opt = 1; setsockopt(listenSock, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt)); return listenSock; } static void Bind(int socket, uint16_t port) { struct sockaddr_in local; // 用户栈 memset(&local, 0, sizeof local); local.sin_family = PF_INET; local.sin_port = htons(port); local.sin_addr.s_addr = INADDR_ANY; // 2.2 本地socket信息,写入sock_对应的内核区域 if (bind(socket, (const struct sockaddr *)&local, sizeof local) < 0) { exit(2); } } static void Listen(int socket) { if (listen(socket, gbacklog) < 0) { exit(3); } } static int Accept(int socket, std::string *clientip, uint16_t *clientport) { struct sockaddr_in peer; socklen_t len = sizeof(peer); int serviceSock = accept(socket, (struct sockaddr *)&peer, &len); if (serviceSock < 0) { // 获取链接失败 return -1; } if(clientport) *clientport = ntohs(peer.sin_port); if(clientip) *clientip = inet_ntoa(peer.sin_addr); return serviceSock; } };
PollServer.cc文件:
#include <iostream> #include <poll.h> #include "Sock.hpp" #define NUM 1024 struct pollfd fdsArray[NUM]; // 保存历史上所有的合法fd #define DFL -1 using namespace std; static void showArray(struct pollfd arr[], int num) { cout << "当前合法sock list# "; for (int i = 0; i < num; i++) { if (arr[i].fd == DFL) continue; else cout << arr[i].fd << " "; } cout << endl; } static void usage(std::string process) { cerr << "\nUsage: " << process << " port\n" << endl; } // readfds: 现在包含就是已经就绪的sock static void HandlerEvent(int listensock) { for (int i = 0; i < NUM; i++) { if (fdsArray[i].fd == DFL) continue; if (i == 0 && fdsArray[i].fd == listensock) { // 我们是如何得知哪些fd,上面的事件就绪呢? if (fdsArray[i].revents & POLLIN) { // 具有了一个新链接 cout << "已经有一个新链接到来了,需要进行获取(读取/拷贝)了" << endl; string clientip; uint16_t clientport = 0; int sock = Sock::Accept(listensock, &clientip, &clientport); // 不会阻塞 if (sock < 0) return; cout << "获取新连接成功: " << clientip << ":" << clientport << " | sock: " << sock << endl; // read/write -- 不能,因为你read不知道底层数据是否就绪!!select知道! int i = 0; for (; i < NUM; i++) { if (fdsArray[i].fd == DFL) break; } if (i == NUM) { cerr << "我的服务器已经到了最大的上限了,无法在承载更多同时保持的连接了" << endl; close(sock); } else { fdsArray[i].fd = sock; // 将sock添加到select中,进行进一步的监听就绪事件了! fdsArray[i].events = POLLIN; fdsArray[i].revents = 0; showArray(fdsArray, NUM); } } } else { // 处理普通sock的IO事件! if (fdsArray[i].revents & POLLIN) { // 一定是一个合法的普通的IO类sock就绪了 // read/recv读取即可 char buffer[1024]; ssize_t s = recv(fdsArray[i].fd, buffer, sizeof(buffer), 0); // 不会阻塞 if (s > 0) { buffer[s] = 0; cout << "client[" << fdsArray[i].fd << "]# " << buffer << endl; } else if (s == 0) { cout << "client[" << fdsArray[i].fd << "] quit, server close " << fdsArray[i].fd << endl; close(fdsArray[i].fd); fdsArray[i].fd = DFL; // 去除对该文件描述符的select事件监听 fdsArray[i].events = 0; fdsArray[i].revents = 0; showArray(fdsArray, NUM); } else { cout << "client[" << fdsArray[i].fd << "] quit, server error " << fdsArray[i].fd << endl; close(fdsArray[i].fd); fdsArray[i].fd = DFL; // 去除对该文件描述符的select事件监听 fdsArray[i].events = 0; fdsArray[i].revents = 0; showArray(fdsArray, NUM); } } } } } // ./SelectServer 8080 // 只关心读事件 int main(int argc, char *argv[]) { if (argc != 2) { usage(argv[0]); exit(1); } int listensock = Sock::Socket(); Sock::Bind(listensock, atoi(argv[1])); Sock::Listen(listensock); for (int i = 0; i < NUM; i++) { fdsArray[i].fd = DFL; fdsArray[i].events = 0; fdsArray[i].revents = 0; } fdsArray[0].fd = listensock; fdsArray[0].events = POLLIN; int timeout = -1; while (true) { int n = poll(fdsArray, NUM, timeout); switch (n) { case 0: cout << "time out ... : " << (unsigned long)time(nullptr) << endl; break; case -1: cerr << errno << " : " << strerror(errno) << endl; break; default: // 等待成功 // poll 就绪的时候,可能是listen 就绪,也可能是普通的IO sock就绪啦!! HandlerEvent(listensock); break; } } return 0; }
Makefile文件:
PollServer:PollServer.cc g++ -o $@ $^ -std=c++11 .PHONY:clean clean: rm -f PollServer
注:
1.poll服务器运行后,要做的就是不断调用poll函数,当事件就绪时对应执行某种动作即可。
• 首先,在poll服务器开始死循环调用poll函数之前,需要定义一个fdsArray数组,该数组当中的每个位置都是一个struct pollfd结构,后续调用poll函数时会作为参数进行传入。先将fdsArray数组当中每个位置初始化为无效,并将监听套接字添加到fdsArray数组当中,表示服务器刚开始运行时只需要监视监听套接字的读事件。
• 此后,poll服务器就不断调用poll函数监视读事件是否就绪。如果poll函数的返回值大于0,则说明poll函数调用成功,此时已经有文件描述符的读事件就绪,接下来就应该对就绪事件进行处理。如果poll函数的返回值等于0,则说明timeout时间耗尽,此时直接准备进行下一次poll调用即可。如果poll函数的返回值为-1,则说明poll调用失败,此时也让服务器准备进行下一次poll调用,但实际应该进一步判断错误码,根据错误码来判断是否应该继续调用poll函数。2.当poll检测到有文件描述符的读事件就绪,就会在其对应的struct pollfd结构中的revents成员中添加读事件并返回,接下来poll服务器就应该对就绪事件进行处理了,事件处理过程如下:
• 首先遍历fdsArray数组中的每个struct pollfd结构,如果该结构当中的fd有效,且revents当中包含读事件,则说明该文件描述符的读事件就绪,接下来就需要进一步判断该文件描述符是监听套接字还是与客户端建立的套接字。
• 如果是监听套接字的读事件就绪,则调用accept函数将底层建立好的连接获取上来,并将获取到的套接字添加到fdsArray数组当中,表示下一次调用poll函数时需要监视该套接字的读事件。
• 如果是与客户端建立的连接对应的读事件就绪,则调用read函数读取客户端发来的数据,并将读取到的数据在服务器端进行打印。
• 如果在调用read函数时发现客户端将连接关闭或read函数调用失败,则poll服务器也直接关闭对应的连接,并将该连接对应的文件描述符从fdsArray数组当中清除,表示下一次调用poll函数时无需再监视该套接字的读事件。3.因为这里将fdsArray数组的大小是固定设置的,因此在将新获取连接对应的文件描述符添加到fdsArray数组时,可能会因为fdsArray数组已满而添加失败,这时poll服务器只能将刚刚获取上来的连接对应的套接字进行关闭。
2.4.poll的优缺点
poll的优点:
• struct pollfd结构当中包含了events和revents,相当于将select的输入输出型参数进行分离,因此在每次调用poll之前,不需要像select一样重新对参数进行设置。
• poll可监控的文件描述符数量没有限制。
• 当然,poll也可以同时等待多个文件描述符,能够提高IO的效率。
注:1.虽然代码中将fdsArray数组的元素个数定义为1024,但fdsArray数组的大小是可以继续增大的,poll函数能够帮你监视多少个文件描述符是由传入poll函数的第二个参数决定的。
2.而fd_set类型只有1024个比特位,因此select函数最多只能监视1024个文件描述符。poll的缺点:
• 和select函数一样,当poll返回后,需要遍历fds数组来获取就绪的文件描述符。
• 每次调用poll,都需要把大量的struct pollfd结构从用户态拷贝到内核态,这个开销也会随着poll监视的文件描述符数目的增多而增大。
• 同时每次调用poll都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大。
2.5.使用poll监控标准输入的代码
代码:
#include <poll.h> #include <unistd.h> #include <stdio.h> int main() { struct pollfd poll_fd; poll_fd.fd = 0; poll_fd.events = POLLIN; for (;;) { int ret = poll(&poll_fd, 1, 1000); if (ret < 0) { perror("poll"); continue; } if (ret == 0) { printf("poll timeout\n"); continue; } if (poll_fd.revents == POLLIN) { char buf[1024] = {0}; read(0, buf, sizeof(buf) - 1); printf("stdin:%s", buf); } } }
运行结果:
意义:
• 该代码说明了不仅是网络socket,本地的文件描述符也可以被托管给多路转接,那么后面的文件操作、管道等也可以无缝对接到多路转接。
• 进程间通信中,System V版本的消息队列、共享内存、信号量其实用的很少,一部分原因就是无法与多路转接整合使用,而与文件描述符相关的技术可以与多路转接整合使用。
3.I/O多路转接之epoll
3.1.epoll初识
epoll也是系统提供的一个多路转接接口。
• epoll系统调用也可以让我们的程序同时监视多个文件描述符上的事件是否就绪,与select和poll的定位是一样的,适用场景也相同。
• epoll在select和poll的基础上进行了一些改进:(1)epoll在命名上比poll多了一个e,这个e可以理解成是extend,epoll就是为了同时处理大量文件描述符而改进的poll(select、poll都是基于对多个fd进行遍历检测来识别事件,链接多的时候一定会引起遍历周期的增加)。
(2)select和poll对于事件(用户告诉内核,内核通知用户)使用的数据结构(数组)需要程序员自己维护,epoll在此基础上进行了改进。
• epoll在2.5.44内核中被引进,它几乎具备了select和poll的所有优点,被公认为Linux2.6下性能最好的多路I/O就绪通知方法。
3.2.epoll的相关系统调用
epoll有三个相关的系统调用,分别是epoll_create、epoll_ctl和epoll_wait。
epoll_create函数:
epoll_create函数用于创建一个epoll模型。
参数:
size:自从Linux2.6.8之后,size参数是被忽略的,但size的值必须设置为大于0的值。
返回值:epoll模型创建成功返回其对应的文件描述符,否则返回-1,同时错误码会被设置。
注:1.当不再使用时,必须调用close函数关闭epoll模型对应的文件描述符,当所有引用epoll实例的文件描述符都已关闭时,内核将销毁该实例并释放相关资源。
2.使用epoll_create函数需要包含<sys/epoll.h>头文件。
epoll_ctl函数:
epoll_ctl函数用于向指定的epoll模型中注册事件。
参数:
epfd:指定的epoll模型。
op:表示具体的动作,用三个宏来表示。
fd:需要监视的文件描述符。
event:需要监视该文件描述符上的哪些事件。
第二个参数op的取值有以下三种:EPOLL_CTL_ADD:注册新的文件描述符到指定的epoll模型中。
EPOLL_CTL_MOD:修改已经注册的文件描述符的监听事件。
EPOLL_CTL_DEL:从epoll模型中删除指定的文件描述符。
返回值:函数调用成功返回0,调用失败返回-1,同时错误码会被设置。
注:使用epoll_ctl函数需要包含<sys/epoll.h>头文件。
第四个参数对应的struct epoll_event结构如下:struct epoll_event结构中有两个成员,第一个成员events表示的是需要监视的事件,第二个成员data是一个联合体结构,一般选择使用该结构当中的fd,表示需要监听的文件描述符。
events的常用取值如下:
• EPOLLIN:表示对应的文件描述符可以读(包括对端SOCKET正常关闭)。
• EPOLLOUT:表示对应的文件描述符可以写。
• EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来)。
• EPOLLERR:表示对应的文件描述符发送错误。
• EPOLLHUP:表示对应的文件描述符被挂断,即对端将文件描述符关闭了。
• EPOLLET:将epoll的工作方式设置为边缘触发(Edge Triggered)模式。
• EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听该文件描述符的话,需要重新将该文件描述符添加到epoll模型中。
这些取值实际也是以宏的方式进行定义的,它们的二进制序列当中有且只有一个比特位是1,且为1的比特位是各不相同的。
epoll_wait函数:
epoll_wait函数用于收集监视的事件中已经就绪的事件。
参数:
epfd:指定的epoll模型。
events:输出型参数,内核会将已经就绪的事件拷贝到events数组当中(events不能是空指针,内核只负责将就绪事件拷贝到该数组中,不会帮我们在用户态中分配内存)。
maxevents:events数组中的元素个数,该值不能大于创建epoll模型时传入的size值。
timeout:表示epoll_wait函数的超时时间,单位是毫秒(ms)。
参数timeout的取值:-1:epoll_wait调用后进行阻塞等待,直到被监视的某个文件描述符上的某个事件就绪。
0:epoll_wait调用后进行非阻塞等待,无论被监视的文件描述符上的事件是否就绪,epoll_wait检测后都会立即返回。
特定的时间值:epoll_wait调用后在直到的时间内进行阻塞等待,如果被监视的文件描述符上一直没有事件就绪,则在该时间后epoll_wait进行超时返回。
返回值:如果函数调用成功,则返回有事件就绪的文件描述符个数。
如果timeout时间耗尽,则返回0。
如果函数调用失败,则返回-1,同时错误码会被设置。
epoll_wait调用失败时,错误码可能被设置为:EBADF:传入的epoll模型对应的文件描述符无效。
EFAULT:events指向的数组空间无法通过写入权限访问。
EINTR:此调用被信号所中断。
EINVAL:epfd不是一个epoll模型对应的文件描述符,或传入的maxevents值小于等于0。
注:使用epoll_wait函数需要包含<sys/epoll.h>头文件。
3.3.epoll工作原理
红黑树和就绪队列:
当某一进程调用epoll_create函数时,Linux内核会创建一个eventpoll结构体,也就是我们所说的epoll模型,eventpoll结构体当中的成员rbr和rdlist与epoll的使用方式密切相关。
• epoll模型当中的红黑树本质就是告诉内核,需要监视哪些文件描述符上的哪些事件,调用epll_ctl函数实际就是在对这颗红黑树进行对应的增删改操作。
• epoll模型当中的就绪队列本质就是告诉内核,哪些文件描述符上的哪些事件已经就绪了,调用epoll_wait函数实际就是在从就绪队列当中获取已经就绪的事件。struct eventpoll{ ... //红黑树的根节点,这棵树中存储着所有添加到epoll中的需要监视的事件 struct rb_root rbr; //就绪队列中则存放着将要通过epoll_wait返回给用户的满足条件的事件 struct list_head rdlist; ... }
在epoll中,对于每一个事件都会有一个对应的epitem结构体,红黑树和就绪队列当中的节点分别是基于epitem结构中的rbn成员和rdllink成员的,epitem结构当中的成员ffd记录的是指定的文件描述符值,event成员记录的就是该文件描述符对应的事件。
• 对于epitem结构当中rbn成员来说,ffd与event的含义是,需要监视ffd上的event事件是否就绪。
• 对于epitem结构当中的rdlink成员来说,ffd与event的含义是,ffd上的event事件已经就绪了。
struct epitem{ struct rb_node rbn; //红黑树节点 struct list_head rdllink; //双向链表节点 struct epoll_filefd ffd; //事件句柄信息 struct eventpoll *ep; //指向其所属的eventpoll对象 struct epoll_event event; //期待发生的事件类型 }
注:
1.红黑树是一种二叉搜索树,因此必须有键值key,而这里的文件描述符就天然的可以作为红黑树的key值。
2.epoll_ctl函数执行注册新的文件描述符到指定的epoll模型中功能其实就是往红黑树中新增节点,epoll_ctl函数执行修改已经注册的文件描述符的监听事件功能其实就是修改红黑树的某个节点,epoll_ctl函数执行从epoll模型中删除指定的文件描述符功能其实就是删除红黑树的某个节点。
3.调用epoll_ctl向红黑树当中新增节点时,如果设置了EPOLLONESHOT选项,当监听完这次事件后,如果还需要继续监听该文件描述符则需要重新将其添加到epoll模型中,本质就是当设置了EPOLLONESHOT选项的事件就绪时,操作系统会自动将其从红黑树当中删除。
4.而如果调用epoll_ctl向红黑树当中新增节点时没有设置EPOLLONESHOT,那么该节点插入红黑树后就会一直存在,除非用户调用epoll_ctl将该节点从红黑树当中删除。
回调机制:
所有添加到红黑树当中的事件,都会与设备(网卡)驱动程序建立回调方法,这个回调方法在内核中叫ep_poll_callback。
在网络通信中,网卡先得到数据,网卡驱动会向CPU发送硬件中断,然后操作系统调用网卡驱动中的预设中断函数,从外设进行数据拷贝,从外设拷贝到内核缓冲区中。网卡驱动中的预设中断函数允许设置回调函数(回调函数作为预设中断函数的参数传入),预设的中断函数执行完数据拷贝到内核缓冲区工作后(在结束前),会调用该回调函数。
• 对于select和poll来说,操作系统在监视多个文件描述符上的事件是否就绪时,需要让操作系统主动对这多个文件描述符进行轮询检测,这一定会增加操作系统的负担。
• 而对于epoll来说,操作系统不需要主动进行事件的检测,当红黑树中监视的事件就绪时,会自动调用对应的回调方法,将就绪的事件添加到就绪队列当中。
• 当用户调用epoll_wait函数获取就绪事件时,只需要关注底层就绪队列是否为空,如果不为空则将就绪队列当中的就绪事件拷贝给用户即可。
采用回调机制最大的好处,就是不再需要操作系统主动对就绪事件进行检测了,当事件就绪时会自动调用对应的回调函数进行处理。注:
1.只有添加到红黑树当中的事件才会与底层建立回调方法,因此只有当红黑树当中对应的事件就绪时,才会执行对应的回调方法将其添加到就绪队列当中。
2.当不断有监视的事件就绪时,操作系统会不断调用回调方法向就绪队列当中插入节点,而上层程序员也会不断调用epoll_wait函数从就绪队列当中获取节点,这是典型的生产者消费者模型。
3.由于就绪队列可能会被多个执行流同时访问,因此必须要使用互斥锁对其进行保护,eventpoll结构当中的lock和mtx就是用于保护临界资源的,因此epoll本身是线程安全的。
4.eventpoll结构当中的wq(wait queue)就是等待队列,当多个执行流想要同时访问同一个epoll模型时,就需要在该等待队列下进行等待。
epoll三部曲:
总结一下,epoll的使用过程就是三部曲:
• 调用epoll_create创建一个epoll模型。
• 调用epoll_ctl,将要监控的文件描述符进行注册。
• 调用epoll_wait,等待文件描述符就绪。
epoll高效的原因:
(1)epoll管理文件描述符及其事件所用的数据结构是红黑树(select和poll使用的是数组)。
(2)epoll不用再让操作系统遍历每一个文件描述符检查事件就绪情况,而是设置回调函数,当某个文件描述符事件就绪,自动调用回调函数,将对应的就绪文件描述符和就绪事件构建节点,插入到就绪队列中。
(3)epoll获取就绪文件描述符时不用再遍历每一个文件描述符了,直接从就绪队列中获取即可。
3.4.epoll服务器实现
创建Sock.hpp文件,写入下图一所示的代码,创建EpollServer.hpp文件,写入下图二所示的代码,创建main.cc文件,写入下图三所示的代码,创建Log.hpp文件,写入下图四所示的代码,创建Makefile文件,写入下图五所示的代码,使用make命令生成SelectServer可执行程序,使用./SelectServer命令运行SelectServer可执行程序,创建两个新选项卡作为客户端,分别使用telnet 127.0.0.1 8080命令连接服务端,输入ctrl+]进入telnet行,然后回车并输入消息内容发送给服务端,服务端收到消息后进行打印,客户端发送完消息后输入ctrl+]进入telnet行,然后输入quit退出,如下图六所示。
Sock.hpp文件:
#pragma once #include <iostream> #include <fstream> #include <string> #include <vector> #include <cstdio> #include <cstring> #include <signal.h> #include <unistd.h> #include <sys/socket.h> #include <sys/stat.h> #include <arpa/inet.h> #include <netinet/in.h> #include <sys/types.h> #include <sys/wait.h> #include <pthread.h> #include <cerrno> #include <cassert> class Sock { public: static const int gbacklog = 20; static int Socket() { int listenSock = socket(PF_INET, SOCK_STREAM, 0); if (listenSock < 0) { exit(1); } int opt = 1; setsockopt(listenSock, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt)); return listenSock; } static void Bind(int socket, uint16_t port) { struct sockaddr_in local; // 用户栈 memset(&local, 0, sizeof local); local.sin_family = PF_INET; local.sin_port = htons(port); local.sin_addr.s_addr = INADDR_ANY; // 2.2 本地socket信息,写入sock_对应的内核区域 if (bind(socket, (const struct sockaddr *)&local, sizeof local) < 0) { exit(2); } } static void Listen(int socket) { if (listen(socket, gbacklog) < 0) { exit(3); } } static int Accept(int socket, std::string *clientip, uint16_t *clientport) { struct sockaddr_in peer; socklen_t len = sizeof(peer); int serviceSock = accept(socket, (struct sockaddr *)&peer, &len); if (serviceSock < 0) { // 获取链接失败 return -1; } if(clientport) *clientport = ntohs(peer.sin_port); if(clientip) *clientip = inet_ntoa(peer.sin_addr); return serviceSock; } };
EpollServer.hpp文件:
#pragma once #include <iostream> #include <string> #include <cstdlib> #include <functional> #include <cassert> #include <sys/epoll.h> #include "Sock.hpp" #include "Log.hpp" using namespace std; class EpollServer { public: static const int gsize = 128; static const int num = 256; using func_t = function<int(int)>; public: EpollServer(uint16_t port, func_t func) : port_(port), listensock_(-1), epfd_(-1), func_(func) { } void InitEpollServer() { listensock_ = Sock::Socket(); Sock::Bind(listensock_, port_); Sock::Listen(listensock_); // 这里直接使用原生接口 epfd_ = epoll_create(gsize); if (epfd_ < 0) { logMessage(FATAL, "%d:%s", errno, strerror(errno)); exit(3); } logMessage(DEBUG, "创建监听套接字成功: %d", listensock_); logMessage(DEBUG, "创建epoll成功: %d", epfd_); } void HandlerEvents(struct epoll_event revs[], int n) { for (int i = 0; i < n; i++) { int sock = revs[i].data.fd; uint32_t revent = revs[i].events; if (revent & EPOLLIN) // 读事件就绪 { if (sock == listensock_) { string clientip; uint16_t clientport = 0; // 监听socket就绪, 获取新链接 int sockfd = Sock::Accept(listensock_, &clientip, &clientport); if (sockfd < 0) { logMessage(FATAL, "%d:%s", errno, strerror(errno)); continue; } // 托管给epoll struct epoll_event ev; ev.events = EPOLLIN; ev.data.fd = sockfd; int n = epoll_ctl(epfd_, EPOLL_CTL_ADD, sockfd, &ev); assert(n == 0); (void)n; } else { // 普通socket就绪,进行数据INPUT // bug int n = func_(sock); if (n == 0 || n < 0) { // 先移除,在关闭 int x = epoll_ctl(epfd_, EPOLL_CTL_DEL, sock, nullptr); assert(x == 0); (void)x; logMessage(DEBUG, "client quit: %d", sock); close(sock); } } } else { } } } void Run() { // 1. 先添加listensock_ struct epoll_event ev; ev.events = EPOLLIN; ev.data.fd = listensock_; int n = epoll_ctl(epfd_, EPOLL_CTL_ADD, listensock_, &ev); assert(n == 0); (void)n; struct epoll_event revs[num]; int timeout = 10000; while (true) { // 关于n:就绪的fd的个数,只需要进行将底层的就绪队列中的节点,依次从0下标放入revs中即可! int n = epoll_wait(epfd_, revs, num, timeout); switch (n) { case 0: cout << "time out ... : " << (unsigned long)time(nullptr) << endl; break; case -1: cerr << errno << " : " << strerror(errno) << endl; break; default: HandlerEvents(revs, n); break; } } } ~EpollServer() { if (listensock_ != -1) close(listensock_); if (epfd_ != -1) close(epfd_); } private: int listensock_; int epfd_; uint16_t port_; func_t func_; };
main.cc文件:
#include "EpollServer.hpp" #include <memory> static void usage(std::string process) { cerr << "\nUsage: " << process << " port\n" << endl; } int myfunc(int sock) { //bug char buffer[1024]; ssize_t s = recv(sock, buffer, sizeof(buffer)-1, 0); //不会被阻塞 if(s > 0) { buffer[s] = 0; logMessage(DEBUG, "client[%d]# %s", sock, buffer); } return s; } int main(int argc, char *argv[]) { if (argc != 2) { usage(argv[0]); exit(0); } unique_ptr<EpollServer> epollserver(new EpollServer(atoi(argv[1]), myfunc)); epollserver->InitEpollServer(); epollserver->Run(); return 0; }
Log.hpp文件:
#pragma once #include <cstdio> #include <ctime> #include <cstdarg> #include <cassert> #include <cassert> #include <cstring> #include <cerrno> #include <stdlib.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #define DEBUG 0 #define NOTICE 1 #define WARINING 2 #define FATAL 3 const char *log_level[] = {"DEBUG", "NOTICE", "WARINING", "FATAL"}; #define LOGFILE "serverTcp.log" class Log { public: Log():logFd(-1) {} void enable() { umask(0); logFd = open(LOGFILE, O_WRONLY | O_CREAT | O_APPEND, 0666); assert(logFd != -1); dup2(logFd, 1); dup2(logFd, 2); } ~Log() { if(logFd != -1) { fsync(logFd); close(logFd); } } private: int logFd; }; // logMessage(DEBUG, "%d", 10); void logMessage(int level, const char *format, ...) { assert(level >= DEBUG); assert(level <= FATAL); char *name = getenv("USER"); char logInfo[1024]; va_list ap; // ap -> char* va_start(ap, format); vsnprintf(logInfo, sizeof(logInfo) - 1, format, ap); va_end(ap); // ap = NULL // 每次打开太麻烦 FILE *out = (level == FATAL) ? stderr : stdout; fprintf(out, "%s | %u | %s | %s\n", log_level[level], (unsigned int)time(nullptr), name == nullptr ? "unknow" : name, logInfo); fflush(out); // 将C缓冲区中的数据刷新到OS fsync(fileno(out)); // 将OS中的数据尽快刷盘 }
Makefile文件:
epollServer:main.cc g++ -o $@ $^ -std=c++11 .PHONY:clean clean: rm -f epollServer
注:
1.epoll服务器运行后要做的就是不断调用epoll_wait函数,从就绪队列当中获取就绪事件进行处理即可。
• 首先,在epoll服务器开始死循环调用epoll_wait函数之前,需要先调用epoll_ctl将监听套接字添加到epoll模型当中,表示服务器刚开始运行时只需要监视监听套接字的读事件。
• 此后,epoll服务器就不断调用epoll_wait函数监视读事件是否就绪。如果epoll_wait函数的返回值大于0,则说明已经有文件描述符的读事件就绪,并且此时的返回值代表的就是有事件就绪的文件描述符个数,接下来就应该对就绪事件进行处理。
• 如果epoll_wait函数的返回值等于0,则说明timeout时间耗尽,此时直接准备进行下一次epoll_wait调用即可。如果epoll_wait函数的返回值为-1,此时也让服务器准备进行下一次epoll_wait调用,但实际应该进一步判断错误码,根据错误码来判断是否应该继续调用epoll_wait函数。2.说明一下:
• 默认情况下,只要底层有就绪事件没有处理,epoll也会一直通知用户,也就是调用epoll_wait会一直成功返回,并将就绪的事件拷贝到我们传入的数组当中。
• 需要注意的是,所谓的事件处理并不是调用epoll_wait将底层就绪队列中的就绪事件拷贝到用户层,比如当这里的读事件就绪后,我们应该调用accept获取底层建立好的连接,或调用recv读取客户端发来的数据,这才算是将读事件处理了。
• 如果我们仅仅是调用epoll_wait将底层就绪队列当中的事件拷贝到应用层,那么这些就绪事件实际并没有被处理掉,底层注册的回调函数会被再次调用,将就绪的事件重新添加到就绪队列当中,本质原因就是我们实际并没有对底层就绪的数据进行读取。3.如果底层就绪队列当中有就绪事件,那么调用epoll_wait函数时就会将底层就绪队列中的事件拷贝到用户提供的revs数组当中,接下来epoll服务器就应该对就绪事件进行处理了,事件处理过程如下:
• 根据调用epoll_wait时得到的返回值,来判断操作系统向revs数组中拷贝了多少个struct epoll_event结构,进而对这些文件描述符上的事件进行处理。
• 对于每一个拷贝上来的struct epoll_event结构,如果该结构当中的events当中包含读事件,则说明该文件描述符对应的读事件就绪,但接下来还需要进一步判断该文件描述符是监听套接字还是与客户端建立的套接字。
• 如果是监听套接字的读事件就绪,则调用accept函数将底层建立好的连接获取上来,并调用epoll_ctl函数将获取到的套接字添加到epoll模型当中,表示下一次调用epoll_wait函数时需要监视该套接字的读事件。
• 如果是与客户端建立的连接对应的读事件就绪,则调用recv函数读取客户端发来的数据,并将读取到的数据在服务器端进行打印。
• 如果在调用recv函数时发现客户端将连接关闭或recv函数调用失败,则epoll服务器也直接关闭对应的连接,并调用epoll_ctl函数将该连接对应的文件描述符从epoll模型中删除,表示下一次调用epoll_wait函数时无需再监视该套接字的读事件。
3.5.epoll的优点
epoll的优点:
• 接口使用方便:虽然拆分成了三个函数,但是反而使用起来更方便高效。
• 数据拷贝轻量:只在新增监视事件的时候调用epoll_ctl将数据从用户拷贝到内核,而select和poll每次都需要重新将需要监视的事件从用户拷贝到内核。此外,调用epoll_wait获取就绪事件时,只会拷贝就绪的事件,不会进行不必要的拷贝操作。
• 事件回调机制:避免操作系统主动轮询检测事件就绪,而是采用回调函数的方式,将就绪的文件描述符结构加入到就绪队列中。调用epoll_wait时直接访问就绪队列就知道哪些文件描述符已经就绪,检测是否有文件描述符就绪的时间复杂度是O(1),因为本质只需要判断就绪队列是否为空即可。
• 没有数量限制:监视的文件描述符数目无上限,只要内存允许,就可以一直向红黑树当中新增节点。注:有人说epoll中使用了内存映射机制,内核可以直接将底层就绪队列通过mmap的方式映射到用户态(用户地址空间),此时用户就可以直接读取到内核中就绪队列当中的数据,避免了内存拷贝的额外性能开销。
这种说法是错误的,实际操作系统并没有做任何映射机制,因为操作系统是不相信任何人的,操作系统不会让用户进程直接访问到内核的数据的,用户只能通过系统调用来获取内核的数据。
因此用户要获取内核当中的数据,势必还是需要将内核的数据拷贝到用户空间。
epoll与select和poll的不同之处:
• 在使用select和poll时,都需要借助第三方数组来维护历史上的文件描述符以及需要监视的事件,这个第三方数组是由用户自己维护的,对该数组的增删改操作都需要用户自己来进行。
• 而使用epoll时,不需要用户自己维护所谓的第三方数组,epoll底层的红黑树就充当了这个第三方数组的功能,并且该红黑树的增删改操作都是由内核维护的,用户只需要调用epoll_ctl让内核对该红黑树进行对应的操作即可。
• 在使用多路转接接口时,数据流都有两个方向,一个是用户告知内核,一个是内核告知用户。select和poll将这两件事情都交给了同一个函数来完成,而epoll在接口层面上就将这两件事进行了分离,epoll通过调用epoll_ctl完成用户告知内核,通过调用epoll_wait完成内核告知用户。
3.6.epoll工作方式
epoll有两种工作方式,分别是水平触发工作模式和边缘触发工作模式。
水平触发(LT,Level Triggered):
• 只要底层有事件就绪,epoll就会一直通知用户。
• 就像数字电路当中的高电平触发一样,只要一直处于高电平,则会一直触发。
epoll默认状态下就是LT工作模式。
• 由于在LT工作模式下,只要底层有事件就绪就会一直通知用户,因此当epoll检测到底层读事件就绪时,可以不立即进行处理,或者只处理一部分,因为只要底层数据没有处理完,下一次epoll还会通知用户事件就绪。
• select和poll其实就是工作是LT模式下的。
• 支持阻塞读写和非阻塞读写。
边缘触发(ET,Edge Triggered):
• 只有底层就绪事件数量由无到有或由有到多发生变化的时候,epoll才会通知用户。
• 就像数字电路当中的上升沿触发一样,只有当电平由低变高的那一瞬间才会触发。
如果要将epoll改为ET工作模式,则需要在添加事件时设置EPOLLET选项。
• 由于在ET工作模式下,只有底层就绪事件无到有或由有到多发生变化的时候才会通知用户,因此当epoll检测到底层读事件就绪时,必须立即进行处理,而且必须全部处理完毕,因为有可能此后底层再也没有事件就绪,那么epoll就再也不会通知用户进行事件处理,此时没有处理完的数据就相当于丢失了。
• ET工作模式下epoll通知用户的次数一般比LT少,因此ET的性能一般比LT性能更高,Nginx就是默认采用ET模式使用epoll的。
• 只支持非阻塞的读写。
ET工作模式下应该如何进行读写:
因为在ET工作模式下,只有底层就绪事件无到有或由有到多发生变化的时候才会通知用户,这就倒逼用户当读事件就绪时必须一次性将数据全部读取完毕,当写事件就绪时必须一次性将发送缓冲区写满,否则可能再也没有机会进行读写了。
因此读数据时必须循环调用recv函数进行读取,写数据时必须循环调用send函数进行写入。
• 当底层读事件就绪时,循环调用recv函数进行读取,直到某次调用recv读取时,实际读取到的字节数小于期望读取的字节数,则说明本次底层数据已经读取完毕了。
• 但有可能最后一次调用recv读取时,刚好实际读取的字节数和期望读取的字节数相等,但此时底层数据也恰好读取完毕了,如果我们再调用recv函数进行读取,那么recv就会因为底层没有数据而被阻塞住。
• 而这里的阻塞是非常严重的,就比如我们这里写的服务器都是单进程的服务器,如果recv被阻塞住,并且此后该数据再也不就绪,那么就相当于我们的服务器挂掉了,因此在ET工作模式下循环调用recv函数进行读取时,必须将对应的文件描述符设置为非阻塞状态。
• 调用send函数写数据时也是同样的道理,需要循环调用send函数进行数据的写入,并且必须将对应的文件描述符设置为非阻塞状态。
注:1.ET工作模式下,recv和send操作的文件描述符必须设置为非阻塞状态,这是必须的,不是可选的。
2.ET模式的本质是一种让上层尽快取走数据的机制,这样可以给发送端回复更大的窗口大小。
对比LT和ET:
• 在ET模式下,一个文件描述符就绪之后,用户不会反复收到通知,看起来比LT更高效,但如果在LT模式下能够做到每次都将就绪的文件描述符立即全部处理,不让操作系统反复通知用户的话,其实LT和ET的性能也是一样的。
• 此外,ET的编程难度比LT更高。