TCP IP网络编程(13) Linux下epoll与多线程

优于select的epoll

1. epoll的理解与应用

  select服用方法由来已久,在《TCP/IP网络编程(6) 》中,介绍了如何使用select方法实现IO复用。但是利用该技术后,无论如何优化程序性能,也难于同时接入上百个客户端(同时也是基于硬件性能的不同)。这种select方式并不适合以web服务器端开发,因此需要了解Linux平台下的epoll。

1.1 基于select的IO复用技术速度慢的原因

  针对《TCP/IP网络编程(6) 》中基于select的IO复用代码,很容易分析其中的不合理之处:

  • 调用select()函数后常见的针对所有文件描述符的循环操作
  • 每次调用select()函数时都需要向该函数传递监视对象的信息

调用select()函数之后,并不是把发生变化的文件描述符集中到一起,而是通过观察作为监视对象的fd_set变量的变化,从而找出发生变化的文件描述符。这一操作无法避免针对所有监视对象的循环语句,而且,作为监视对象的fd_set变量会发生变化,所以在调用select()函数之前,应该复制并保存fd_set变量原有的信息,并在每次调用select()函数时传递新的监视对象信息(具体可见《TCP/IP网络编程(6) 》中详细代码)

那么是哪些因素阻碍了程序性能的进一步提高呢?

  1. 循环语句 ?
  2. 每次传递的对象监视信息 ?

实际上更大的障碍是每次调用select()函数时向操作系统传递的对象监视信息,应用程序向操作系统传递数据将对程序造成很大的负担,且无法通过优化应用程序代码解决,因此成为性能上的致命弱点

因为select()与文件描述符有关,更准确的说是监视套接字变化的函数,而套接字是由操作系统进行管理的,因此select()函数必须借助操作系统才能完成其功能。

select()函数这一缺点的改进方法:仅向操作系统传第一次监视对象,监视范围或者内容发生变化的时候,只通知发生变化的事项。这样就无需每次调用select()函数的时候,都要向操作系统传第一次监视对象信息。Linux操作系统通过epoll支持这种处理方式,Windows是通过IOCP进行支持。

select()函数的优点:

  • 具备更好的兼容性 (例如epoll尽在Linux下支持,但是select具备兼容性)
  • 服务器端接入的客户端数量很少,即可使用select()
1.2 epoll实现

epoll()函数具备如下优点:

  • 无需编写以监视状态变化为目的的针对所有文件描述符的循环语句
  • 调用epoll_wait()函数(对应于select()函数)的时候无需每次都传递监视对象信息

epoll()服务器端实现中需要的3个函数:

  1. epoll_create : 创建保存epoll文件描述符的空间
  2. epoll_ctl : 向空间注册并注销文件描述符
  3. epoll_wait : 与select()函数类似,等待文件描述符发生变化

select方式中需要声明fd_set变量保存监视对象文件描述符不同的是,epoll方式下由操作系统负责保存监视对象的文件描述符,因此首先需要向操作系统请求创建保存文件描述符的空间,此操作是通过epoll_create()函数完成的。

select函数中,添加和删除监视对象的文件描述符通过FD_SET,FD_CLR来完成,在epoll方式下,需要调用epoll_ctl()函数,请求操作系统完成。

调用epoll_wait()函数等待文件描述符变化,在select方式中,通过fd_set变量查看监视对象的状态变化(事件是否发生),而在epoll方式下,通过结构体epoll_event将发生变化的文件描述符单独集中到一起。

epoll_event结构体定义

struct epoll_event
{
    __uint32 events;
    epoll_data_t data;
}

typedef union epoll_data
{
    void* ptr;
    int fd;
    __uint32 u32;
    __uint64 u64;
} epoll_data_t;

仅需申明足够大的epoll_event结构体数组后,传递给epoll_wait()函数,发生变化的文件描述符信息将被填入该结构体数组,无需像select()函数那样针对所有文件描述符进行循环。

1.3 epoll相关函数介绍
1.3.1 epoll_create函数

  epoll_create()函数是从Linux 2.5.44版本的内核开始引入的,可通过如下命令查看Linux版本内核:

cat /proc/sys/kernel/osrelease
#include <sys/epoll.h>

int epoll_create(int size);

// size : epoll实例的大小  返回值: 文件描述符,与套接字相似

调用epoll_create()函数创建的文件描述符保存空间称之为epoll例程,参数size的值决定epoll例程的大小,但是size的值只是向操作系统提供建议,size的值并非最后用来决定epoll例程的大小,仅仅供操作系统参考。

注:Linux 2.6.8之后的内核将完全忽略```epoll_create()函数传入的size参数,而是完全由操作系统系统内核自行决定epoll例程大小。

epoll_create()函数创建的资源与套接字相同,也是由操作系统进行管理。因此,该函数也会返回文件描述符,该函数返回的文件描述符主要用于区分epoll例程,需要终止时,同样需要调用close()函数进行关闭。

1.3.2 epoll_ctl函数

生成epoll例程后,应在其内部注册监视对象文件描述符。

#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

参数含义:

  • epfd   用于注册监视对象的epoll例程文件描述符
  • op   用于指定监视对象的添加,删除,或者修改,其值和含义如下所示
    1. EPOLL_CTL_ADD : 将文件描述符注册到epoll例程
    2. EPOLL_CTL_DEL : 从epoll例程中删除文件描述符, 此时event参数应该设为NULL
    3. EPOLL_CTL_MOD : 更改注册的文件描述符的关注事件发生情况
  • fd   需要注册的监视对象的文件描述符
  • event  监视对象的事件类型

epoll_ctl()函数中,第四个参数为epoll_event结构体,此处的作用是用于在epoll例程中注册文件描述符时,用于注册关注的事件。此外之前介绍过epoll_event结构体还可以用于保存发生事件的文件描述符集合。

示例代码: 将sockfd注册到epoll例程epfd当中,并在需要读取数据的情况下产生相应的事件

struct epoll_event event;

event.events = EPOLLIN;     // 发生需要读取数据的情况时
event.data.fd = sockfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &event);

epoll_event的成员events可取的值及其含义如下所示:

  • EPOLLIN  需要读取数据的情况
  • EPOLLOUT  输出缓冲区为空,可以立刻发送数据的情况
  • EPOLLPRI  收到OOB数据的情况
  • EPOLLRDHUP   套接字在断开连接或者半关闭的情况,在边缘触发方式下非常有用
  • EPOLLERR  发生错误的情况
  • EPOLLET  以边缘触发的方式得到事件通知
  • EPOLLONESHOT  发生一次事件后,相应的文件描述符不再收到事件通知。因此需要向epoll_ctl()函数的第二个参数传递EPOLL_CTL_MOD,在此设置事件。

上述的多个参数可通过位或运算同时进行传递。

1.3.3 epoll_wait函数
#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event* events, int maxevents, int timeout);

// epfd : 事件发生监视范围的epoll例程文件描述符
// events : 保存发生事件的文件描述符集合的结构体地址
// maxevents : 第二个参数中可以保存的最大事件数量
// timeout: 等待时间(单位:ms),若传递-1,则会一直等待事件发生
// 返回值: 发生事件的文件描述符数量

epoll_wait的使用步骤方法如下所示:

int event_count;     // 存储发生事件的文件描述符数量

struct epoll_event *ep_events;

ep_events = malloc(sizeof(struct epoll_event) * EPOLL_SIZE);    // EPOLL_SIZE为宏常量

event_count = epoll_wait(epfd, ep_events, EPOLL_SIZE, -1);

调用函数后,返回发生事件的文件描述符的数量,且在第二个参数指向的缓冲区中保存发生事件的文件描述符集合,无需像select()函数那样插入循环去遍历;

基于epoll的回声服务器实现

sever.cpp

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/epoll.h>

#define BUF_SIZE  100
#define EPOLL_SIZE  50
#define SERVER_PORT 13520

void error_handler(const char* msg);

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

    struct sockaddr_in servAddr, clientAddr;

    socklen_t addrSize = sizeof(servAddr);

    char buffer[BUF_SIZE] = { 0 };

    struct epoll_event *ep_events;    
    struct epoll_event event;

    int epfd;

    int eventCount;

    serverSock = socket(PF_INET, SOCK_STREAM, 0);
    memset(&servAddr, 0, sizeof(servAddr));
    servAddr.sin_family = AF_INET;
    servAddr.sin_addr.s_addr = htonl(INADDR_ANY);
    servAddr.sin_port = htons(SERVER_PORT);

    if (bind(serverSock, (struct sockaddr*)&servAddr, addrSize) == -1)
    {
        error_handler("Failed to bind the server socket.");
    }

    if (listen(serverSock, 5) == -1)
    {
        error_handler("Failed to listen the server socket.");
    }

    // epoll相关
    epfd = epoll_create(EPOLL_SIZE);    // 创建epoll例程
    
    // 分配epoll_event数组,存储发生变化的文件描述符监视对象
    ep_events = (struct epoll_event*)malloc(sizeof(struct epoll_event) * EPOLL_SIZE);

    // 指定epoll例程中注册文件描述符监视的事件类型
    event.events = EPOLLIN;
    event.data.fd = serverSock;     // 需要监视的文件描述符
    epoll_ctl(epfd, EPOLL_CTL_ADD, serverSock, &event);

    while (true)
    {
        eventCount = epoll_wait(epfd, ep_events, EPOLL_SIZE, -1);    

        if (eventCount == -1)
        {
            fputs("epoll_wiat() error.\n", stderr);
            break;
        }

        for (size_t i = 0; i < eventCount; i++)
        {
            if (ep_events[i].data.fd == serverSock)
            {
                // 服务端出现新的连接请求
                clientSock = accept(serverSock, (struct sockaddr*)&clientAddr, &addrSize);
                
                if (clientSock == -1)
                {
                    fputs("accept error().\n", stderr);
                    continue;
                }

                event.data.fd = clientSock;
                event.events = EPOLLIN;

                epoll_ctl(epfd, EPOLL_CTL_ADD, clientSock, &event);

                printf("Accepted connect from %s %d.\n", inet_ntoa(clientAddr.sin_addr), ntohs(clientAddr.sin_port));
            }
            else
            {
                int len = read(ep_events[i].data.fd, buffer, BUF_SIZE-1);

                if (len == 0)    // receive eof
                {
                    epoll_ctl(epfd, EPOLL_CTL_DEL, ep_events[i].data.fd, NULL);
                    close(ep_events[i].data.fd);
                    printf("disconnect client: %d.\n", ep_events[i].data.fd);
                }
                else
                {
                    // 写数据
                    write(ep_events[i].data.fd, buffer, len);   // 返回接收的数据到客户端
                }
            }

            memset(buffer, 0, BUF_SIZE);
        }
        
    }

    close(serverSock);
    close(epfd);
    
    return 0;
}

void error_handler(const char* msg)
{
    printf("ERROR: %s\n", msg);
    exit(-1);
}
1.4 条件触发和边缘触发

  在学习epoll的时候,需要掌握条件触发(Level Triggered)和边缘触发(Edge Trigger)的区别:
  条件触发和边缘触发的区别在于发生事件的时间点

1.4.1 条件触发

"条件触发方式中,只要缓冲区中有数据就会一直通知该事件"

例如,服务器端输入缓冲区收到50字节的数据时,服务器端操作系统将通知该事件(即注册到发生变化的文件描述符),假设服务器端读取20字节后,还剩下30字节,此时依然会注册事件。也即在条件触发方式中,只要输入缓冲区中还有数据,就将以事件方式再次注册。

边缘触发方式:

在边缘触发中,输入缓冲区收到数据的时候,仅仅注册一次,即使输入缓冲区中的数据没有一次读完,也不会再进行注册。

将上述的epoll回声服务器端代码稍作修改,将缓冲区修改为5, 且对epoll_wait()执行进行记录:

server.cpp


#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/epoll.h>

#define BUF_SIZE  5
#define EPOLL_SIZE  50
#define SERVER_PORT 13520

void error_handler(const char* msg);

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

    struct sockaddr_in servAddr, clientAddr;

    socklen_t addrSize = sizeof(servAddr);

    char buffer[BUF_SIZE] = { 0 };

    struct epoll_event *ep_events;    
    struct epoll_event event;

    int epfd;

    int eventCount;

    serverSock = socket(PF_INET, SOCK_STREAM, 0);
    memset(&servAddr, 0, sizeof(servAddr));
    servAddr.sin_family = AF_INET;
    servAddr.sin_addr.s_addr = htonl(INADDR_ANY);
    servAddr.sin_port = htons(SERVER_PORT);

    if (bind(serverSock, (struct sockaddr*)&servAddr, addrSize) == -1)
    {
        error_handler("Failed to bind the server socket.");
    }

    if (listen(serverSock, 5) == -1)
    {
        error_handler("Failed to listen the server socket.");
    }

    // epoll相关
    epfd = epoll_create(EPOLL_SIZE);
    ep_events = (struct epoll_event*)malloc(sizeof(struct epoll_event) * EPOLL_SIZE);

    // 指定epoll例程中注册文件描述符监视的事件类型
    event.events = EPOLLIN;
    event.data.fd = serverSock;     // 需要监视的文件描述符
    epoll_ctl(epfd, EPOLL_CTL_ADD, serverSock, &event);

    while (true)
    {
        eventCount = epoll_wait(epfd, ep_events, EPOLL_SIZE, -1);    

        if (eventCount == -1)
        {
            fputs("epoll_wiat() error.\n", stderr);
            break;
        }

        fputs("called the epoll_wait() function.\n", stdout);

        for (size_t i = 0; i < eventCount; i++)
        {
            if (ep_events[i].data.fd == serverSock)
            {
                // 服务端出现新的连接请求
                clientSock = accept(serverSock, (struct sockaddr*)&clientAddr, &addrSize);
                
                if (clientSock == -1)
                {
                    fputs("accept error().\n", stderr);
                    continue;
                }

                event.data.fd = clientSock;
                event.events = EPOLLIN;

                epoll_ctl(epfd, EPOLL_CTL_ADD, clientSock, &event);

                printf("Accepted connect from %s %d.\n", inet_ntoa(clientAddr.sin_addr), ntohs(clientAddr.sin_port));
            }
            else
            {
                int len = read(ep_events[i].data.fd, buffer, BUF_SIZE-1);

                if (len == 0)    // receive eof
                {
                    epoll_ctl(epfd, EPOLL_CTL_DEL, ep_events[i].data.fd, NULL);
                    close(ep_events[i].data.fd);
                    printf("disconnect client: %d.\n", ep_events[i].data.fd);
                }
                else
                {
                    // 写数据
                    write(ep_events[i].data.fd, buffer, len);   // 返回接收的数据到客户端
                }
            }

            memset(buffer, 0, BUF_SIZE);
        }
        
    }

    close(serverSock);
    close(epfd);
    
    return 0;
}

void error_handler(const char* msg)
{
    printf("ERROR: %s\n", msg);
    exit(-1);
}

client.cpp

#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <string.h>

#define BUF_SIZE 100
#define SERVER_ADDR  "127.0.0.1"
#define SERVER_PORT  13520


void error_handler(const char* msg);

int main(int argc, char* argv[])
{
    int sock;
    char buffer[BUF_SIZE];

    struct sockaddr_in servAddr;

    sock = socket(PF_INET, SOCK_STREAM, 0);

    if (sock == -1)
    {
        error_handler("Failed to create sock");
    }

    memset(buffer, 0 , BUF_SIZE);

    memset(&servAddr, 0, sizeof(servAddr));
    servAddr.sin_family = AF_INET;
    servAddr.sin_addr.s_addr = inet_addr(SERVER_ADDR);
    servAddr.sin_port = htons(SERVER_PORT);

    if (connect(sock, (struct sockaddr*)&servAddr, sizeof(servAddr)) == -1)
    {
        error_handler("Failed to connect to server.\n");
    }

    printf("Connecting to server: %s : %d.\n", inet_ntoa(servAddr.sin_addr), ntohs(servAddr.sin_port));

    while(true)
    {
        fputs("Input message(Q or q to quit):", stdout);
        fgets(buffer, BUF_SIZE, stdin);

        if (!strcmp(buffer, "q\n") || !strcmp(buffer, "Q\n"))
        {
            break;
        }

        // 向服务端写数据
       int len = write(sock, buffer, strlen(buffer));
        
        // 清空缓冲区
        memset(buffer, 0, BUF_SIZE);

        int recvLen = 0;
        while (recvLen < len)
        {
            int recvCount = read(sock, &buffer[recvLen], BUF_SIZE-1);

            if (recvCount == -1)
            {
                printf("read() error.\n");
                break;
            }

            recvLen += recvCount;
        }
    
        printf("Message from sever is: %s\n", buffer);

        memset(buffer, 0, BUF_SIZE);
    }

    close(sock);

    return 0;
}

void error_handler(const char* msg)
{
    printf("%s\n", msg);
    exit(-1);
}

运行结果:
img

减少服务端缓冲区大小是为了防止服务器端一次性接受完所有数据,可以看到在每次调用read()读取后,服务端有剩余的数据,就会注册新的事件,调用epoll_wait()函数后,被监视的文件描述符任然会被放入struct epoll_event结构体数组中。因此从运行结果图中,可以看到客户端每发送一次长字符串之后,服务端会多次打印信息"called epoll_wait() function."信息。

边缘触发方式:

仅需对服务端代码做微小改动:

if (ep_events[i].data.fd == serverSock)
{
    // 服务端出现新的连接请求
    clientSock = accept(serverSock, (struct sockaddr*)&clientAddr, &addrSize);
    
    if (clientSock == -1)
    {
        fputs("accept error().\n", stderr);
        continue;
    }

    event.data.fd = clientSock;
    event.events = EPOLLIN | EPOLLET;     // 边缘触发方式

    epoll_ctl(epfd, EPOLL_CTL_ADD, clientSock, &event);

    printf("Accepted connect from %s %d.\n", inet_ntoa(clientAddr.sin_addr), ntohs(clientAddr.sin_port));
}

可以验证服务端在接收到数据之后,仅仅打印了一次"called epoll_wait() function."信息。

select模型是以条件触发的方式进行工作的,输入缓冲区中只要有数据,一定会注册事件

1.4.2 边缘触发方式的服务器实现注意事项

实现边缘触发的两点必知内容:

  • 通过errno变量验证错误原因
  • 为了完成非阻塞(non-block)IO,更改套接字特性

Linux中套接字相关的函数一般通过返回-1,来表示函数调用发生了错误,但是仅凭返回值无法得知发生错误的具体原因。为了在发生错误的时候提供额外的信息,Linux声明了一个全局变量:

int errno;

为了能够访问errno变量,需要隐入头文件error.h,其中有此变量的extern声明,当函数调用发生错误的时候,会将对应的错误码保存到变量errno中。

例如,read()函数发现输入缓冲区中没有数据可以读取的时候,会返回-1,同时在变量errno中保存EAGAIN.

记录一种Linux提供的更改或者读取文件属性的方法:

#include <fcntl.h>

int fcntl(int filedes, int cmd, ...);

// filedes : 更改目标的文件描述符
// cmd :  表示函数调用的目的
// 第三个参数需要根据cmd的值来进行传入
// 返回值: 执行失败,返回-1,执行成功,返回值与cmd有关

fcntl()能够对一个已经打开文件描述符进行一些列控制操作,例如复制文件描述符,获取/设置文件描述符标志,获取/设置文件状态标志等,类似于一个多功能文件描述符管理工具箱。

cmd操作命令大致可以分为五种:

  • 复制文件描述符 cmd = F_DUPFD
  • 获取/设置文件描述符标志 cmd = F_GETFD / F_SETFD
  • 获取/设置文件状态标志 cmd = F_GETFL / F_SETFL
  • 获取/设置异步IO所有权 cmd = F_GETOWN / F_SETOWN
  • 获取/设置记录锁 cmd = F_GETLK /F_SETLK

两个概念:

  • 文件描述符标志:

    每个进程为打开的文件维护的一个标志,目前这个标志只定义了一个值。主要用于父进程fork()出子进程,子进程在调用exec时,会用到此标志。如果在父进程中打开某些文件创建了文件对应的描述符,在fork出的子进程时,子进程默认也具有这些文件的读写权限(因为子进程将父进程的文件描述符也复制了过来);但是在某些应用场景下,并不像让子进程拥有这些权限,此时可通过文件描述符标志,来进行设置,使子进程在执行exec前关闭这些文件描述符,避免子进程拥有这些权限。这个标志为FD_CLOEXEC,当此标志被置位的时候,子进程就在调用exec()的时候关闭文件描述符。

  • 文件状态标志:

    用于指明文件的状态属性,由open()函数的flags参数进行指定。与“文件描述符标志对应进程”不同,复制的文件描述符指向相同的文件描述,所以他们共享文件状态标志。Duplicated file descriptors (made with dup(2), fcntl(F_DUPFD), fork(2), etc.) re‐fer to the same open file description, and thus share the same file status flags.

文件状态标志可分为三类:

  • 访问方式标志
  • 打开时标志
  • IO操作方式标志

访问方式标志:
  指明文件描述符用于读,写,读写,O_RDONLY,O_WRONLY,O_RDWR

打开时标志:

posted @ 2023-01-03 08:27  Alpha205  阅读(295)  评论(0编辑  收藏  举报