IO复用之——epoll

一. 关于epoll

专注于为中小企业提供网站制作、成都网站制作服务,电脑端+手机端+微信端的三站合一,更高效的管理,为中小企业陇县免费做网站提供优质的服务。我们立足成都,凝聚了一批互联网行业人才,有力地推动了上千多家企业的稳健成长,帮助中小企业通过网站建设实现规模扩充和转变。

    对于IO复用模型,前面谈论过了关于select和poll函数的使用,select提供给用户一个关于存储事件的数据结构fd_set来统一监测等待事件的就绪,分为读、写和异常事件集;而poll则是用一个个的pollfd类型的结构体管理事件的文件描述符和事件所关心的events,并通过结构体里面的输出型参数revents来通知用户事件的就绪状态;

    但是对于上述两种函数,都是需要用户遍历所有的事件集合来确定到底是哪一个或者是哪些事件已经就绪可以进行数据的处理了,因此当要处理等待的事件比较多时,就会有数据复制和系统遍历的开销导致效率并不高效;针对select和poll的缺点,另外一种相对高效的处理IO复用的函数就出现了,那就是epoll;


二. epoll相关函数的使用

    首先,和select及poll函数不同的是,epoll并没有直接的一个用epoll来命名的函数使用,而是分别提供出来三个函数:epoll_createepoll_ctlepoll_wait

  1. epoll_create

IO复用之——epoll

epoll_create函数创建一个epoll的“实例”,请求内核分配一个指定大小的空间用于事件的后台存储,函数参数size只是一个关于内核如何维护内部结构的提示,不过现在这个size已经被忽略并不需要在意了;

函数成功会返回一个引用新创建的epoll实例的一个文件描述符,用于随后调用其他的epoll函数的结构,如果不再需要的话,应当使用close函数关闭,这时内核会销毁该epoll实例并释放相关资源;如果函数失败会返回-1并置相应的错误码

2. epoll_ctl

IO复用之——epoll

函数参数中,

epfd是用epoll_create创建出来的epoll文件描述符,用来操纵epoll实例;

op是要对创建出的epoll实例进行操作,而op的操作选项有如下三种宏:

IO复用之——epoll

EPOLL_CTL_ADD用于在epfd标识的epoll实例中添加登记要处理的事件;

EPOLL_CTL_MOD用于更改特定的文件描述符所关心的事件;

EPOLL_CTL_DEL用于删除在epoll实例中登记的事件,标识并不需要再关心了;

fd是指要进行数据IO的事件的文件描述符,也就是用户需要进行操作的事件的文件描述符;

event是一个epoll_event的结构体,用于存放需要对fd进行操作的相关信息:

IO复用之——epoll

结构体中,

events表示文件描述符fd所对应的事件所关心的操作,是相应的比特位的设置,有如下几种宏:

IO复用之——epoll

如上的宏中,最主要使用的有如下几种:

EPOLLIN表示fd可以进行数据的读取;

EPOLLOUT表示fd可以进行数据的写入;

EPOLLPRI表示当前有紧急数据可供读取;

EPOLLERR表示当前事件发生错误;

EPOLLHUP表示当前事件被挂断;

EPOLLET将相关的文件描述符设置为边缘触发,因为默认是水平触发的;对于LT和ET模式下面会讨论;

对于结构体中的data则是一个联合,用于表示有关文件描述符操作的数据信息:

IO复用之——epoll

ptr是指向数据缓冲区的一个指针;

fd是相应操作的文件描述符;

epoll_ctl函数成功返回0,失败返回-1并置相应的错误码;

3. epoll_wait

如果说上面的epoll_create和epoll_ctl是为了进行相关事件的操作而进行的准备工作,那么真正和select及poll函数一样用来进行多个事件的等待就绪则就是epoll_wait函数了:

IO复用之——epoll

函数参数中,

epfd是用epoll_create创建出的epoll实例的文件描述符;

events是上述的一个结构体的指针,这里一般是一个数组的首地址,是一个输入输出型参数,当作为输入时,是用户提供给系统一个用来存放就绪事件的地址空间,而作为输出型参数时,系统会将就绪的事件放入其中供用户提取,因此不可以为NULL

maxevents是events的大小;

timeout则是设置等待的超时时间,单位为毫秒

这里值得一提的是,既然epoll是select和poll的改进,那么其最主要的高效就是体现在epoll_wait的返回值:

  • 函数失败返回-1并置相应的错误码;

  • 函数返回0表示超时,预定时间内并没有事件就绪;

  • 当函数返回值大于0时,是告诉用户当前事件集中已经就绪的IO事件的个数,并且将其按序从头开始排列在了用户提供的空间events内,因此,不需要像select和poll那样遍历整个事件集找出就绪的事件,只需要在相应的数组中从头访问固定的返回值的个数就拿到了所有就绪的事件了;


三. 栗子时间

    同样的,使用epoll相关的接口函数,可以自主来编写一个基于TCP协议的服务端,其基本步骤如下:

  1. 首先,先要创建出一个监听socket,绑定好本地网络地址信息并将其处于监听状态,但是这里,为了使其更为高效,还需要调用setsockopt函数来将其属性设定为SO_REUSEADDR,使其地址信息可被重用;

  2. 调用epoll_create创建出一个关于epoll实例的文件描述符,用于以后操作epoll相关函数;

  3. 调用epoll_ctl函数,将监听socket登记添加到epoll实例中;

  4. 定义一个epoll_event结构体数组,用户指定大小,供系统存放就绪的IO事件;

  5. 调用epoll_wait进行事件的就绪等待,并接收其返回值;

  6. 当epoll_wait返回时,对返回的事件一一进行判断处理,如果是监听事件就绪,表明有连接请求需要处理,并将新的套接字添加进epoll实例中;如果是其他socket就绪,表明数据就绪可以进行读取和写入了;

  7. 当连接的一端关闭或者epoll实例使用完毕的时候,需要调用close函数关闭相应的文件描述符回收资源;

server客户端程序设计如下:

#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 

#define _BACKLOG_ 5  //网络中连接请求等待队列最大值
#define _MAX_NUM_ 20 //事件就绪队列存储空间
#define _DATA_SIZE_ 1024 //数据缓冲区大小

//因为epoll_event结构体中的data成员是一个联合体,因此当需要同时使用联合中的fd和ptr的时候就会有问题
//因此可以将其各自单独拿出存储
typedef struct data_buf
{
    int _fd;
    char _buf[_DATA_SIZE_];
}data_buf_t, *data_buf_p;

//命令行参数的格式判断
void Usage(const char *argv)
{
    assert(argv);
    printf("Usage: %s  [ip]  [port]\n", argv);
    exit(0);
}

//创建监听套接字
static int CreateListenSock(int ip, int port)
{
    int sock = socket(AF_INET, SOCK_STREAM, 0);//创建新socket
    if(sock < 0)
    {
        perror("socket");
        exit(1);
    }

    int opt = 1;//调用setsockopt函数使当server首先断开连接的时候避免进入一个TIME_WAIT的等待时间
    if(setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) < 0)
    {
        perror("setsockopt");
        exit(2);
    }

        //设置本地网络地址信息
    struct sockaddr_in server;
    server.sin_family = AF_INET;
    server.sin_port = htons(port);
    server.sin_addr.s_addr = ip;

    //绑定套接字和本地网络信息
    if(bind(sock, (struct sockaddr*)&server, sizeof(server)) < 0)
    {
        perror("bind");
        exit(3);
    }

    //设定套接字为监听状态
    if(listen(sock, _BACKLOG_) < 0)
    {
        perror("listen");
        exit(4);
    }

    return sock;
}

//执行epoll
void epoll_server(int listen_sock)
{
        //创建出一个epoll实例,获取其文件描述符,大小随意指定
    int epoll_fd = epoll_create(256);
    if(epoll_fd < 0)
    {
        perror("epoll_create");
        exit(5);
    }

    //定义一个epoll_event结构体用于向epoll实例中注册需要IO的事件信息
    struct epoll_event ep_ev;
    ep_ev.events = EPOLLIN;
    ep_ev.data.fd = listen_sock;
    if(epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_sock, &ep_ev) < 0)
    {
        perror("epoll_ctl");
        exit(6);
    }

    //申请一个确定的空间提供给系统,用于存放就绪事件队列
    struct epoll_event evs[_MAX_NUM_];
    int maxnum = _MAX_NUM_;//提供的空间大小
    int timeout = 10000;//设定超时时间,如果为-1,则以阻塞方式一直等待
    int ret = 0;//epoll_wait的返回值,获取就绪事件的个数

    while(1)
    {
        switch((ret = epoll_wait(epoll_fd, evs, maxnum, timeout)))
        {
            case -1://出错
                perror("epoll_wait");
                break;
            case 0://超时
                printf("timeout...\n");
                break;
            default://至少有一个事件就绪
                {
                    int i = 0;
                    for(; i < ret; ++i)
                    {
                            //判断是否为监听套接字,如果是,获取连接请求
                        if((evs[i].data.fd == listen_sock) && (evs[i].events & EPOLLIN))
                        {
                            struct sockaddr_in client;
                            socklen_t client_len = sizeof(client);

                            //处理连接请求,获取新的通信套接字
                            int accept_sock = accept(listen_sock, (struct sockaddr*)&client, &client_len);
                            if(accept_sock < 0)
                            {
                                perror("accept");
                                continue;
                            }
                            printf("connect with a client...[fd]:%d   [ip]:%s  [port]:%d\n", accept_sock, inet_ntoa(client.sin_addr), ntohs(client.sin_port));

                            //将新的事件添加进epoll实例中
                            ep_ev.events = EPOLLIN;
                            ep_ev.data.fd = accept_sock;
                            if(epoll_ctl(epoll_fd, EPOLL_CTL_ADD, accept_sock, &ep_ev) < 0)
                            {
                                perror("epoll_ctl");
                                close(accept_sock);
                            }
                        }
                        else//除了监听套接字之外的IO套接字
                        {
                                //如果为读事件就绪
                            if(evs[i].events & EPOLLIN)
                            {
                                    //申请空间用于同时存储文件描述符和缓冲区地址
                                data_buf_p _data = (data_buf_p)malloc(sizeof(data_buf_t));
                                if(!_data)
                                {
                                    perror("malloc");
                                    continue;
                                }
                                _data->_fd = evs[i].data.fd;
                                printf("read from fd: %d\n", _data->_fd);
                                //从缓冲区中读取数据
                                ssize_t size = read(_data->_fd, _data->_buf, sizeof(_data->_buf)-1);
                                if(size < 0)//读取出错
                                    printf("read error...\n");
                                else if(size == 0)//远端关闭连接
                                {
                                    printf("client closed...\n");
                                    //收尾工作,将事件从epoll实例中移除,关闭文件描述符和防止内存泄露
                                    epoll_ctl(epoll_fd, EPOLL_CTL_DEL, _data->_fd, NULL);
                                    close(_data->_fd);
                                    free(_data);
                                }
                                else
                                {
                                        //读取成功,输出数据
                                    (_data->_buf)[size] = '\0';
                                    printf("client# %s", _data->_buf);
                                    fflush(stdout);
                                    //将事件改为关心写事件,进行回写
                                    ep_ev.data.ptr = _data;
                                    ep_ev.events = EPOLLOUT;
                                    //在epoll实例中更改同一个事件
                                    epoll_ctl(epoll_fd, EPOLL_CTL_MOD, _data->_fd, &ep_ev);
                                }
                            }
                            else if(evs[i].events & EPOLLOUT)//判断为写事件就绪
                            {
                                data_buf_p _data = (data_buf_p)evs[i].data.ptr;

                                //向缓冲区中回写数据
                                write(_data->_fd, _data->_buf, strlen(_data->_buf));
                                //写完之后就进行完毕一次通信,进行收尾
                                epoll_ctl(epoll_fd, EPOLL_CTL_DEL, _data->_fd, NULL);
                                close(_data->_fd);
                                free(_data);
                            }
                            else
                            {}
                        }
                    }
                }
                break;
        }
    }
}


int main(int argc, char *argv[])
{
    if(argc != 3)//判断命令行参数的正确性
        Usage(argv[0]);

    //获取端口号和IP地址
    int port = atoi(argv[2]);
    int ip = inet_addr(argv[1]);

    //获取监听套接字
    int listen_sock = CreateListenSock(ip, port);

    //进行epoll操作
    epoll_server(listen_sock);
    close(listen_sock);//关闭文件描述符

    return 0;
}

这里要说明一下,系统内部其实是为epoll相关的操作维护了一棵平衡搜索二叉树和一张链表,如果用户一次性提供出来的空间不够存放所有就绪的事件,那么下一次系统会将剩下的再提供出来,因此不必要担心提供给epoll_wait的结构体数组空间的问题;

运行程序:

IO复用之——epoll左边为server端,右边为使用telnet请求连接端

因为设计的是一问一答的模式,因此在server端收到连接请求和数据之后,将数据读取出再回写回连接请求端,就认为完成了一次通信;

如上的模式,还可以用浏览器来进行测试,只是当浏览器进行连接请求之后,server端就认为收到了数据,转而需要进行回写,而回写的内容则有所要求,因为大部分浏览器所使用的是HTTP协议,因此在浏览器接收的时候,应该收到的是server端写回的作为响应的消息,而这里,HTTP的响应由三部分组成,状态行、消息报头和响应正文,而作为状态行的格式为“协议版本+响应状态码+表示状态码的文本”,过多的内容并不属于本篇文章的讨论范围,因此不赘述,总之,作为响应消息,server端写回的内容应该是如下格式:

char *msg = "HTTP/1.1 200 OK\r\n\r\nHello, what can i do for you ? :)\r\n";
write(_data->_fd, msg, strlen(msg));

运行server端程序,打开浏览器输入IP和端口号:

IO复用之——epoll

当浏览器连接上server时,server端会接收到关于浏览器方面的信息,也就是获取了浏览器的请求信息,而之后会将响应消息返回给浏览器,而浏览器会根据接收到的响应消息得到正文内容并显示出来,如右边的显示(使用本地环回IP进行的测试即127.0.0.1);


四. 水平触发和边缘触发

    当epoll_wait在进行多个事件的等待时,如果有数据发送到缓冲区中时,则表示当前事件处于就绪状态,则需要返回来通知用户“有数据来了,可以进行处理了”,那么对于系统通知用户的方式,就分为水平式触发和边缘式触发:

    水平触发(Level Trigger)简称LT,其特点是当数据到来的时候会通知用户,如果用户一次数据处理并没有将缓冲区中的数据全部取走还留有一部分,那么下一次再进行相同事件的epoll_wait的时候系统会认为事件仍然是就绪的,还会继续通知用户来取走剩下的数据,因此,水平触发的特点是:只要数据缓冲区中有数据,当前的IO事件始终都是就绪的,epoll_wait始终会返回有效值通知用户程序

    边缘触发(Edge Triggered)简称ET,当有数据到来的时候仍然会返回通知用户程序,但是和水平触发不同的是,如果用户在通知一次后对数据的IO处理并不完全,也就是一次处理之后缓冲区中还留有数据,那么再次返回进行epoll_wait的时候就不会再表明当前事件是就绪的了,只有当这个事件再次有数据到达时才会再一次通知用户程序来处理数据,因此,边缘触发的特点是:只有当数据到来的时候系统才会通知用户程序且只会通知一次,如果还有数据没有处理完,只有等到再次有数据到来的时候才会再次满足事件就绪,epoll_wait返回通知用户程序处理数据

    这里需要注意的是:对于边缘式触发,因为只有当数据到来时系统才会通知用户程序一次,如果当前的IO接口工作于阻塞模式,那么当一个事件被阻塞的时候,其他事件的就绪也就只会被通知一次但并得不到处理,因此会导致多数据的堆积,所以,当使用边缘式触发的时候:

  • 最好将当前的IO接口设定为非阻塞的;

  • 当一个IO事件进行数据的读取和写入的时候,最好一次性就将缓冲区中的数据全部都处理完;因此,对于数据的读取,可以用一个循环来每次读取特定的长度,当最后一次读取的长度小于特定的长度时,就可以认为当前缓冲区的数据已经全部读取完毕终止循环;但是,不可避免的是,如果最后一次的读取恰好也就是特定的长度,那么在此进行读取缓冲区中数据为0,就会返回一个EAGAIN的错误码,这个就可以作为循环的终止条件;

EAGAIN的错误码为11,可在/usr/include/asm-generic/errno.h及errno-base.h中查到:

IO复用之——epoll

若输出其对应错误描述,为:Resource temporarily unavailable,意思是资源暂时不可用,可以try again;

将IO接口设置为非阻塞的,可以调用fcntl函数:

IO复用之——epoll

函数参数中,

fd表示要进行操作的文件描述符;

cmd表示要进行的操作;

至于后面的参数,则有cmd来决定;

IO复用之——epoll

在这里要设置文件接口为非阻塞的,首先要将cmd设置为F_GETFL,表示获取当前文件描述符的标志,因为重新设定时需要用到;之后需要再次调用fcntl函数,将cmd设定为F_SETFL,要重新设置文件描述符的标志,其中有一个选项就是O_NONBLOCK

对于fcntl函数的返回值,根据操作的不同而不同:

IO复用之——epoll

    对比水平触发和边缘触发,可以发现水平触发对于数据的处理来说是更安全更可靠的,而边缘触发是要更为高效的,因此,选择哪种通知方式,可以依情况而定;

因为上面的程序中,默认epoll_wait的通知方式是LT也就是水平触发的,要将其改为高效一些的ET边缘触发模式,则需要满足如上所述的非阻塞条件和数据一次性读取完毕条件:

  • 首先将事件的IO接口设置为非阻塞模式,则在listen socket创建中以及每一次有新的连接请求获得新的IO文件描述符之后,都需要调用如下的函数:

int set_non_block(int fd) 
{
    //获取当前文件描述符的文件标识
    int old_fl = fcntl(fd, F_GETFL);
    if(old_fl < 0)
    {   
        perror("fcntl");
        return -1; 
    }
    //将文件描述符所对应的事件设置为非阻塞模式
    if(fcntl(fd, F_SETFL, old_fl|O_NONBLOCK))
    {   
        perror("fcntl");
        return -1; 
    }   
    return 0;
}
  • 其次,就需要自行封装出一个函数来进行循环地获取或者写入缓冲区中数据,直到没有数据可读为止,这是为了避免边缘触发的特点带来的数据拥堵不能够被处理的现象:

//读取数据
ssize_t MyRead(int fd, char *buf, size_t size)
{
    assert(buf);

    int index = 0;
    ssize_t ret = 0;
    //如果读取到的数据等于0,则说明远端关闭连接,直接返回0
    //而如果为非0,不管是大于零还是出错小于零都需要进入循环
    while((ret = read(fd, buf+index, size-index)))
    {
        if(errno == EAGAIN)//如果错误码为EAGAIN,则说明读取完毕,打印出错误码和错误消息并退出
        {
            printf("read errno: %d\n", errno);
            perror("read");
            break;
        }
        index += ret;
    }
    return (ssize_t)index;//返回获得的总数据量
}

//写入数据
ssize_t MyWrite(int fd, char* buf, size_t size)
{
    assert(buf);

    int index = 0;
    ssize_t ret = -1;
    //和读取数据一样,当写入数据量为0的时候直接返回0
    //否则,返回值为非零进入循环
    while((ret = write(fd, buf+index, size-index)))
    {
        if(errno == EAGAIN)//当数据全部写完的时候返回错误码为EAGAIN
        {
            printf("write errno: %d\n", errno);
            perror("write");
            break;
        }
        index += ret;
    }
    return (ssize_t)index;//和读取数据相同,返回写入的总数据量
}

将上面修改的代码添加到上述例子中之后,运行程序:

IO复用之——epoll

分析一下程序结果,会发现第一次连接并没有什么问题,得到了一问一答的结果,但是如果第二次连接包括以后的多次连接,所发送的数据就无法被server端接收到,反而被认为连接端已经关闭了,因此server端就主动关闭了连接和相关事件的清除;这是怎么一回事呢?

这是因为,在上面所封装的数据的读写函数中,当第一次连接进行数据的读取,读取完毕缓冲区中所有的数据之后,再次进行read就会出错,因而错误码被置为了EAGAIN,而错误码errno是个全局变量,所以当再次或者多次连接进行数据的读取的时候,即使读到了数据read的返回值大于零,但进入循环进行

if(errno == EAGAIN)

判断的时候,errno已经被第一次连接置为了EAGAIN,而运行是在同一个进程当中的,所以始终满足上述条件跳出循环,返回值为0,之后再进行判断,就会认为并没有读到数据,转而关闭相应的文件描述符;

这就是在一个函数中使用了全局变量造成了函数的不可重入性;


要解决上述问题,

  1. 可以在上述的判断条件增加一个条件,即:

if((ret < 0) && (errno == EAGAIN))
{   
     printf("read errno: %d\n", errno);
     perror("read");
     break;
}

当read出错进入循环的时候,要和read成功分开进行操作,这样就不会有误了,虽然无法避免使用全局变量errno,但是可以通过read的返回值来进一步加强判断;

2. 另外有一种方法,就是可以用多进程来操作,即将errno变成某一个进程专属的全局变量,也就是当一个IO的读事件就绪的时候,就创建出一个子进程来进行缓冲区中数据的读写,将进行epoll_wait之后的读事件就绪以后的代码改为如下:

else
{
    if(evs[i].events & EPOLLIN)//读事件就绪
    {
        data_buf_p _data = (data_buf_p)malloc(sizeof(data_buf_t));
        if(!_data)
        {
            perror("malloc");
            continue;
        }
        _data->_fd = evs[i].data.fd;
        printf("read from fd: %d\n", _data->_fd);

        //创建进程
        pid_t id = fork();
        if(id < 0)//创建失败
            perror("fork");
        else if(id == 0)//子进程
        {
            printf("child proc: %d\n", getpid());
            ssize_t size = MyRead(_data->_fd, _data->_buf, sizeof(_data->_buf)-1);
            //ssize_t size = read(_data->_fd, _data->_buf, sizeof(_data->_buf)-1);
            if(size < 0)
                printf("read error...\n");
            else if(size == 0)
            {
                printf("client closed...\n");
                exit(12);
                //epoll_ctl(epoll_fd, EPOLL_CTL_DEL, _data->_fd, NULL);
                //close(_data->_fd);
                //free(_data);
            }
            else
            {
                (_data->_buf)[size] = '\0';
                printf("client# %s", _data->_buf);
                fflush(stdout);
                ep_ev.data.ptr = _data;
                ep_ev.events = EPOLLOUT | EPOLLET;
                epoll_ctl(epoll_fd, EPOLL_CTL_MOD, _data->_fd, &ep_ev);
            }
        }
        else
        {
            pid_t ret = wait(NULL);
            if(ret < 0)
                perror("waitpid");
            else
                printf("wait success : %d\n", ret);
            epoll_ctl(epoll_fd, EPOLL_CTL_DEL, _data->_fd, NULL);
            close(_data->_fd);
            free(_data);
        }
    }
    else if(evs[i].events & EPOLLOUT)
    {
        data_buf_p _data = (data_buf_p)evs[i].data.ptr;
        MyWrite(_data->_fd, _data->_buf, strlen(_data->_buf));
        //epoll_ctl(epoll_fd, EPOLL_CTL_DEL, _data->_fd, NULL);
        //close(_data->_fd);
        //free(_data);
        exit(11);
    }

这里要解释:当创建一个子进程的时候,子进程复制父进程的PCB,自然也就会获取其相应的文件描述符进行操作,但是当需要改变其内容的时候,比如文件描述符和epoll实例,子进程就会进行写时拷贝,这个时候已经不能单单进行子进程中关闭文件描述符和释放空间的操作了,因为这并没有起到实际效果,只不过是清除了拷贝出来的内容而已,这就是为什么上面的程序中注释掉了子进程中的收尾工作,转而在父进程中进行;而与此同时,父进程是需要进行等待的,如果不进行等待就会导致同一个IO事件的乱序而无法达到预期的效果

运行程序:

IO复用之——epoll

其实,对于函数的可重入性,不免就会想到线程的安全问题,那么上面的程序如果给改成多线程的话是能不能行呢?

对于线程而言,是共享进程的资源的,而errno是一个全局变量,在整个进程空间内都有效,因此,对于多线程也是同样共享这一个全局变量的,虽然全局变量是临界资源,但上述的问题并不是因为争夺临界资源而造成的,因为使用了for循环来一个一个地处理IO事件,而是前一个操作对全局变量的改变影响了后来的操作,这是典型的函数的可重入性,函数的可重入性并不等同于线程安全,它需要函数内部使用的变量全部来自于自身的栈空间,因此,如果用多线程或者线程互斥来进行操作是没有什么变化的。

《完》


本文题目:IO复用之——epoll
分享地址:http://ybzwz.com/article/jeidsh.html