【Linux】I/O多路复用模型

I/O多路复用

通过这种方式可以同时监测多个文件描述符并且这个过程是阻塞的,一旦检测到有文件描述符就绪,程序的阻塞就会被解除,之后就可以基于这些就绪的文件描述符进行通信。通过这种方式在单线程 / 进程的场景下也可以在服务器端实现并发。

  1. 多线程 / 多进程并发
  • 主线程 / 父进程:调用 accept() 函数接受客户端连接请求。如果没有新的客户端的连接请求,当前线程 / 进程会阻塞。如果有新的客户端连接请求则解除阻塞,建立连接。
  • 子线程 / 子进程:与建立连接的客户端通信。调用 read() / recv() 接收客户端发送的通信数据,如果没有通信数据,当前线程 / 进程会阻塞,数据到达之后阻塞自动解除。调用 write() / send() 给客户端发送数据,如果写缓冲区已满,当前线程 / 进程会阻塞,否则将待发送数据写入写缓冲区中。
  1. IO 多路复用并发
    使用 IO 多路转接函数委托内核检测服务器端所有的文件描述符,这个检测过程会导致进程 / 线程的阻塞,如果检测到已就绪的文件描述符阻塞解除,并将这些已就绪的文件描述符传出。根据类型对传出的所有已就绪文件描述符进行判断,并做出不同的处理
  • 监听的文件描述符:与客户端建立连接。此时调用 accept() 不会导致程序阻塞的,因为监听的文件描述符是已就绪的。
  • 通信的文件描述符:调用通信函数和已建立连接的客户端通信。调用 read() / recv() 不会阻塞程序,因为通信的文件描述符是就绪的,读缓冲区内已有数据。调用 write() / send() 不会阻塞程序,因为通信的文件描述符是就绪的,写缓冲区不满,可以往里面写数据。

select

#include 
struct timeval
{
    time_t tv_sec;       // 秒
    suseconds_t tv_usec; // 微秒
};

int select(
    int nfds,               // 委托内核检测的下列三个集合中最大的文件描述符+1
                            // 内核需要线性遍历这些集合中的文件描述符,这个值是循环结束的条件
    fd_set *readfds,        // 传入传出参数,内核只检测该集合中文件描述符对应的读缓冲区
    fd_set *writefds,       // 传入传出参数,内核只检测该集合中文件描述符对应的写缓冲区
    fd_set *exceptfds,      // 传入传出参数,内核只检测该集合中文件描述符是否异常
    struct timeval *timeout // 超时时长,用来强制解除函数阻塞
);
// 返回值:发生错误返回值为-1,超时返回值为0,成功返回就绪的文件描述符的个数。
// 将集合中所有的文件描述符对应的标志位设置为0
void FD_ZERO(fd_set *set);
// 将fd对应的标志位设置为0        
void FD_CLR(int fd, fd_set *set);
// 将fd对应的标志位设置为1
void FD_SET(int fd, fd_set *set);
// fd对应的标志位是0还是1
int  FD_ISSET(int fd, fd_set *set);

局限性:

  1. 待检测的文件描述符集合需要频繁的在用户区和内核区之间进行数据的拷贝,效率低。
  2. 文件描述符集合通过线性表描述,内核线性地遍历文件描述符集合。
  3. 在32位机器上,能够检测的最大文件描述符个数上限默认是1024,但可以修改源码重新编译内核。
  4. 函数只返回发生了事件的文件描述符的个数,想知道是谁发生了事件,同样需要遍历。

优势:

  1. 可以跨平台使用。
    【Linux】I/O多路复用模型_第1张图片

示例

// server.c
#include 
#include 
#include 
#include 
#include 

int main()
{
    // 1. 创建监听的文件描述符
    int lfd = socket(AF_INET, SOCK_STREAM, 0);

    // 2. 绑定
    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_port = htons(9999);
    addr.sin_addr.s_addr = INADDR_ANY;
    bind(lfd, (struct sockaddr *)&addr, sizeof(addr));

    // 3. 设置监听
    listen(lfd, 128);

    // 最大的文件描述符
    int maxfd = lfd;
    // 委托内核检测的读集合
    fd_set rdset;
    // 读事件就绪的文件描述符集合
    fd_set rdtemp;
    FD_ZERO(&rdset);
    // 将用于监听的文件描述符设置到集合中
    FD_SET(lfd, &rdset);

    // 应该让内核持续检测
    while (1)
    {
        rdtemp = rdset;
        int num = select(maxfd + 1, &rdtemp, NULL, NULL, NULL);
        // 检测用于监听的文件描述符
        if (FD_ISSET(lfd, &rdtemp))
        {
            struct sockaddr_in cliaddr;
            int cliLen = sizeof(cliaddr);
            // 接受连接请求(不会阻塞)
            int cfd = accept(lfd, (struct sockaddr *)&cliaddr, &cliLen);
            // 将用于通信的文件描述符设置到集合中
            FD_SET(cfd, &rdset);
            // 重置最大的文件描述符
            maxfd = cfd > maxfd ? cfd : maxfd;
        }

        // 检测用于通信的文件描述符
        for (int i = 0; i < maxfd + 1; ++i)
        {
            if (i != lfd && FD_ISSET(i, &rdtemp))
            {
                // 接收数据
                char buf[10] = {0};
                int len = read(i, buf, sizeof(buf));
                if (len == 0)
                {
                    printf("客户端关闭了连接...\n");
                    // 将检测的文件描述符从读集合中删除
                    FD_CLR(i, &rdset);
                    close(i);
                }
                else if (len > 0)
                {
                    // 发送接收到的数据
                    printf("客户端:%s\n", buf);
                    write(i, buf, strlen(buf) + 1);
                }
                else
                {
                    // 异常
                    perror("read");
                }
            }
        }
    }
    return 0;
}

poll

struct pollfd
{
    int fd;        // 委托内核检测的文件描述符
    short events;  // 委托内核检测文件描述符的什么事件
    short revents; // 传出参数,文件描述符实际发生的事件
};

int poll(
    struct pollfd *fds, // 存储待检测的文件描述符的信息
    nfds_t nfds,        // 第一个参数数组中元素总个数
    int timeout         // 指定函数的阻塞时长
);
// 返回值:发生错误返回值为-1,超时返回值为0,成功返回就绪的文件描述符的个数。

局限性:

  1. 待检测的文件描述符集合需要频繁的在用户区和内核区之间进行数据的拷贝,效率低。
  2. 文件描述符集合通过链表描述,内核线性地遍历文件描述符集合。
  3. 函数只返回发生了事件的文件描述符的个数,想知道是谁发生了事件,同样需要遍历。
  4. 只能在Linux平台使用。

优势:

  1. 能够检测的最大文件描述符个数无限制。

示例

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

int main()
{
    // 1.创建监听套接字
    int lfd = socket(AF_INET, SOCK_STREAM, 0);
    if (lfd == -1)
    {
        perror("socket");
        exit(0);
    }
    // 2. 绑定
    struct sockaddr_in addr;
    addr.sin_port = htons(9999);
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    int ret = bind(lfd, (struct sockaddr *)&addr, sizeof(addr));
    if (ret == -1)
    {
        perror("bind");
        exit(0);
    }
    // 3. 监听
    ret = listen(lfd, 100);
    if (ret == -1)
    {
        perror("listen");
        exit(0);
    }

    // 初始化
    struct pollfd fds[1024];
    for (int i = 0; i < 1024; ++i)
    {
        fds[i].fd = -1;
        fds[i].events = POLLIN; // 读事件
    }
    fds[0].fd = lfd;

    // 最大的文件描述符
    int maxfd = 0;

    // 应该让内核持续检测
    while (1)
    {
        ret = poll(fds, maxfd + 1, -1);
        if (ret == -1)
        {
            perror("poll");
            exit(0);
        }

        // 监听的文件描述符
        if (fds[0].revents & POLLIN)
        {
            struct sockaddr_in sockcli;
            int len = sizeof(sockcli);
            // 接收连接请求(不会阻塞)
            int connfd = accept(lfd, (struct sockaddr *)&sockcli, &len);
            // 找到第一个可用的文件描述符
            for (int i = 0; i < 1024; i++)
            {
                if (fds[i].fd == -1)
                {
                    // 将该文件描述符设置到检测集合
                    fds[i].fd = connfd;
                    break;
                }
            }
            // 重置最大的文件描述符
            maxfd = i > maxfd ? i : maxfd;
        }
        // 通信的文件描述符
        for (int i = 1; i <= maxfd; i++)
        {
            if (fds[i].revents & POLLIN)
            {
                // 接收数据
                char buf[128];
                int ret = read(fds[i].fd, buf, sizeof(buf));
                if (ret == -1)
                {
                    // 异常
                    perror("read");
                    exit(0);
                }
                else if (ret == 0)
                {
                    printf("对方已经关闭了连接。\n");
                    close(fds[i].fd);
                    // 将该文件描述符从集合中删除
                    fds[i].fd = -1;
                }
                else
                {
                    // 发送接收到的数据
                    printf("客户端: %s\n", buf);
                    write(fds[i].fd, buf, strlen(buf) + 1);
                }
            }
        }
    }
    close(lfd);
    return 0;
}

epoll

// 1.创建 epoll 实例
int epoll_create(int size);
// 返回值:失败返回-1,成功返回 epoll 实例的文件描述符。
// 联合体类型
typedef union epoll_data
{
    void *ptr;
    int fd;			// 目标文件描述符
    uint32_t u32;
    uint64_t u64;
} epoll_data_t;

struct epoll_event
{
    uint32_t events;	// EPOLLIN:读事件
    					// EPOLLOUT:写事件
    					// EPOLLERR:异常事件
    epoll_data_t data;
};

// 2.管理红黑树上的文件描述符
int epoll_ctl(
	int epfd,                 // epoll 实例文件描述符
    int op,                   // EPOLL_CTL_ADD:添加新节点
    						  // EPOLL_CTL_MOD:修改已存在节点
    						  // EPOLL_CTL_DEL:删除节点
    int fd,                   // 目标文件描述符
    struct epoll_event *event // 检测该文件描述符的事件
);
// 返回值:失败返回-1,成功返回0。

```c
// 3.检测红黑树中是否有就绪的文件描述符
int epoll_wait(
    int epfd,                   // epoll 实例文件描述符
    struct epoll_event *events, // 传出参数,结构体数组存储已就绪文件描述符的信息
    int maxevents,              // 结构体数组的容量
    int timeout                 // 函数阻塞的时长
);
// 返回值:发生错误返回值为-1,超时返回值为0,成功返回就绪的文件描述符的个数。

【Linux】I/O多路复用模型_第2张图片

优势:

  1. 文件描述符集合通过红黑树描述,链表存储已就绪的文件描述符。
  2. 使用回调机制,内核检测到就绪的文件描述符时,触发回调函数,将该文件描述符对应的事件插入内核就绪链表中。
  3. 能够检测的最大文件描述符个数无限制。
  4. 直接得到已就绪的文件描述符个数与结构体数组,无需再次检测。

示例

水平触发模式

检测到文件描述符有事件发生,则将其通知给应用程序,应用程序可以不立即处理该事件。当下一次检测时,还会再次向应用程序报告此事件,直至被处理。

// server.c
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 

int main(int argc, const char *argv[])
{
    // 创建监听套接字
    int lfd = socket(AF_INET, SOCK_STREAM, 0);
    if (lfd == -1)
    {
        perror("socket error");
        exit(1);
    }

    // 绑定
    struct sockaddr_in serv_addr;
    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_port = htons(9999);
    serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);

    // 设置端口复用
    int opt = 1;
    setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    // 绑定
    int ret = bind(lfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr));
    if (ret == -1)
    {
        perror("bind error");
        exit(1);
    }

    // 监听
    ret = listen(lfd, 64);
    if (ret == -1)
    {
        perror("listen error");
        exit(1);
    }
    // 创建一个epoll实例
    int epfd = epoll_create(100);
    if (epfd == -1)
    {
        perror("epoll_create");
        exit(0);
    }

    // 往epoll实例中添加监听文件描述符
    struct epoll_event ev;
    ev.events = EPOLLIN;
    ev.data.fd = lfd;
    int ret = epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev);
    if (ret == -1)
    {
        perror("epoll_ctl");
        exit(0);
    }

    struct epoll_event evs[1024];
    int size = sizeof(evs) / sizeof(struct epoll_event);

    // 应该让内核持续检测
    while (1)
    {
        int num = epoll_wait(epfd, evs, size, -1); // 阻塞式
        for (int i = 0; i < num; i++)
        {
            // 取出当前的文件描述符
            int curfd = evs[i].data.fd;
            // 用于监听的文件描述符
            if (curfd == lfd)
            {
                // 建立新的连接
                int cfd = accept(curfd, NULL, NULL);
                // 添加到epoll实例
                ev.events = EPOLLIN;
                ev.data.fd = cfd;
                ret = epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &ev);
                if (ret == -1)
                {
                    perror("epoll_ctl-accept");
                    exit(0);
                }
            }
            // 用于通信的文件描述符
            else
            {
                // 接收数据
                char buf[1024];
                memset(buf, 0, sizeof(buf));
                int len = recv(curfd, buf, sizeof(buf), 0);
                if (len == 0)
                {
                    printf("客户端已经断开了连接\n");
                    // 从epoll实例中删除
                    epoll_ctl(epfd, EPOLL_CTL_DEL, curfd, NULL);
                    close(curfd);
                }
                else if (len > 0)
                {
                    printf("客户端: %s\n", buf);
                    send(curfd, buf, len, 0);
                }
                else
                {
                    perror("recv");
                    exit(0);
                }
            }
        }
    }

    return 0;
}

边沿触发模式

检测到文件描述符有事件发生,则将其通知给应用程序,应用程序必须立即处理该事件。必须要一次性将数据读取完,套接字默认是阻塞的,当读缓冲区数据被读完之后,读操作阻塞,当前进程 / 线程无法执行其他操作。所以把套接字修改为非阻塞,读缓冲区数据被读完,对应的全局变量errno值为EAGAIN或者EWOULDBLOCK

// server.c
// epoll的工作模式 -- 边沿触发
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 

int main(int argc, const char *argv[])
{
    // 创建监听的套接字
    int lfd = socket(AF_INET, SOCK_STREAM, 0);
    if (lfd == -1)
    {
        perror("socket error");
        exit(1);
    }

    // 绑定
    struct sockaddr_in serv_addr;
    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_port = htons(9999);
    serv_addr.sin_addr.s_addr = htonl(INADDR_ANY); // 本地有多个IP

    // 设置端口复用
    int opt = 1;
    setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    // 绑定
    int ret = bind(lfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr));
    if (ret == -1)
    {
        perror("bind error");
        exit(1);
    }

    // 监听
    ret = listen(lfd, 64);
    if (ret == -1)
    {
        perror("listen error");
        exit(1);
    }

    // 创建一个epoll实例
    int epfd = epoll_create(100);
    if (epfd == -1)
    {
        perror("epoll_create");
        exit(0);
    }

    // 往epoll实例中添加监听的文件描述符
    struct epoll_event ev;
    ev.events = EPOLLIN;
    ev.data.fd = lfd;
    ret = epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev);
    if (ret == -1)
    {
        perror("epoll_ctl");
        exit(0);
    }

    struct epoll_event evs[1024];
    int size = sizeof(evs) / sizeof(struct epoll_event);
    // 应该让内核持续检测
    while (1)
    {
        int num = epoll_wait(epfd, evs, size, -1); // 阻塞式
        printf("num = %d\n", num);

        for (int i = 0; i < num; i++)
        {
            // 取出当前的文件描述符
            int curfd = evs[i].data.fd;
            // 用于监听的文件描述符
            if (curfd == lfd)
            {
                // 建立新的连接
                int cfd = accept(curfd, NULL, NULL);

                // 将文件描述符设置为非阻塞
                int flag = fcntl(cfd, F_GETFL);
                flag |= O_NONBLOCK;
                fcntl(cfd, F_SETFL, flag);
                // 文件描述符添加到epoll实例中
                ev.events = EPOLLIN | EPOLLET; // 边沿触发模式
                ev.data.fd = cfd;
                ret = epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &ev);
                if (ret == -1)
                {
                    perror("epoll_ctl-accept");
                    exit(0);
                }
            }
            // 用于通信的文件描述符
            else
            {
                // 接收数据
                char buf[5];
                memset(buf, 0, sizeof(buf));
                // 循环读数据
                while (1)
                {
                    int len = recv(curfd, buf, sizeof(buf), 0);
                    if (len == 0)
                    {
                        printf("客户端断开了连接。\n");
                        // 将这个文件描述符从epoll实例中删除
                        epoll_ctl(epfd, EPOLL_CTL_DEL, curfd, NULL);
                        close(curfd);
                        break;
                    }
                    else if (len > 0)
                    {
                        // 接收的数据打印到终端
                        write(STDOUT_FILENO, buf, len);
                        // 发送数据
                        send(curfd, buf, len, 0);
                    }
                    else
                    {
                        // len == -1
                        if (errno == EAGAIN || errno == EWOULDBLOCK)
                        {
                            printf("数据读完了。\n");
                            break;
                        }
                        else
                        {
                            // 异常
                            perror("recv");
                            exit(0);
                        }
                    }
                }
            }
        }
    }

    return 0;
}

应用场景

  • 当所有的fd都是活跃连接时,使用epoll需要建立文件系统、红黑树和链表,效率不如selectpoll
  • 当监测的fd数目较小且各个fd都比较活跃,建议使用select或者poll
  • 当监测的fd数目非常大且单位时间只有其中的一部分处于就绪状态,使用epoll能够明显提升性能。

参考:https://subingwen.cn/linux/
参考:https://www.bilibili.com/video/BV1Rq4y1s7uu

你可能感兴趣的:(Linux,linux)