【UNIX网络编程】I/O多路复用

目录

1、select 

1.1、函数API

1.2、使用流程

1.3、底层原理

1.4、优缺点

1.5、代码示例

2、poll

2.1、函数API

2.2、使用流程

2.3、优缺点

3、epoll

3.1、工作流程和原理

3.2、水平触发和边缘触发

3.3、代码示例

3.4、优缺点


I/O 多路复用是允许单个线程或进程同时去监控多个 I/O 操作的技术,其核心就是通过一个系统调用去内核监听多个文件描述符,当内核将数据准备好了之后会返回可读的条件,这时候我们再去调用系统调用将数据拷贝到用户缓冲区里。不了解网络 I/O 可以先看【UNIX网络编程】5种I/O模型这篇文章

常见的 I/O 复用技术有 select、poll、epoll。

1、select 

select的核心原理是通过单进程创建一个文件描述集合,我们将关心的文件描述符添加到这里。内核通过轮询检测的方式监控是否有文件描述符可读写,一但有文件描述符就绪,通知进程进行 I/O。

1.1、函数API

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

int select(int maxfdp1, fd_set *readset, fd_set *writeset, fd_set *exceptset,
           const struct timeval *timeout);
/* 成功返回就绪描述符数据,超时返回0,出错返回-1 */

maxfdp1:用于指定待监测的文件描述符的最大编号,它的值是待测试的最大文件描述符加1。

从0、1、2......一直到 maxfdp1-1。例如我们要监测 {0,2,7},那我们的 maxfdp1 就要设置为8。

头文件 #include <sys/select.h> 定义的 FD_SETSIZE 是 fd_set 中的最大文件描述符编号,默认是1024

由于系统的限制,进程默认能打开的最大文件描述符数通常也是 1024(通过 ulimit -n 查看)。所以即使 FD_SETSIZE 更大,若进程无法打开更多 fdselect() 也无法使用。

虽然我们能够去修改进程默认打开的最大文件描述符数量,但是从可以移植性来说,我们需要更加小心。

readset,writeset,exceptset:分别时指向可读、可写、异常事件的文件描述符

timeout:超时时间结构体指针,指定 select() 等待的最长时间。支持秒和微秒。当不检测任何 fd 的时候 ,可以用于实现微秒级定时器。

当传入值为 NULL 时,默认是一直阻塞等待。timeval = {0, 0} 时非阻塞立马返回。

struct timeval 
{
    long tv_sec;  // 秒
    long tv_usec; // 微秒
};

操作文件描述符的集合函数

将文件描述符添加到文件描述符集合中
void FD_SET(int fd,fd_set *set)

将fd从文件描述符集合中删除
void FD_CLR(int fd,fd_set *set)

判断fd是否在文件描述符集合中
int FD_ISSET(int fd,fd_set *set)

将文件描述符集合清空
void FD_ZERO(fd_set *set)

参数描述:
fd:文件描述符
set:文件描述符集合的指针

1.2、使用流程

步骤1:初始化监控集合

使用 FD_ZERO 清空集合,FD_SET 添加需监视的 fd。

fd_set readfds;
FD_ZERO(&readfds);           /* 清空集合 */
FD_SET(socket_fd, &readfds); /* 添加 socket_fd 到读集合 */

步骤 2:调用 select()

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

步骤 3:检查就绪的 fd

使用 FD_ISSET 判断具体哪个 fd 就绪。

if (FD_ISSET(socket_fd, &readfds)) {
    /* socket_fd 可读,执行 recv() */
}

1.3、底层原理

核心数据结构

select 使用3个位图 fdset 来标记监控的fd,这个位图实际上就是一个固定大小的位数组,默认大小是1024,即 FD_SETSIZE = 1024。

fd_set readfds;   /* 监视可读事件 */
fd_set writefds;  /* 监视可写事件 */
fd_set exceptfds; /* 监视异常事件 */

轮询机制

调用 select 后,内核会去以轮询的方式监测这个位数组,如果哪个位上的文件描述符有事件发生了,就会通知应用层去做处理。所以这就是为什么描述符上限是1024,因为时间复杂度是O(n),当监控的文件描述符数量太多了,会大大降低服务器响应效率,不应在select上投入更多精力。

1.4、优缺点

优点:

  • 跨平台
  • 可以实现微秒级定时器

缺点:

  • 文件描述符上限 1024 
  • 轮询检测fd,效率低
  • 每次都需要将需要监听的文件描述符集合由应用层拷贝到内核

1.5、代码示例

#include <sys/select.h>
#include <unistd.h>
#include <stdio.h> 

int main() 
{
    fd_set readfds;
    FD_ZERO(&readfds);
    FD_SET(STDIN_FILENO, &readfds); /* 监视标准输入 */

    struct timeval timeout = {5, 0}; /* 5秒超时 */
    int ready = select(1, &readfds, NULL, NULL, &timeout);

    if (ready == -1) 
    {
        perror("select失败!");
    } 
    else if (ready == 0) 
    {
        printf("超时,无数据!.\n");
    } 
    else 
    {
        if (FD_ISSET(STDIN_FILENO, &readfds)) 
        {
            char buf[256];
            read(STDIN_FILENO, buf, sizeof(buf));
            printf("接收到的数据: %s", buf);
        }
    }
    return 0;
}

2、poll

poll 与 select的原理非常相似,也是通过轮询的方式来监控 fd 是否就绪。但是与 select 不同的是,poll 是采用动态数组存储的 fd,表面上突破了 FD_SETSIZE 的限制。

2.1、函数API

#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

fds:指向 pollfds 结构数组的指针,每个元素描述一个待监测的 fd。

pollfd结构体

struct pollfd 
{
    int fd;         /* 文件描述符 */
    short events;   /* 监视的事件(输入) */
    short revents;  /* 实际发生的事件(输出) */
};

常用事件:

事件常量描述作为events输入作为revents结果
POLLIN普通或优先级带数据可读
POLLRDNORM普通数据可读
POLLRDBAND优先级带数据可读
POLLPRI高优先级数据可读
POLLOUT普通数据可写
POLLWRNORM普通数据可写
POLLWRBAND优先级带数据可写
POLLERR发生错误
POLLHUP发生挂起
POLLNVAL描述符不是一个打开的文件

nfds:数组长度,即监控的fd数量。

timeout:超时时间,-1为阻塞,0为非阻塞。

2.2、使用流程

步骤1:初始化 pollfd 数组

struct pollfd fds[2];
fds[0].fd = socket_fd1; 
fds[0].events = POLLIN;  /* 监视可读事件 */

fds[1].fd = socket_fd2;
fds[1].events = POLLOUT; /* 监视可写事件 */

步骤2:调用 poll

int ready = poll(fds, 2, 1000);

步骤3:检查就绪事件

if (fds[0].revents & POLLIN) 
{
    recv(socket_fd1, buf, sizeof(buf), 0); /* 处理可读事件 */
}
if (fds[1].revents & POLLOUT) 
{
    send(socket_fd2, data, sizeof(data), 0); /* 处理可写事件 */
}

2.3、优缺点

优点(相比 select

  • 无 fd 数量限制
  • 支持更多事件类型
  • pollfd 的 events 字段可长期保持,仅需检查 revents

缺点

  • 高并发时遍历所有 fd 效率低(与 select 相同)。
  • 每次调用需将 pollfd 数组从用户态拷贝到内核态。
  • 跨平台差异,部分系统不支持某些事件标志。

3、epoll

epoll 相比于 select 和 poll 来说有较大的不同。

3.1、工作流程和原理

步骤1:创建 epoll 实例

int epoll_create(int size);

调用 epoll_create() 后会在内核创建一个 eventpoll 结构体,会去初始化结构体中三个关键的数据结构:

  • 红黑树(rbr):存储所有被监控的文件描述符
  • 就绪链表(rdllist):存储已经就绪的文件描述符
  • 等待队列(wq):存放等待事件的进程

返回一个文件描述符指向这个 epoll 实例。

步骤2:添加/修改/删除监控项

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

op:指定操作类型,EPOLL_CTL_ADD、EPOLL_CTL_MOD、EPOLL_CTL_DEL

对于 EPOLL_CTL_ADD 操作,内核会创建一个 epitem 结构体,也就是我们要监控的内容,然后将 epitem 插入到红黑树上。向文件描述符的回调列表注册回调函数 ep_poll_callback。

步骤3:等待事件发生

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

调用 epoll_wait() 后会检查就绪链表是否为空,如果为空,将当前进程加入到 eventpoll 的等待队列,然后休眠。有事件发生的时候,ep_poll_callback 回调函数会被调用,将 epitem 加入到就绪链表。唤醒等待进程,然后将就绪事件拷贝到用户空间。

大致的调用流程如下图:

总结 

epoll 会先在内核创建一个红黑树、一个就绪链表和一个等待队列。红黑树用于存储文件描述符节点,就绪链表用于存储准备好的文件描述符,等待队列用于存储阻塞中的进程/线程。我们通过 epoll_ctl() 将文件描述节点注册到红黑树上,注册回调到设备驱动上(socket等待队列)。epoll 底层是通过内核协议栈去接收网卡中断或数据包,当有网络事件来了,会触发回调,在红黑树上找到来网络事件的 fd 节点,将其添加到就绪链表上,并且唤醒阻塞等待的进程/线程来处理就绪链表。将就绪链表中的就绪事件信息拷贝到用户自己的缓冲区中,拷贝的过程中需要遍历链表。

3.2、水平触发和边缘触发

水平触发和边缘触发,是 I/O 复用中两种核心的事件机制。

1、水平触发 (LT)

水平触发是只要 fd 处于就绪状态了,就会不断地通知用户去处理这个 fd。例如,数据缓冲区里面有 1KB 数据没有读取,我们每次调用 epoll_wait() 的时候都会通知我们这个 fd 可读。

水平触发是默认的模式。

优点是变成起来比较简单,不容易遗漏事件;缺点是可能会频繁的唤醒进程。

2、边缘触发(ET)

边缘触发是 fd 就绪之后只会通知用户一次,不是否处理,后续都不会再通知了。例如,若 socket 接收缓冲区收到新数据,仅第一次 epoll_wait() 报告可读,即使数据未读完,后续不再通知。

优点是减少了 epoll_wait() 的调用次数,效率更高;缺点是编程复杂,需要手动处理事件。

ev.events = EPOLLIN | EPOLLET;  启用边沿触发(ET)

关键要求:

必须使用非阻塞 IO

/* 设置 fd 为非阻塞 */
fcntl(fd, F_SETFL, O_NONBLOCK);

循环读取 直到 ENGAIN

while (true) 
{
    ssize_t n = read(fd, buf, sizeof(buf));
    if (n < 0) 
    {
        if (errno == EAGAIN) break; /* 数据读完 */
        else handle_error();
    }
    /* 处理数据... */
}

3.3、代码示例

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

#define MAX_EVENTS 10
#define PORT 8080
#define BUFFER_SIZE 1024

/* 设置文件描述符为非阻塞模式 */
void set_nonblocking(int fd)
{
    int flags = fcntl(fd, F_GETFL, 0);
    fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

int main()
{
    int server_fd, epoll_fd;
    struct sockaddr_in addr;
    struct epoll_event ev, events[MAX_EVENTS];
    
    /* 1. 创建TCP套接字 */
    if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == -1)
    {
        perror("创建套接字失败");
        exit(EXIT_FAILURE);
    }

    /* 2. 设置套接字选项(避免地址占用错误) */
    int opt = 1;
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
    
    /* 3. 绑定地址和端口 */
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    addr.sin_port = htons(PORT);
    
    if (bind(server_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0)
    {
        perror("绑定地址失败");
        exit(EXIT_FAILURE);
    }
    
    /* 4. 开始监听 */
    if (listen(server_fd, 10) < 0)
    {
        perror("监听失败");
        exit(EXIT_FAILURE);
    }
    
    printf("服务器正在监听端口 %d\n", PORT);

    /* 5. 创建epoll实例 */
    if ((epoll_fd = epoll_create1(0)) == -1)
    {
        perror("创建epoll实例失败");
        exit(EXIT_FAILURE);
    }
    
    /* 6. 将服务器套接字加入epoll监控(水平触发模式) */
    ev.events = EPOLLIN;  /* 水平触发(LT) */
    ev.data.fd = server_fd;
    
    if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, server_fd, &ev) == -1)
    {
        perror("epoll_ctl操作失败(服务器套接字)");
        exit(EXIT_FAILURE);
    }
    
    /* 7. 事件循环 */
    while (1)
    {
        int nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
        if (nfds == -1)
        {
            perror("epoll_wait失败");
            exit(EXIT_FAILURE);
        }
        
        for (int i = 0; i < nfds; i++)
        {
            /* 7.1 处理新的客户端连接 */
            if (events[i].data.fd == server_fd)
            {
                struct sockaddr_in client_addr;
                socklen_t addr_len = sizeof(client_addr);
                int client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &addr_len);
                
                if (client_fd == -1)
                {
                    perror("接受连接失败");
                    continue;
                }
                
                /* 设置为非阻塞模式 */
                set_nonblocking(client_fd);
                
                /* 将新客户端加入epoll监控(水平触发模式) */
                ev.events = EPOLLIN;
                ev.data.fd = client_fd;
                
                if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &ev) == -1)
                {
                    perror("epoll_ctl操作失败(客户端套接字)");
                    close(client_fd);
                }
                
                printf("新客户端连接: 文件描述符=%d\n", client_fd);
            } 
            /* 7.2 处理客户端数据 */
            else
            {
                int client_fd = events[i].data.fd;
                char buffer[BUFFER_SIZE];
                ssize_t bytes_read;
                
                /* 读取客户端数据 */
                bytes_read = read(client_fd, buffer, BUFFER_SIZE);
                
                if (bytes_read > 0)
                {
                    /* 回显数据给客户端 */
                    write(client_fd, buffer, bytes_read);
                    printf("已回显 %zd 字节到客户端: 文件描述符=%d\n", bytes_read, client_fd);
                }
                /* 处理连接关闭或错误 */
                else if (bytes_read == 0 || (bytes_read == -1 && errno != EAGAIN))
                {
                    printf("客户端断开连接: 文件描述符=%d\n", client_fd);
                    epoll_ctl(epoll_fd, EPOLL_CTL_DEL, client_fd, NULL);
                    close(client_fd);
                }
            }
        }
    }
    
    close(server_fd);
    return 0;
}

3.4、优缺点

优点

  • 效率高,直接返回就绪的文件描述列表,无需遍历所有文件描述符,时间复杂度 O(1)。
  • 文件描述符数量仅受系统内存限制
  • 支持边缘触发和水平触发两种方式
  • 可以实现百万级并发量,单进程可以管理数万到数十万连接(受系统内存限制)

缺点

  • 仅 Linux 系统支持 epoll
  • 低并发场景下,效率不如 selec/poll

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值