深入理解select、poll、epoll

674 阅读15分钟

Linux I/O 的相关函数

accept 函数

#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

#define _GNU_SOURCE /* See feature_test_macros(7) */
#include <sys/socket.h>

int accept4(int sockfd, struct sockaddr *addr,
            socklen_t *addrlen, int flags);

accept系统调用(system call)用于基本的套接字类型(SOCK_STREAM, SOCK_SEQPACKET),它会提取监听套接字(sockfd)的挂起连接队列(全连接队列)上的第一个连接请求,并创建一个新的连接的套接字,然后返回一个引用该套接字的新文件描述符。新创建的套接字不处于监听状态(处于 ESTABLISHED 状态)。原始套接字sockfd不受此调用的影响。

参数sockfd是一个 socket。它是socket函数创建的,用bind函数绑定到一个本地地址和端口号组合,在listen函数监听之后的连接。它处于监听状态。

addrsockaddr_in结构体指针, addrlen为参数addr的长度,可由sizeof()求得 ,addr保存了客户端的 IP 地址和端口号。

如果全连接队列中没有挂起的连接,并且套接字没有标记为非阻塞(nonblocking),accept()将阻塞调用者,直到出现连接为止。如果套接字标记为非阻塞(nonblocking)且队列中没有挂起的连接,则accept()失败,并出现EAGAINEWOULDBLOCK错误。

为了通知套接字上的传入连接,可以使用select(2)、poll(2)epoll(7)。当尝试建立新连接时,将传递一个可读的事件,然后可以调用accept()来获得该连接的套接字。

select 函数

#include <sys/select.h>

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

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);

select()允许程序监视多个文件描述符,直到一个或多个文件描述符准备好进行某些类型的 I/O 操作。如果文件描述符可以不阻塞地执行相应的 I/O 操作(例如,read()write()),则认为它已经准备好了。

select()只能监听小于FD_SETSIZE个文件描述符,pollepoll没有这个限制。

select()的主要参数是三个文件描述符“集合”(用类型fd_set声明),它们允许调用者等待指定的文件描述符集合上的三个类事件。如果不监视相应的事件类的文件描述符,那么每个fd_set参数都可以指定为 NULL。

select()在返回时,每个文件描述符集都会被修改,以指示哪些文件描述符当前处于“就绪”,因此,如果在循环中使用select(),则必须在每次调用之前重新初始化fd_set

操作成功时,select()将会返回三个描述符集合中"就绪"文件描述符的数量(即 readfds、writefds 和 exceptfds 中设置的总位数)。如果在任何文件描述符准备就绪之前超时已经结束,则返回值可能为零。

  1. FD_ZERO(fd_set *) 清空一个文件描述符集合;

  2. FD_SET(int ,fd_set *) 将一个文件描述符添加到一个指定的文件描述符集合中;

  3. FD_CLR(int ,fd_set*) 将一个给定的文件描述符从集合中删除;

  4. FD_ISSET(int ,fd_set* )检查集合中指定的文件描述符是否可以读写。

Bugs

select()操作不受 O_NONBLOCK影响。但是,在 Linux 上,select()可能会报告一个套接字文件描述符为"ready for reading",但是读取数据时仍然有可能会发生阻塞。例如,当数据已经到达,但在检查时发现校验和错误并被丢弃时,就会发生这种情况。在其他情况下,文件描述符也可能被虚假地报告为就绪。因此,使用非阻塞的模式的套接字会更安全。

Linux 内核对没有 fd_set 固定限制,但 glibc 实现使fd_set大小固定,FD_SETSIZE定义为 1024,要监视大于 1023 的文件描述符 需要使用 poll 或者 epoll。

poll 函数

#include <poll.h>

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

#define _GNU_SOURCE  /* See feature_test_macros(7) */
#include <signal.h>
#include <poll.h>

int ppoll(struct pollfd *fds, nfds_t nfds,
 const struct timespec *tmo_p, const sigset_t *sigmask);

poll 函数和 select 函数的功能类似,它等待一组 fd 中就绪的 I/O。

要监视的 fd 集合在fds参数中指定,它是一个结构数组,结构体如下:

struct pollfd {
    int   fd;         /* file descriptor */
    short events;     /* requested events */
    short revents;    /* returned events */
};

字段fd包含打开文件的文件描述符。如果该字段为负,则忽略相应的events字段,而revents字段返回零

字段events是一个输入参数,它是一个位掩码,用于指定应用程序对文件描述符 fd 感兴趣的事件。这个字段可以指定为 0,在这种情况下,revents中唯一可以返回的事件是POLLHUP, POLLERRPOLLNVAL

字段revents是一个输出参数,由内核填充实际发生的事件。在revents中返回的位可以包括events中指定的任何位,或者POLLERRPOLLHUPPOLLNVAL值之一。

如果任何文件描述符都没有发生请求的事件(也没有错误),则 poll()将阻塞,直到其中一个事件发生为止。

成功后,poll()返回一个非负值,该值是 pollfds 中 revents 字段被设置为非零值(表示事件或错误)的元素数量。返回值为 0 表示系统调用在读取任何文件描述符之前超时。

epoll API

epoll()执行与poll()类似的任务:监视多个文件描述符,看看其中任何一个文件上是否有 I/O。epoll API 既可以用作边缘触发(edge-triggered)的接口,也可以用作水平触发(level-triggered)的接口,并且可以很好地扩展到大量监视的文件描述符。

epoll API 的核心概念是 epoll 实例,这是一个内核数据结构,从用户空间的角度来看,它可以被视为两个列表的容器:

  • 兴趣列表(interest list):进程注册要监视的一组文件描述符
  • 就绪列表(ready list):I/O“就绪”的文件描述符集。就绪列表是兴趣列表(interest)中的文件描述符的子集(或者更准确地说,是一组对它们的引用)。就绪列表是由内核动态填充的,这是那些文件描述符上的 I/O 活动的结果。

以下系统调用来创建和管理 epoll 实例:

  • epoll_create(2)创建一个新的 epoll 实例,并返回引用该实例的文件描述符。
  • 然后通过epoll_ctl(2)注册对特定文件描述符的兴趣,它将项目添加到 epoll 实例的兴趣列表中
  • epoll_wait(2)等待 I/O 事件,如果当前没有可用的事件,则阻塞调用线程。(这个系统调用可以看作是从 epoll 实例的就绪列表中取回项目)

水平触发(LT)和边缘触发(ET)

epoll 事件分发接口能够同时作为边缘触发(ET)和级别触发(LT)。这两种机制之间的区别可以描述如下。假设这种情况发生:

  1. 注册一个文件描述符(rfd)到 epoll 实例,表示read side 管道。
  2. 在管道的write side向管道写入 2KB 数据。
  3. 调用epoll_wait()等待rfd文件描述符就绪。
  4. rfd读取 1KB 数据。
  5. 完成对epoll_wait()的调用。

如果rfd文件描述符已经使用EPOLLET (edge-triggered)标志添加到 epoll 接口, 那么在步骤5中对epoll_wait()的调用可能会挂起,尽管可用数据仍然存在于文件输入缓冲区中;同时,远程对端可能期待基于它已经发送的数据的响应。这样的原因是边缘触发模式仅在被监视的文件描述符发生更改时才发送事件。因此,在步骤5中,调用者可能最终要等待输入缓冲区中已经存在的一些数据(因为此时文件描述符 rfd 并没有发生变更)。在上面的例子中,rfd上的一个事件将因为步骤2中的写入操作而生成,而事件将在步骤3中被消耗。因为在步骤4执行的读操作并不消耗整个缓冲区数据,所以在步骤5中对epoll_wait的调用可能会无限期阻塞。

使用EPOLLET标志的应用程序应该使用非阻塞(noblocking)的文件描述符,以避免阻塞读或写,导致正在处理多个文件描述符的任务处于饥饿状态。建议使用边缘触发(EPOLLET)epoll 接口如下:

  1. 使用非阻塞文件描述符
  2. by waiting for an event only after read(2) or write(2) return EAGAIN.

相比之下,如果将 epoll 用作水平触发(LT)的接口(默认情况下,当未指定EPOLLET时),则 epoll 只是一个更快的poll(),可以在使用后者的任何地方使用它,因为它们具有相同的语义。

如果多个线程在epoll_wait()中阻塞,等待相同的 epoll 文件描述符通知的兴趣列表中的文件描述符就绪,并且标记为边缘触发(EPOLLET)。

那么只有一个线程(或进程)从epoll_wait()中唤醒。这为避免在某些场景中出现“惊群效应”唤醒提供了有用的优化。

epoll_create

#include <sys/epoll.h>

int epoll_create(int size);
int epoll_create1(int flags);

创建一个 epoll 实例,epoll_create1(0)和`epoll_create 没有区别

epoll_ctl

#include <sys/epoll.h>

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

epoll_ctl系统调用用于添加、修改或删除文件描述符epfd引用的 epoll 实例的兴趣列表中的条目。它请求对目标文件描述符 fd 执行操作op

op 参数

  • EPOLL_CTL_ADD:向epfd引用的 epoll 实例的兴趣列表中添加一个条目。该条目包括文件描述符、fd、对相应打开文件描述的引用(参见 epoll(7)和 open(2)),以及 event 中指定的设置。

  • EPOLL_CTL_MOD:将兴趣列表中与 fd 关联的设置更改为 event 中指定的新设置。

  • EPOLL_CTL_DEL:从兴趣列表中删除(取消注册)目标文件描述符 fd

event 参数

event参数描述了链接到文件描述符 fd 的对象。结构体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 */
};

epoll_event 结构的数据成员指定了当这个文件描述符准备好时内核应该保存并返回(通过 epoll_wait(2))的数据

epoll_event 结构的 events 成员是一个位掩码,由以下零个或多个可用事件类型组合在一起组成:

  • EPOLLIN: 相关文件可用于 read 操作
  • EPOLLOUT:相关文件可用于 write 操作

成功时,epoll_ctl 返回 0,当发生错误时,返回-1。

epoll_wait

#include <sys/epoll.h>

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

epoll_wait()系统调用等待文件描述符epfd引用的 epoll 实例上的事件。events指向的缓冲区用于从兴趣列表中有一些事件可用的文件描述符的就绪列表返回信息。epoll_wait()返回最大maxeventsmaxevents参数必须大于 0。

timeout 参数用于指定 epoll_wait()阻塞的时间,调用 epoll_wait()将会被阻塞,直到:

  • 文件描述符传递一个事件;
  • 调用被中断
  • 超时

每个返回的epoll_event结构的 data 字段包含与最近调用epoll_ctl(EPOLL_CTL_ADD, EPOLL_CTL_MOD)中指定的对应打开文件描述符相同的数据。

成功时,epoll_wait()返回为请求的 I/O 准备好的文件描述符的数量,如果在请求的超时毫秒期间没有文件描述符准备好,则返回 0。当发生错误时,epoll_wait()返回-1 并设置 errno。

阻塞式 I/O 的问题

服务端

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

#define BUF_SIZE 1024

int main()
{
    // 1.创建TCP socket
    int serv_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    printf("server_socket_fd: %d\n", serv_sock);
    // 2.声明addr结构体
    struct sockaddr_in serv_addr;
    // 初始化结构体
    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;                     //使用IPv4地址
    serv_addr.sin_addr.s_addr = inet_addr("127.0.0.1"); //具体的IP地址
    serv_addr.sin_port = htons(8081);                   //端口

    // 3.bind
    bind(serv_sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr));

    // 4.listen
    listen(serv_sock, 1024);

    //5. 接收客户端请求
    struct sockaddr_in clnt_addr;
    socklen_t clnt_addr_size = sizeof(clnt_addr);
    // 设置读缓冲区
    char buffer[BUF_SIZE];

    // 6. accept
    printf("accept...\n");
    int clnt_sock = accept(serv_sock, (struct sockaddr *)&clnt_addr, &clnt_addr_size);
    printf("clnt_socket_fd: %d\n", clnt_sock);

    // 7. read
    printf("read...\n");
    read(clnt_sock, buffer, sizeof(buffer) - 1);
    printf("Message form server: %s\n", buffer);

    // 8. write
    char str[] = "Hello World!";
    write(clnt_sock, str, sizeof(str));

    //9. 关闭套接字
    close(clnt_sock);
    close(serv_sock);
    return 0;
}

启动服务端后,输出如下

server_socket_fd: 3
accept...

这说明了 accept 函数是阻塞,当没有新的连接建立时 accept 会阻塞当前进程。

接下来使用nc localhost 8081命令,建立一个连接。服务端输出如下

server_socket_fd: 3
accept...
clnt_socket_fd: 4
read...

然后向服务端发送一点数据,如下

nc localhost 8081
niubi
Hello World!

这时服务端打印了niubi并立即退出,输出如下

server_socket_fd: 3
accept...
clnt_socket_fd: 4
read...
Message form server: niubi

这说明了另一个问题,read 函数在没有 socket 缓冲区没有数据的时候,会阻塞当前进程。

由于 accept 函数和 read 函数都是阻塞的,这就会造成一个问题。我们没有办法通过一个线程(可能是进程/协程,以下都以线程代替)在读取 socket 数据的时候 accept 多个 socket 的建立。所以每个 socket 都需要创建一个线程去收发数据,主线程继续处理新连接的建立。而线程创建的成本是比较高的,线程上下文切换对 CPU 的压力,创建线程所需的内存压力都是线性增加的。

非阻塞 I/O

通过使用fcntl函数或者SOCK_NONBLOCK标志,将 socket 设置为非阻塞。在非阻塞模式下 accept 函数和 read 函数在没有新连接和 socket 缓冲区都不会阻塞当前线程。

server_socket_fd: 3
accept...
clnt_socket_fd: -1
read...
Message form server:

accept 函数和 read 函数不阻塞,这为我们使用一个线程处理多个连接提供了一种可能,但是这种做法仍然不可行,为什么?因为应用程序不知道哪个连接可读了,必须得遍历所有的连接,调用 read 函数。系统调用太多了。

I/O 多路复用

使用 select 函数

#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <stdio.h>

int fd[1024]; //连接的fd数组
#define BUF_SIZE 1024

int main(void)
{
    int yes = 1;

    int sock_fd; //监听套接字

    // 1.建立sock_fd套接字
    if ((sock_fd = socket(AF_INET, SOCK_STREAM, 0)) == -1)
    {
        perror("socket error");
        exit(1);
    }
    printf("server_sock_fd = %d\n", sock_fd);

    // 设置套接口的选项 SO_REUSEADDR 允许在同一个端口启动服务器的多个实例
    // setsockopt的第二个参数SOL SOCKET 指定系统中,解释选项的级别 普通套接字
    if (setsockopt(sock_fd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(int)) == -1)
    {
        perror("setsockopt error \n");
        exit(1);
    }

    // 2.声明server addr结构体,client addr结构体
    struct sockaddr_in serv_addr;
    struct sockaddr_in clnt_addr;
    socklen_t serv_addr_size = sizeof(serv_addr);
    socklen_t clnt_addr_size = sizeof(clnt_addr);
    // 初始化结构体
    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;                     //使用IPv4地址
    serv_addr.sin_addr.s_addr = inet_addr("127.0.0.1"); //具体的IP地址
    serv_addr.sin_port = htons(8081);                   //端口

    // 3.bind
    bind(sock_fd, (struct sockaddr *)&serv_addr, sizeof(serv_addr));

    // 4.listen
    listen(sock_fd, 1024);

    // 文件描述符集的定义
    fd_set fdsr;
    int maxsock = sock_fd;
    struct timeval tv;
    // 设置读缓冲区
    char buffer[BUF_SIZE];
    int conn_amount = 0;

    while (1)
    {
        // 清空描述符集
        FD_ZERO(&fdsr);
        // 添加server_sock_fd到描述符集
        FD_SET(sock_fd, &fdsr);
        //设置超时时间为阻塞30s,将这个参数设置为NULL,表明此时select为阻塞模式
        tv.tv_sec = 30;
        tv.tv_usec = 0;

        // 重新添加所有的描述符到描述符集合
        for (int i = 0; i < conn_amount; i++)
        {
            if (fd[i] != 0)
            {
                FD_SET(fd[i], &fdsr);
            }
        }

        //如果文件描述符中有连接请求 会做相应的处理,实现I/O的复用 多用户的连接通讯
        printf("select...\n");

        int res = select(maxsock + 1, &fdsr, NULL, NULL, &tv);
        if (res < 0) //没有找到有效的连接 失败
        {
            perror("select error!\n");
            break;
        }
        else if (res == 0) // 指定的时间到,
        {
            printf("timeout \n");
            continue;
        }

        printf("select result is %d\n", res);

        if (FD_ISSET(sock_fd, &fdsr))
        {
            int clnt_sock = accept(sock_fd, (struct sockaddr *)&clnt_addr, &clnt_addr_size);

            printf("accpet a new client fd=[%d]: %s:%d\n", clnt_sock, inet_ntoa(clnt_addr.sin_addr), clnt_addr.sin_port);
            // 将套接字存储到数组中
            fd[conn_amount++] = clnt_sock;

            if (clnt_sock > maxsock)
            {
                maxsock = clnt_sock;
            }
        }

        //下面这个循环是非常必要的,因为你并不知道是哪个连接发过来的数据,所以只有一个一个去找。
        for (int i = 0; i < conn_amount; i++)
        {
            if (FD_ISSET(fd[i], &fdsr))
            {
                res = recv(fd[i], buffer, sizeof(buffer), 0);
                //如果客户端主动断开连接,会进行四次挥手,会出发一个信号,此时相应的套接字会有数据返回,告诉select,我的客户断开了,你返回-1

                if (res <= 0) //客户端连接关闭,清除文件描述符集中的相应的位
                {
                    printf("client fd=[%d] close\n", fd[i]);
                    close(fd[i]);
                    FD_CLR(fd[i], &fdsr);
                    fd[i] = 0;
                    conn_amount--;
                }
                //否则有相应的数据发送过来 ,进行相应的处理
                else
                {
                    if (res < BUF_SIZE)
                        memset(&buffer[res], '\0', 1);
                    printf("clint fd=[%d] recv message: %s\n", fd[i], buffer);

                    char str[] = "Hello World!";
                    write(fd[i], str, sizeof(str));

                    printf("clint fd=[%d] send message: %s\n", fd[i], str);
                }
            }
        }
    }
}

select 函数使用三个fd_set来监控 socket 是否"就绪",它避免所有的 socket 都要进行system call的问题,让我们只使用一个线程就能处理很多的连接。但是它也有一些问题。

  1. glibc 标准库的fd_set是实际上是一个位图,它的最大值是 1024。所以最多只能操作 1024 个文件描述符。
  2. 每次循环都需要将所有的 fd 从用户空间拷贝内核空间。
  3. 每次循环之前都需要重新初始化fd_set,并把所有 socket 的 fd 重新添加到fd_set中,当 fd 符多的时候这也是一笔不小的开销。
  4. 只会返回有连接已经"就绪",但是不知道具体是哪个连接就绪了,还需要遍历所有的 fd,去确定哪个连接就绪。
  5. 内核通过循环遍历的方式检查fd_set中是否有就绪的 fd,如果没有发现有就绪的 fd,则挂起当前进程到等待队列,当有 fd 就绪或者主动超时,内核会通过回调函数唤醒进程,进程被唤醒后会再一次遍历fd_set,cpu 的消耗随着监控的 fd 的数量呈线性增加。
  6. 假就绪的 bug

使用 poll 函数

#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <stdio.h>
#include <sys/poll.h>
#include <fcntl.h>

#define READ_BUF_LEN 256
#define BACK_LOG 1024
#define OPEN_MAX 1024

int main()
{

    int sock_fd; //监听套接字

    // 1.建立sock_fd套接字
    if ((sock_fd = socket(AF_INET, SOCK_STREAM, 0)) == -1)
    {
        perror("socket error");
        exit(1);
    }
    printf("server_sock_fd = %d\n", sock_fd);

    int on = 1;
    // 设置套接口的选项 SO_REUSEADDR 允许在同一个端口启动服务器的多个实例
    // setsockopt的第二个参数SOL SOCKET 指定系统中,解释选项的级别 普通套接字
    if (setsockopt(sock_fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(int)) == -1)
    {
        perror("setsockopt error \n");
        exit(1);
    }

    // 2.声明server addr结构体,client addr结构体
    struct sockaddr_in serv_addr;
    struct sockaddr_in clnt_addr;
    socklen_t serv_addr_size = sizeof(serv_addr);
    socklen_t clnt_addr_size = sizeof(clnt_addr);

    // 初始化结构体
    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;                     //使用IPv4地址
    serv_addr.sin_addr.s_addr = inet_addr("127.0.0.1"); //具体的IP地址
    serv_addr.sin_port = htons(8081);                   //端口

    // 3.bind
    bind(sock_fd, (struct sockaddr *)&serv_addr, serv_addr_size);

    // 4.listen
    listen(sock_fd, BACK_LOG);

    struct pollfd client[OPEN_MAX];
    client[0].fd = sock_fd;
    client[0].events = POLLIN;
    int conn_amount = 1;
    int sockfd, ret;
    // 设置读缓冲区
    char buffer[READ_BUF_LEN];
    while (1)
    {
        printf("poll ...\n");
        int res = poll(client, conn_amount + 1, -1);
        printf("poll result is %d\n", res);
        // 新连接接入
        if (client[0].revents & POLLIN)
        {
            int clnt_sock = accept(sock_fd, (struct sockaddr *)&clnt_addr, &clnt_addr_size);

            printf("accpet a new client fd=[%d]: %s:%d\n", clnt_sock, inet_ntoa(clnt_addr.sin_addr), clnt_addr.sin_port);
            // 将套接字存储到数组中
            conn_amount++;
            client[conn_amount].fd = clnt_sock;
            client[conn_amount].events = POLLIN;
        }
        // 遍历所有的连接
        for (int i = 1; i <= conn_amount; i++)
        {
            if ((sockfd = client[i].fd) < 0)
                continue;
            if (client[i].revents & POLLIN)
            {
                if ((ret = read(sockfd, buffer, sizeof(buffer))) <= 0)
                {
                    printf("client fd=[%d] close\n", sock_fd);
                    client[i].fd = -1;

                    close(sockfd);
                }
                else
                {
                    printf("clint fd=[%d] recv message: %s\n", sockfd, buffer);

                    char str[] = "Hello World!";
                    write(sockfd, str, sizeof(str));
                    printf("clint fd=[%d] send message: %s\n", sockfd, str);
                }
            }
        }
    }
}

poll 函数的优点

  1. poll 函数使用数组存储文件描述符,它没有最大数量的限制。
  2. poll 函数的输入和输出是分开的(event 和 revent)。在调用时不需要从用户态拷贝所有文件描述符到内核态。

缺点是和 select 一样,在返回后需要遍历所有的文件描述符,来获取就绪的 socket。事实上在大量客户端连接的情况下某一时刻只会有少数的已就绪连接。随着监视文件描述符的增加,性能会程线性下降。

使用 epoll API

#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <stdio.h>
#include <sys/epoll.h>
#include <fcntl.h>

#define MAX_EVENT 20
#define READ_BUF_LEN 256
#define BACK_LOG 1024

void setNonblocking(int sockfd)
{
    int opts;
    opts = fcntl(sockfd, F_GETFL);
    if (opts < 0)
    {
        perror("fcntl(sock,GETFL)");
        return;
    } //if

    opts = opts | O_NONBLOCK;
    if (fcntl(sockfd, F_SETFL, opts) < 0)
    {
        perror("fcntl(sock,SETFL,opts)");
        return;
    } //if
}

int main()
{

    int sock_fd; //监听套接字

    // 1.建立sock_fd套接字
    if ((sock_fd = socket(AF_INET, SOCK_STREAM, 0)) == -1)
    {
        perror("socket error");
        exit(1);
    }
    printf("server_sock_fd = %d\n", sock_fd);

    int on = 1;
    // 设置套接口的选项 SO_REUSEADDR 允许在同一个端口启动服务器的多个实例
    // setsockopt的第二个参数SOL SOCKET 指定系统中,解释选项的级别 普通套接字
    if (setsockopt(sock_fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(int)) == -1)
    {
        perror("setsockopt error \n");
        exit(1);
    }

    // 2.声明server addr结构体,client addr结构体
    struct sockaddr_in serv_addr;
    struct sockaddr_in clnt_addr;
    socklen_t serv_addr_size = sizeof(serv_addr);
    socklen_t clnt_addr_size = sizeof(clnt_addr);

    // 初始化结构体
    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;                     //使用IPv4地址
    serv_addr.sin_addr.s_addr = inet_addr("127.0.0.1"); //具体的IP地址
    serv_addr.sin_port = htons(8081);                   //端口

    // 3.bind
    bind(sock_fd, (struct sockaddr *)&serv_addr, serv_addr_size);

    // 4.listen
    listen(sock_fd, BACK_LOG);

    setNonblocking(sock_fd);

    /*声明epoll_event结构体变量,ev用于注册事件,数组用于回传要处理的事件*/
    struct epoll_event ev, events[MAX_EVENT];
    // 5.创建epoll实例
    int epfd = epoll_create1(0);

    /*设置监听描述符*/
    ev.data.fd = sock_fd;
    /*设置处理事件类型, 为边缘触发*/
    ev.events = EPOLLIN | EPOLLET;

    /*注册事件*/
    epoll_ctl(epfd, EPOLL_CTL_ADD, sock_fd, &ev);
    int sockfd;
    // 设置读缓冲区
    char buffer[READ_BUF_LEN];
    char str[] = "Hello World!";
    ssize_t n, ret;

    for (;;)
    {
        printf("epoll wait...\n");
        int nfds = epoll_wait(epfd, events, MAX_EVENT, -1);
        if (nfds <= 0)
            continue;
        printf("nfds = %d\n", nfds);

        for (int i = 0; i < nfds; i++)
        {
            // 新连接接入
            if (events[i].data.fd == sock_fd)
            {
                int clnt_sock = accept(sock_fd, (struct sockaddr *)&clnt_addr, &clnt_addr_size);
                printf("accpet a new client fd=[%d]: %s:%d\n", clnt_sock, inet_ntoa(clnt_addr.sin_addr), clnt_addr.sin_port);
                // 设置非阻塞
                setNonblocking(clnt_sock);
                /*设置监听描述符*/
                ev.data.fd = clnt_sock;
                /*设置处理事件类型, 为边缘触发*/
                ev.events = EPOLLIN | EPOLLET;
                epoll_ctl(epfd, EPOLL_CTL_ADD, clnt_sock, &ev);
            }
            else if (events[i].events & EPOLLIN)
            { //
                if ((sockfd = events[i].data.fd) < 0)
                    continue;
                if ((ret = read(sockfd, buffer, sizeof(buffer))) <= 0)
                {
                    printf("client fd=[%d] close\n", sock_fd);
                    close(sockfd);
                    events[i].data.fd = -1;
                }
                else
                {
                    printf("clint fd=[%d] recv message: %s\n", sock_fd, buffer);

                    /*设置用于注册写操作文件描述符和事件*/
                    ev.data.fd = sockfd;
                    ev.events = EPOLLOUT | EPOLLET;
                    epoll_ctl(epfd, EPOLL_CTL_MOD, sockfd, &ev);
                }
            }
            else if (events[i].events & EPOLLOUT)
            {
                if ((sockfd = events[i].data.fd) < 0)
                    continue;
                write(sockfd, str, sizeof(str));
                printf("clint fd=[%d] send message: %s\n", sock_fd, str);
                //if
                /*设置用于读的文件描述符和事件*/
                ev.data.fd = sockfd;
                ev.events = EPOLLIN | EPOLLET;
                /*修改*/
                epoll_ctl(epfd, EPOLL_CTL_MOD, sockfd, &ev);
            }
        }
    }
}

epoll 的优点

  1. 监视的文件描述符不受数量限制。
  2. 事件驱动
  3. 只返回就绪的 socket 文件描述符
  4. epoll 实例采用红黑树