Linux系统编程之多路IO转接服务器

本章节主要介绍Linux中高并发服务器下的三种多路IO转接模型:select,poll,epoll。各模型分别从原理,系统函数,实现代码三个方面一一说明。文章内容稍有深度,代码理解不易,需要读者结合代码注释以及参考链接内容,反复比较思考。文章最后,对几种多路IO模型的优缺点进行总结,算是对此章节做的收尾工作。

多路IO转接服务器

IO多路复用是指内核一旦发现进程指定的一个或者多个IO条件准备读取,它就通知该进程。IO多路复用适用如下场合:

  1. 当客户处理多个描述字时(一般是交互式输入和网络套接口),必须使用I/O复用
  2. 当一个客户同时处理多个套接口时,而这种情况是可能的,但很少出现
  3. 如果一个TCP服务器既要处理监听套接口,又要处理已连接套接口,一般也要用到I/O复用
  4. 如果一个服务器即要处理TCP,又要处理UDP,一般要使用I/O复用
  5. 如果一个服务器要处理多个服务或多个协议,一般要使用I/O复用

与多进程和多线程技术相比,I/O多路复用技术的最大优势是系统开销小,系统不必创建进程/线程,也不必维护这些进程/线程,从而大大减小了系统的开销。

select服务器模型

select函数能够实现多路IO复用机制,通过select系统调用,让我们的程序监视多个文件句柄的状态变化的,其使用一个 fd_set 来监测数据是否到达,状态是否改变,提供三种数据:读数据,写数据,异常数据。同时,select还可以指定文件阻塞方式和超时等待,非阻塞直接返回,阻塞式直到被监视的文件句柄有一个或多个发生了状态改变才会返回,返回值为变化的文件描述符个数。

使用 select 需要注意以下两点:

  1. select能监听的文件描述符个数受限于FD_SETSIZE,一般为1024,单纯改变进程打开的文件描述符个数并不能改变select监听文件个数
  2. 解决1024以下客户端时使用select是很合适的,但如果链接客户端过多,select采用的是轮询模型,会大大降低服务器响应效率,不应在select上投入更多精力

函数原型

#include <sys/select.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

nfds: 监控的文件描述符集里最大文件描述符加1,因为此参数会告诉内核检测前多少个文件描述符的状态
readfds: 监控有读数据到达文件描述符集合,传入传出参数
writefds: 监控写数据到达文件描述符集合,传入传出参数
exceptfds: 监控异常发生达文件描述符集合,如带外数据到达异常,传入传出参数
timeout: 定时阻塞监控时间,3种情况
  1. NULL,永远等下去
  2. 设置timeval,等待固定时间
  3. 设置timeval里时间均为0,检查描述字后立即返回,轮询

返回值:
  1. -1,执行错误
  2. 0,timeout时间到达
  3. 其他,正确执行,并且有就绪事件到达。

结构体 timeval
struct timeval {
  long tv_sec; /* seconds */
  long tv_usec; /* microseconds */
};

文件描述符集 fd_set 操作函数
void FD_CLR(int fd, fd_set *set); 把文件描述符集合里fd清 0
int FD_ISSET(int fd, fd_set *set); 测试文件描述符集合里fd是否置 1
void FD_SET(int fd, fd_set *set); 把文件描述符集合里fd位置 1
void FD_ZERO(fd_set *set); 把文件描述符集合里所有位清 0

select 函数参数众多,从函数原型上看得到所以然,下面展示模板代码,理解起来稍微不易,需要认真思考分析。

实例代码

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/select.h>
#include "wrap.h"

#define SERVER_PORT 8000
#define BUF_SIZE 1024

int main(int argc ,char *argv[])
{

    struct sockaddr_in serveraddr, clientaddr;
    socklen_t clientlen;
    char buf[BUF_SIZE];
    // INET_ADDRSTRLEN 宏,ipv4地址大小
    char clientip[INET_ADDRSTRLEN];
    int readlen, sockfd, connfd;

    int maxfd, maxi,i;
    int selectfd;
    // FD_SETSIZE 大小为 1024
    int clientset[FD_SETSIZE];
    // select 中需要的文件描述符集
    fd_set curset, oriset;

    sockfd = Socket(AF_INET,SOCK_STREAM,0);
    bzero(&serveraddr,sizeof (serveraddr));
    serveraddr.sin_family = AF_INET;
    serveraddr.sin_addr.s_addr = htonl(INADDR_ANY);
    serveraddr.sin_port = htons(SERVER_PORT);
    Bind(sockfd,(struct sockaddr *)&serveraddr,sizeof (serveraddr));
    Listen(sockfd,20);

    maxi = -1;
    maxfd = sockfd;
    // 初始化 clientset
    for(i=0; i < FD_SETSIZE; i++){
        clientset[i] = -1;
    }
    // 清空 oriset,将 sockfd 加入监听
    FD_ZERO(&oriset);
    FD_SET(sockfd,&oriset);

    printf("Select... \n");

    // 循环监测socket文件描述符
    for(;;){
        curset = oriset;
        // 使用select监测scoket文件,监测读断,返回链接服务器的客户端个数
        // 由于参数位 &curset 是传入传出参数,每次调用 select 都会发生改变。这里采用临时备份值记录
        // 监听最大文件描述符 sockfd+1,只关心读数据到达,不关心写数据到达,异常数据到达,阻塞式
        selectfd = select(maxfd+1,&curset,NULL,NULL,NULL);
        // 有数据到达,做处理判断,返回已到达的链接数
        if(selectfd < 0)
            perr_exit("select");

        // 检查读文件描述符集set中sockfd是否被置位,即是否有新客户端链接上,处理新客户端链接上操作
        if(FD_ISSET(sockfd,&curset)){
            // sockfd 文件描述符有读到达,即有客户端链接请求
            clientlen = sizeof (clientaddr);
            // 调用Accept,内核分配文件描述符进行数据交互
            connfd = Accept(sockfd,(struct sockaddr *)&clientaddr,&clientlen);

            printf("Clinet IP: %s, port: %d \n",
                   inet_ntop(AF_INET,&clientaddr.sin_addr,clientip,INET_ADDRSTRLEN),
                   ntohs(clientaddr.sin_port)
                   );

            // 将产生的数据交互文件描述符添加到数据集合中,等待下一轮轮训统一处理
            for(i = 0; i< FD_SETSIZE;i++){
                if(clientset[i] < 0){
                    clientset[i] = connfd;
                    break;
                }
            }

            // 异常监测,链接数大于可承载范围1024
            if(i == FD_SETSIZE){
                fputs("too many clients!!!",stderr);
                exit(1);
            }

            // 将分配的新文件描述符(客户端链接上,用于数据交互)添加到 select 中继续监听是否有数据到达
            FD_SET(connfd,&oriset);
            // 更新 select 内最大文件描述符值(新加入一个)
            if(connfd > maxfd)
                maxfd = connfd;

            // 此处的 maxi 为监听文件描述符数组内的数量,用于后续统一进行数据交互之用
            if(i > maxi)
                maxi = i;
            // 这句代码需要重点理解,进入此模块,前提是有新客户端请求链接,客户端通信数据需要判断
            // 1. select监听到 1个读数据到达,那么就是新客户端链接请求(上面处理完),直接返回开头重新监听新客户端链接请求和已连接上客户端的数据交互请求
            // 2. select监听到 2个及以上数据到达,进入此模块肯定有新客户端链接请求(已对其分配描述符,可以进行数据交互),此时--select不为 0,继续向下处理已连接上的客户端数据交互请求
            if( --selectfd == 0)
                continue;
        }

        // select 监听到数据到达返回,但不是新的客户端链接上的请求,这里统一处理已连接上客户端的数据交互
        // 遍历 clientset,统一处理客户端数据请求
        for(i =0;i<=maxi;i++){
            int temp = clientset[i];
            if(temp<0)
                continue;
            // 找出数据到达的文件描述符,处理具体的客户端数据请求
            if(FD_ISSET(temp,&curset)){
                if((readlen = Read(temp,buf,BUF_SIZE)) == 0){
                    // client端关闭连接,服务端也将关闭
                    // 关闭链接文件描述符
                    Close(temp);
                    // 修改set集合对应元素
                    clientset[i] = -1;
                    // 清除监听select中set元素
                    FD_CLR(temp,&oriset);
                    maxfd--;
                }else {
                    // 服务器具体的数据回传处理
                    for(int j=0;j<readlen;j++)
                        buf[j] = toupper(buf[j]);
                    Write(temp,buf,readlen);
                }
                // 自减 select 返回的数据到达数量,避免不必要的遍历操作
                if( --selectfd == 0)
                    break;
            }
        }
    }
    Close(sockfd);
    return 0;
}

上述代码有相关的注释说明,有以下几点要注意:

  1. select 返回数据改变的文件描述符个数,需要对其个数进行具体分析
  2. select返回 1,判断是新客户端链接请求,Accept分配描述符进行数据读写,加入 select 监听,更新中间变量,加入 clientset 数据集监听数据交互请求,后续统一数据交互处理,–select后为 0 直接返回开头,select 重新监听
  3. select返回大于 1,判断是否有新客户端链接请求,是则同上 1 处理后,–select后不为 0,进入第二层 for 循环,统一处理客户端的数据交互
  4. select 返回 1或者大于 1,但不是新客户端链接请求,那么进入底层 for 循环,统一处理客户端的数据交互请求
  5. 底层for遍历循环中,遍历 clientset 数据集,通过 select 返回的 curset,找出具体文件描述符一一进行回复,处理完成一个, –select
  6. 当数据处理中发现有客户端断开(读数据长度为0),需要将 clientset 中对应为清除,同时 select 中对应的 fd_set 文件描述符集对应元素也要清除,
  7. 当–select后为 0时,代表 select 中监听到数据到达都已经处理完成了,循环结束,重新开始 select 的监听

小结 select 服务器模型

select相比多进程多线程高效的原因

首先要知道一个概念,一次I/O分两个部分(①等待数据就绪 ②进行I/O),减少等的比重,增加I/O的比重就可以达到高效服务器的目的。select工作原理就是这个,同时监控多个文件描述符(或者说文件句柄),一旦其中某一个进入就绪状态,就进行I/O操作。监控多个文件句柄可以达到提高就绪状态出现的概率,就可以使CPU在大多数时间下都处于忙碌状态,大大提高CPU的性能。达到高效服务器的目的。 可以理解为select轮询监控多个文件句柄或套接字。

优点

  1. 不需要建立多个线程、进程就可以实现一对多的通信。
  2. 可以同时等待多个文件描述符,效率比起多进程多线程来说要高很多
  3. 跨平台性,windows、linux、micOS、unix、mips都支持 select

缺点

  1. 每次进行 select 都要把文件描述符集 fd 由用户态拷贝到内核态,这样的开销会很大
  2. 实现 select 服务器,内部要不断对文件描述符集 fd 进行循环遍历,当 fd 很多时,开销也很大
  3. select 能监控文件描述符的数量有限,一般为1024。(sizeof(fd_set) * 8 = 1024(fd_set内部是以位图表示文件描述符))

pselect

pselect 相比 select 支持了信号屏蔽字的操作,函数原型如下

#include <sys/select.h>

int pselect(int nfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds, const struct timespec *timeout, const sigset_t *sigmask);

struct timespec {
  long tv_sec; /* seconds */
  long tv_nsec; /* nanoseconds */
};

用sigmask替代当前进程的阻塞信号集,调用返回后还原原有阻塞信号集,具体代码略

参考博客1

参考博客2

poll 服务器模型

poll 的机制与 select 类似,与 select 在本质上没有多大差别,管理多个描述符也是进行轮询,根据描述符的状态进行处理,但是 poll 没有最大文件描述符数量的限制。

函数原型

#include <poll.h>

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

struct pollfd {
  int fd; /* 文件描述符 */
  short events; /* 监控的事件 */
  short revents; /* 监控事件中满足条件返回的事件 */
};

events取值:
  POLLIN普通或带外优先数据可读,即POLLRDNORM | POLLRDBAND
  POLLRDNORM-数据可读
  POLLRDBAND-优先级带数据可读
  POLLPRI 高优先级可读数据
  POLLOUT普通或带外数据可写
  POLLWRNORM-数据可写
  POLLWRBAND-优先级带数据可写
  POLLERR 发生错误
  POLLHUP 发生挂起
  POLLNVAL 描述字不是一个打开的文件

nfds 监控数组中有多少文件描述符需要被监控

timeout 毫秒级等待
  -1: 阻塞等,#define INFTIM,-1 Linux中没有定义此宏
  0: 立即返回,不阻塞进程
  >0: 等待指定毫秒数,如当前系统时间精度不够毫秒,向上取值

返回值:
  返回监控文件描述符状态改变的个数,等待超时返回 0,失败返回 -1,设置 errno

函数相关说明:

  1. 结构体 pollfd 指定了一个被监视的文件描述符,可以传递结构体数组,指示 poll 监视多个文件描述符。每个结构体的 events 域是监视该文件描述符的事件掩码,由用户来设置这个域。revents 域是文件描述符的操作结果事件掩码,内核在调用返回时设置这个域。events 域中请求的任何事件都可能在 revents 域中返回
  2. POLLIN | POLLPRI 等价于 select() 的读事件,POLLOUT |POLLWRBAND 等价于 select() 的写事件。POLLIN 等价于 POLLRDNORM |POLLRDBAND,而 POLLOUT 则等价于 POLLWRNORM。例如,要同时监视一个文件描述符是否可读和可写,我们可以设置 events 为 POLLIN |POLLOUT。在 poll 返回时,我们可以检查 revents 中的标志,对应于文件描述符请求的 events 结构体。如果 POLLIN 事件被设置,则文件描述符可以被读取而不阻塞。如果 POLLOUT 被设置,则文件描述符可以写入而不导致阻塞。这些标志并不是互斥的:它们可能被同时设置,表示这个文件描述符的读取和写入操作都会正常返回而不阻塞。
  3. 成功时,poll() 返回结构体中 revents 域不为 0 的文件描述符个数;如果在超时前没有任何事件发生,poll() 返回 0;失败时,poll() 返回 -1,并设置 errno。

实例代码

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <poll.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <errno.h>
#include "wrap.h"

#define BUF_SIZE 1024
#define SERVER_PORT 8000
#define FILE_MAX 1024

int main(int argc,char  *argv[])
{
    int i,j, maxi, ready,readlen;
    int sockfd, tempfd, connfd;
    socklen_t clientlen;
    struct sockaddr_in serveraddr, clientaddr;
    struct pollfd clientset[FILE_MAX];
    char buf[FILE_MAX], clientip[INET_ADDRSTRLEN];

    sockfd = Socket(AF_INET,SOCK_STREAM,0);
    bzero(&serveraddr,sizeof (serveraddr));
    serveraddr.sin_family = AF_INET;
    serveraddr.sin_addr.s_addr = htonl(INADDR_ANY);
    serveraddr.sin_port = htons(SERVER_PORT);
    Bind(sockfd,(struct sockaddr *)&serveraddr,sizeof (serveraddr));
    Listen(sockfd,20);

    // 将客户端集合0号元素置位于sockfd,设置元素监听为普通数据可读
    clientset[0].fd = sockfd;
    clientset[0].events = POLLRDNORM;

    // 初始化其他元素,注意此时从1开始
    for(i = 1;i< FILE_MAX;i++){
      clientset[i].fd = -1;
    }
    maxi = 0;
    printf("poll...\n");

    for(;;){
        // 阻塞式监听文件描述符事件sockfd,注意参数为集合元素个数,返回状态改变的文件描述符个数
        ready = poll(clientset,maxi+1,-1);
        // 0 元素存在数据可读状态
        if(clientset[0].revents & POLLRDNORM){
            // 为新链接客户端分配新的描述符
            clientlen = sizeof (clientaddr);
            connfd = Accept(sockfd,(struct sockaddr *)&clientaddr,&clientlen);
            printf("received from: %s at PORT: %d\n",
                   inet_ntop(AF_INET, &clientaddr.sin_addr, clientip, INET_ADDRSTRLEN),
                   ntohs(clientaddr.sin_port));
            // 遍历数组 clientset,将新客户端分配的数据交互描述符添加到 poll 监听中
            for(i =1;i<FILE_MAX;i++){
                if(clientset[i].fd < 0){
                    clientset[i].fd = connfd;
                    clientset[i].events = POLLRDNORM;
                    break;
                }
            }
            // 临界数据检测
            if(i >= FILE_MAX)
                perr_exit("too many clients");
            // 初始值为 0,poll 监测个数为 maxi+1,遍历时 i 从 1 开始
            if(i > maxi)
                maxi = i;

            // 只有一个事件是有新客户端链接,分配新的描述子并添加到监听中,跳出此次循环从头开始从新监听
            if(--ready <= 0)
                continue;
        }

        // 遍历处理监听字符集内数据相应,此时只对 1 后面的字符集和操作
        // 注意此处截止到 (i <= maxi)
        for(i=1; i <= maxi; i++){
            tempfd = clientset[i].fd;
            if(tempfd<0)
                continue;

            if(clientset[i].revents & (POLLRDNORM | POLLERR)){
                if((readlen = Read(tempfd,buf,BUF_SIZE))<0){
                    // 数据交互收到 RST 标志
                    if(errno == ECONNABORTED){
                        printf("client[%d] aborted connection",i);
                        Close(tempfd);
                        clientset[i].fd = -1;
                    }else {
                        perr_exit(1);
                    }
                }else if (readlen == 0) {
                    // 数据读出0,客户端关闭了
                    printf("client[%d] closed connection",i);
                    Close(tempfd);
                    clientset[i].fd = -1;
                }else {
                    // 正常数据交互
                    for(j=0;j<readlen;j++){
                        buf[j] = toupper(buf[j]);
                    }
                    Write(tempfd,buf,readlen);
                }
                // 遍历结束条件,状态改变文件描述符个数减为 0
                if(--ready ==0)
                    break;
            }
        }
    }
    Close(sockfd);
    return 0;
}

上述代码和 select逻辑基本相似,只是采用了 poll 方式,逻辑上与 select 并无二至,有以下几点说明

  1. poll 第一个参数接受了一个数组,监测一个 pollfd 的数组最大为 1024
  2. poll 返回监测文件描述符数组内状态改变的个数,同样通过 poll 返回值,遍历处理其中的对应文件描述符的数据
  3. 初始化时候,0 号元素对应sockfd, 后面的数据处理中需要从 1 开始,中间变量的矫正需要注意 maxi 的值
  4. 状态发生改变的文件描述符,通过传出参数 revents来获取,通过其位状态,判断其数据是否可读可写
  5. 对于不想监听的某个描述符时候,直接将其 pollfd 中的 fd 置位 -1,poll 将不再对其监听
    6。 具体的处理流程详见 select 中说明。

poll 小结

  1. poll 和 select 原理上都是通过轮训的方式对文件描述符进行监控,相比于 select 来说,poll 不限制文件描述符 1024
  2. 其自定义结构体 pollfd 内实现了监听事件和返回事件分离。但是其不能跨平台,只能在linux中使用。无法直接定位满足监听事件的文件描述符,需要轮询数组
  3. poll 和 select 同样存在一个缺点就是,包含大量文件描述符的数组被整体复制于用户态和内核的地址空间之间,而不论这些文件描述符是否就绪,它的开销随着文件描述符数量的增加而线性增大
  4. ppoll 为 poll 的升级版本,GNU定义了 ppoll(非 POSIX 标准),可以支持设置信号屏蔽字,具体代码略

参考链接

epoll 服务器模型

目前 epell 是 linux 大规模并发网络程序中的热门首选模型。

epoll 是 Linux 下多路复用IO接口 select/poll 的增强版本,它能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率。epoll 模型相对 select、poll 来说不再是一个系统函数,而是需要具体的 api 来支持,因此其效率异常高效。

epoll API

epoll 不再是一个单独的系统调用,而是由 epoll_create、epoll_ctl、epoll_wait 三个系统调用组成。如下:

epoll_create

创建一个epoll句柄,参数size用来告诉内核监听的文件描述符个数,跟内存大小有关。

#include <epoll.h>

int epoll_create(int size)

size: 告诉内核监听的数目
返回值: 成功时,返回一个非负整数的文件描述符,作为创建好的 epoll 句柄。调用失败时,返回 -1,错误信息可以通过 errno 获得

说明:
创建一个 epoll 句柄,size 用来告诉内核这个监听的数目一共有多大。这个参数不同于 select 中的第一个参数,给出最大监听的 fd+1 的值。需要注意的是,当创建好 epoll 句柄后,它就是会占用一个 fd 值,所以在使用完 epoll 后,必须调用 close 关闭,否则可能导致 fd 被耗尽。

epoll_ctl

控制某个epoll监控的文件描述符上的事件:注册、修改、删除。

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

epfd: epoll_create 函数返回的 epoll 句柄

op: 操作选项,可选值有以下3个
  EPOLL_CTL_ADD:注册新的 fd 到 epfd 中;
  EPOLL_CTL_MOD:修改已经注册的 fd 的监听事件;
  EPOLL_CTL_DEL:从epfd中删除一个 fd;

fd: 要进行操作的目标文件描述符

event: struct epoll_event结构指针,将fd和要进行的操作关联起来。

返回值: 成功时,返回 0,作为创建好的 epoll 句柄。调用失败时,返回 -1,错误信息可以通过 errno 获得。

相关说明:

  1. epoll 的事件注册函数,它不同与 select 是在监听事件时告诉内核要监听什么类型的事件,而是在这里先注册要监听的事件类型。

  2. 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 */  
    };
    
  3. 结构体 epoll_event 中 events可以是以下几个宏的集合

    EPOLLIN: 表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
    EPOLLOUT: 表示对应的文件描述符可以写;
    EPOLLPRI: 表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
    EPOLLERR: 表示对应的文件描述符发生错误;
    EPOLLHUP: 表示对应的文件描述符被挂断;
    EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
    EPOLLONESHOT: 只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里
    

epoll_wait

等待所监控文件描述符上有事件的产生,类似于select()调用。

int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

epfd: epoll_create 函数返回的 epoll 句柄
events: struct epoll_event 结构指针,用来从内核得到事件的集合
maxevents: 告诉内核这个 events 有多大
timeout: 等待时的超时时间,以毫秒为单位

返回值: 成功时,返回需要处理的事件数目。调用失败时,返回 0,表示等待超时

说明:

epoll_wait 调用成功后,返回处理数目大小,待处理数据文件描述符都被封装到了 epoll_event * events 中去,此时只需要遍历这里面的数据就可以了,大大提高效率

实例代码

#include "wrap.h"
#include <sys/epoll.h>
#include <arpa/inet.h>
#include <errno.h>
#include <netinet/in.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>

#define SERVER_PORT 8000
#define BUF_SIZE 1024
#define FILE_MAX 1024

int main(int argc,char *argv[])
{
    int sockfd, connfd,tempfd;
    int i,j, maxi, ready, epfd, readlen;
    socklen_t clientlen;
    char buf[BUF_SIZE], clientip[INET_ADDRSTRLEN];

    int clientset[FILE_MAX];
    struct sockaddr_in serveraddr,clientaddr;
    struct epoll_event temp_event, ep_events[FILE_MAX];

    sockfd = Socket(AF_INET,SOCK_STREAM,0);

    // 初始化服务器地址参数
    bzero(&serveraddr,sizeof (serveraddr));
    serveraddr.sin_family = AF_INET;
    serveraddr.sin_addr.s_addr = htonl(INADDR_ANY);
    serveraddr.sin_port = htons(SERVER_PORT);
    // 执行绑定
    Bind(sockfd,(struct sockaddr *)&serveraddr,sizeof (serveraddr));
    // 执行listen
    Listen(sockfd,20);

    // 初始化局部参数,已链接客户端数据集
    for(i = 0; i< FILE_MAX;i++){
        clientset[i] = -1;
    }
    maxi = -1;

    // 创建 epoll 句柄
    epfd = epoll_create(FILE_MAX);
    if(epfd == -1){
        perr_exit("epoll_create");
    }
    // 操作句柄监听事件
    temp_event.events = EPOLLIN;
    temp_event.data.fd = sockfd;
    // 将 sockfd 加入到 epoll 监听中,监测事件为 EPOLLIN
    int ret = epoll_ctl(epfd,EPOLL_CTL_ADD,sockfd,&temp_event);
    if(ret ==-1){
        perr_exit("epoll_ctl sockfd");
    }
    printf("epoll_wait...\n");

    // 循环监听事件发生
    for(;;){
        // 阻塞等待链接事件发生
        ready = epoll_wait(epfd,ep_events,FILE_MAX,-1);
        if(ready == -1){
            perr_exit("epoll_wait");
        }
        // 遍历描述符变化的数据集 ep_events
        for(i = 0;i<ready;i++){
            // 非数据读入事件,跳出此循环,继续阻塞等待下次事件发生
            if(!(ep_events[i].events & EPOLLIN))
                continue;

            // sockfd 描述符有状态变化,即有新的客户端链接请求,执行 accept
            if (ep_events[i].data.fd == sockfd) {
                clientlen = sizeof (clientaddr);
                // 为新链接客户端分配新的描述符
                connfd = Accept(sockfd,(struct sockaddr *)&clientaddr,&clientlen);
                printf("received from: %s at PORT: %d\n",
                       inet_ntop(AF_INET, &clientaddr.sin_addr, clientip, INET_ADDRSTRLEN),
                       ntohs(clientaddr.sin_port));
                // 加入到服务端集合,for 结束后,j 的值为已链接客户端数目
                for(j =0; j< FILE_MAX;j++){
                    if(clientset[j] < 0){
                        clientset[j] = connfd;
                        break;
                    }
                }

                // 矫正局部参数变量
                if(j > FILE_MAX)
                    perr_exit("too many clients");

                if(j>maxi)
                    maxi = j;

                // 将新分配的客户端描述符添加到 epoll 监听中客户端的数据请求
                temp_event.data.fd = connfd;
                temp_event.events = EPOLLIN;
                int ret = epoll_ctl(epfd,EPOLL_CTL_ADD,connfd,&temp_event);
                if(ret == -1)
                    perr_exit("epoll_ctl sockfd");

            } else {
                // 此处为客户端请求服务器进行数据交互操作
                // 遍历取出客户端集合中对应数据进行处理
                tempfd = ep_events[i].data.fd;
                readlen = Read(tempfd,buf,BUF_SIZE);
                // 数据读出为0,客户端关闭了
                if(readlen == 0){
                    // 客户端关闭,删除 clientset 集合元素,删除 epoll_event 监听事件,关闭 socket 文件
                    for(j = 0;j<maxi;j++){
                        if(clientset[j] = tempfd)
                            clientset[i]=-1;
                        break;
                    }

                    // 客户端关闭了,解除 epoll 中对应文件描述符的监听
                    int ret = epoll_ctl(epfd,EPOLL_CTL_DEL,tempfd,NULL);
                    if(ret == -1){
                        perr_exit("epoll_ctl del");
                    }
                    Close(tempfd);
                    printf("clientset[%d] closed connection! \n", i);
                }else {
                    // 数据读出不为 0,正常数据请求操作
                    for(j =0; j< readlen;j++){
                        buf[j]= toupper(buf[j]);
                    }
                    Write(tempfd,buf,readlen);
                }
            }
        }
    }
    //程序结束,关闭 socket 文件,关闭 epoll 句柄
    Close(sockfd);
    close(epfd);
    return 0;
}

程序说明:

  1. epoll 监听文件描述符,要事先通过 epoll_create 创建句柄,然后将需要监听的描述符信息填充到结构体中,最后通过 epoll_ctl 函数设置到 epoll 中去
  2. epoll 监听到文件描述符有数据到达,直接通过函数 epoll_wait中的 ep_events 参数,将文件描述符集传递出来,只需要对其进行遍历即可
  3. 由于遍历的文件描述符集就是有数据改变的文件描述符,因此效率非常高,避免无用的遍历操作
  4. 不同于 select、poll,epoll 完全没有文件描述符的限制,只限制于进程可打开文件数量的最大值(可修改),使用完成后需要关闭 epoll_create 创建的句柄

相关补充:

一个进程打开大数目的socket描述符

cat /proc/sys/fs/file-max

设置最大打开文件描述符限制

sudo vi /etc/security/limits.conf

写入以下配置,soft软限制,hard硬限制
* soft nofile 65536
* hard nofile 100000

epoll总结

epoll 是 Linux 内核为处理大批量文件描述符而作了改进的 poll,是 Linux 下多路复用IO接口 select/poll 的增强版本,它能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率。另一点原因就是获取事件的时候,它无须遍历整个被侦听的描述符集,只要遍历那些被内核IO事件异步唤醒而加入Ready队列的描述符集合就行了。

epoll除了提供 select/poll 那种IO事件的水平触发(Level Triggered)外,还提供了边缘触发(Edge Triggered),这就使得用户空间程序有可能缓存IO状态,减少 epoll_wait/epoll_pwait 的调用,提高应用程序效率。epoll支持一个进程打开大数目的 socket 描述符,io效率不随 fd 数目增加而线性下降,使用 mmap 加速内核与用户空间的消息传递。

epoll 相比 select、poll 的优势:

  1. 支持一个进程打开大数目的socket描述符
  2. IO效率不随FD数目增加而线性下降
  3. 内核微调

参考链接

总结

本章节主要介绍了 Linux 下几种常见的多路IO转接模型,以适应高并发,大数据量服务器编程实例,每个模型都有详细的代码演示和说明,相信读者看完心中会有所收货,也仅此作为自己的学习笔记,后续遗忘追溯回来,看两眼代码就能够想起来。

在博客撰文中,搜集到网络上前辈们写的一些博文,文章角度和思考深度都值得学习,希望大家看博文期间多参考文章后面的链接,多总结思考,相信会收获更多的。

几种模型的优缺点比较

直接看下表:

函数模型 select poll epoll
事件集合 内核会修改用户注册监听的文件描述符集,用以反馈就绪事件,每次调用 select 都需要重新填入监听文件描述符集 使用 pollfd.events传入监听事件,使用 pollfd.revents来反馈就绪事件 使用内内核事件表来管理用户事件,epoll_wait仅用来保存就绪事件
程序索引就绪文件描述符集的时间复杂度 O(n) O(n) O(1)
最大支持监听的文件描述符个数 有限制,一般1024 65535 65535
工作模式 LT LT 支持ET高效模式
内核实现原理 轮训方式 轮训方式 回调方式

更多详细细节比较,参考链接如下:

点我没错1

点我没错2

邢文鹏Linux教学资料

坚持原创技术分享,您的支持将鼓励我继续创作!