FeelingLife FeelingLife
首页
  • Go

    • Go基础知识
  • Python

    • Python进阶
  • 操作系统
  • 计算机网络
  • MySQL
  • 学习笔记
  • 常用到的算法
  • Docker
  • Kubernetes
  • Observability
  • 容器底层
其他技术
  • 友情链接
  • 收藏
关于
  • 分类
  • 标签
  • 归档
GitHub (opens new window)

xuqil

一介帆夫
首页
  • Go

    • Go基础知识
  • Python

    • Python进阶
  • 操作系统
  • 计算机网络
  • MySQL
  • 学习笔记
  • 常用到的算法
  • Docker
  • Kubernetes
  • Observability
  • 容器底层
其他技术
  • 友情链接
  • 收藏
关于
  • 分类
  • 标签
  • 归档
GitHub (opens new window)
  • 应用层
  • 运输层
  • 抓包

  • 网络编程

    • Linux的IO模型
    • select、poll和epoll
      • select
        • 机制
        • 原理
        • 执行流程
        • 优点
        • 缺点
      • poll
        • 执行流程
        • 优点
        • 缺点
        • 解决的问题
      • epoll
        • 执行流程
        • 使用epoll编程主流程骨架
        • 触发模式
        • 优点
      • 三者的区别
      • 小结
      • 参考
    • sendfile、aio和directIO
    • ip命令的使用
  • 云网络

  • 虚拟网络

  • 常见面试题
  • 《计算机网络知识》
  • 网络编程
xuqil
2023-07-09
目录

select、poll和epoll

# select、poll和epoll的原理和区别

在多路复用IO模型中,会有一个内核线程不断地去轮询多个socket的状态,只有当真正读写事件发送时,才真正调用实际的IO读写操作。因为在多路复用IO模型中,只需要使用一个线程就可以管理多个socket,系统不需要建立新的进程或者线程,也不必维护这些线程和进程,并且只有真正有读写事件进行时,才会使用IO资源,所以它大大减少来资源占用。多路I/O复用模型是利用select、poll、epoll 可以同时监察多个流的 I/O 事件的能力,在空闲的时候,会把当前线程阻塞掉,当有一个或多个流有 I/O 事件时,就从阻塞态中唤醒,于是程序就会轮询一遍所有的流(epoll 是只轮询那些真正发出了事件的流),并且只依次顺序的处理就绪的流,这种做法就避免了大量的无用操作。

可以在Linux官网或者man中查看 select、poll 和 epoll 的详细说明。

  • man 2 select (opens new window)
  • man 2 poll (opens new window)
  • man 7 epoll (opens new window)

实现的源码地址:

  • poll 和select: include/linux/poll.h (opens new window), fs/select.c (opens new window)
  • epoll: fs/poll.c (opens new window)

# select

可以在 Linux 官网或者man select命令查看具体说明。

函数定义:

int select(int nfds, fd_set *_Nullable restrict readfds,
          fd_set *_Nullable restrict writefds,
          fd_set *_Nullable restrict exceptfds,
          struct timeval *_Nullable restrict timeout);
1
2
3
4

参数说明:

  • nfds:监控的文件描述符集里最大文件描述符加1
  • readfds:监控有读数据达到文件描述符集合,传入传出参数
  • writefds:监控有写数据达到文件描述符集合,传入传出参数
  • exceptfds:监控异常发生文件描述符集合,传入传出参数
  • timeout:定时阻塞监控时间,3种情况
    • NULL,永远等待下去
    • 设置 timeval,等待固定时间
    • 设置 timeval 里时间均为0,检查描述子后立即返回,轮询

# 机制

  1. select函数监视的文件描述符分3类,分别是readfds、writefds和exceptfds,将用户传入的数组拷贝到内核空间
  2. 调用后select函数会阻塞,直到有描述符就绪(有数据 可读、可写、或者有 except)或超时(timeout 指定等待时间,如果立即返回设为NULL即可),函数返回。
  3. 当select函数返回后,可以通过遍历fdset,来找到就绪的描述符。

# 原理

使用 Python 代码实现用户态的select

def server():
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.bind(('127.0.0.1', '50001'))
        s.listen(10)  # 设置最大监听数目,并发
        s.setblocking(False)  # 设置为非阻塞
        clients = []  # 保存客户端 socket
        while True:
            try:
                conn, addr = s.accept()  # 非阻塞,轮询检查是否有连接
                conn.setblocking(False)
                clients.append((conn, addr))  # 存放客户端 socket
                print('Connected by', addr)
            except BlockingIOError:
                pass

            for cs, ca in clients:
                try:
                    data = cs.recv(1024)  # 接收数据,非阻塞
                    if len(data) > 0:  # 收到了数据
                        print(f"message from {ca}: {data}")
                        cs.sendall(data)
                    else:
                        cs.close()
                        clients.remove((cs, ca))
                except Exception:
                    pass

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

select其实就是把 NIO 中用户态要遍历的 fd 数组(clients,即保存客户端 socket 的列表)拷贝到内核态,让内核态来遍历。因为用户判断 socket 是否有数据还是要调用内核态,所以拷贝到内核后,这样遍历的时候就不用一直在用户态和内核态之间频繁切换了。

# 执行流程

  1. select是一个阻塞函数,当没有数据时,会一直阻塞在select那一行
  2. 当有数据时会将readfds中对应的那一位置为1
  3. select函数返回,不再阻塞
  4. 遍历文件描述符数组,判断哪个fd被置位(置位为1)了
  5. 读取数据,然后处理
    int fds[5];
    fd_set readfds;
    sockfd = socket(AF_INET, SOCK_STREAM, 0); // 建立服务器端socket
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = htonl(INADDR_ANY);
    addr.sin_port = htons(50007);
    server_len = sizeof(addr);
    bind(sockfd, (struct sockaddr *)&addr, server_len);
    listen(sockfd, 5); // 监听队列最多容纳5个

    for (int i = 0; i < 5; i++) // 模拟5个客户端连接
    {
        memset(&client, 0, sizeof(client));
        addrlen = sizeof(client);
        fds[i] = accept(sockfd, (struct sockaddr *)&client, &addrlen);
        if (fds[i] > max)
        {
            max = fds[i]; // 找到一个最大的文件描述符
        }
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
    while (1)
    {
        FD_ZERO(&readfds);
        for (int i = 0; i < 5; i++)
        {
            /* &readfds 是一个 bitmap,如果5个文件描述符分别是0,1,2,4,5,7,那么这个 bitmap 为01101101 */
            FD_SET(fds[i], &readfds);
        }

        puts("round again");
        /*select 是一个系统调用,它会阻塞直到有数据发送到 socket,select 会把&readfds 相应的位置重置,但不会返回哪个 socket 有数据*/
        select(max + 1, &readfds, NULL, NULL, NULL);

        /*用户态只要遍历 &readfds,看哪一位被置位了,不需要每次调用系统调用来判断了,效率有很大提升,遍历到被置位的文件描述符就进行读取*/
        for (int i = 0; i < 5; i++)
        {
            if (FD_ISSET(fds[i], &readfds))
            {
                memset(buffer, 0, MAXBUF);
                read(fds[i], buffer, MAXBUF);
                puts(buffer);
            }
        }
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

# 优点

select其实就是把 NIO 中用户态要遍历的fd数组(客户端每一个socket,即上面的clients列表)拷贝到了内核态,让内核态来遍历,因为用户态判断socket是否有数据还是要调用内核态的,所有拷贝到内核态后,这样遍历判断的时候就不用一直用户态和内核态频繁切换了

从代码中可以看出,select系统调用后,返回了一个置位后的&readfds,这样用户态只需进行很简单的二进制比较,就能很快知道哪些socket需要read数据,有效提高了效率

# 缺点

  1. 有限的连接数:bitmap最大1024为,一个进程最大只能处理1024个客户端
  2. &readfds不可重用:&readfds不可重用,每次socket有数据时,相应的位会被置位
  3. 拷贝消耗资源:文件描述符数组拷贝到了内核态(只不过无系统调用切换上下文的开销。(内核层可优化为异步事件通知)),仍然有开销。select调用需要传入fd数组,需要拷贝一份到内核,高并发场景下这样的拷贝消耗的资源是惊人的。(可优化为不复制)
  4. O(n)复杂度:select并没有通知用户态哪一个socket有数据,仍然需要O(n)的遍历。select仅仅返回可读文件描述符的个数,具体哪个可读还是要用户自己遍历。(可优化为只返回给用户就绪的文件描述符,无需用户做无效的遍历)

# poll

可以在 Linux 官网或者man poll命令查看具体说明。

poll与select原理基本相同,只不过突破了连接数为1024的限制。

# 执行流程

  1. 将客户端fd数组从用户态拷贝到内核态
  2. poll为阻塞函数,执行poll函数时,如果有数据会将fd对应的revents置为POLLIN
  3. poll函数返回
  4. 循环遍历,查找哪个fd被置位为POLLIN了
  5. 将revents重置为0,便于复用
  6. 对置位的fd进行读取和处理
    // 模拟5个客户端
    for (int i = 0; i < 5; i++)
    {
        memset(&client, 0, sizeof(client));
        addrlen = sizeof(client);
        pollfds[i].fd = accept(sockfd, (struct sockaddr *)&client, &addrlen);
        pollfds[i].events = POLLIN; // 这个5个 socket 只关注只读事件
    }
    sleep(1);

    while (1)
    {
        puts("round again");
        /*poll 中传入 pollfds 数组,交给内核判断是否有事件发生,如果哪个发生事件则 revents 置1*/
        poll(pollfds, 5, 50000);

        /*遍历数组,找到哪个 pollfds 有事件发生*/
        for (int i = 0; i < 5; i++)
        {
            if (pollfds[i].revents & POLLIN)
            {
                pollfds[i].revents = 0; // 找到后 revents 置0
                memset(buffer, 0, MAXBUF);
                read(pollfds[i].fd, buffer, MAXBUF); // 读取数据
                puts(buffer);
            }
        }
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

# 优点

  1. poll使用pollfd数组来代替select中的bitmap,数组没有1024的限制,可以一次管理更多的client。它和 select 的主要区别就是,去掉了select只能监听1024个文件描述符的限制。
  2. 当pollfds数组中有事件发生,相应的revents置位为1,遍历的时候又置位回零,实现了pollfd数组的重用

# 缺点

poll解决select缺点中的前两条,其本质原理还是select的方法,还存在select中原来的问题

  1. pollfds数组拷贝到了内核态,仍然有开销
  2. poll并没有通知用户态哪一个socket有数据,仍然需要O(n)的遍历

# 解决的问题

  1. 解决了bitmap大小限制
  2. 解决了&readfds不可重用的问题

# epoll

可以在 Linux 官网或者man epoll命令查看具体说明。

# 执行流程

epoll是非阻塞的,它的执行流程:

  1. 当有数据的时候,会把相应的文件描述符置位,但是epoll没有revent标志位,所以并不是真正的置位。这时候会把有数据的文件描述符放到队首
  2. epoll会返回有数据的文件描述符的个数
  3. 根据返回的个数,读取前N个文件描述符即可
  4. 读取和处理数据
    struct epoll_event events[5];
    /*epoll_create 在内核开辟一块空间,原来存放 epoll 中 fd 的数据结构(红黑树)*/
    int epfd = epoll_create(10);
    // ...

    // 模拟5个客户端
    for (int i = 0; i < 5; i++)
    {
        /*epoll 中 fd 的数据结构和 poll 的差不多,只是没有了 revents*/
        static struct epoll_event ev;
        memset(&client, 0, sizeof(client));
        addrlen = sizeof(client);
        ev.data.fd = accept(sockfd, (struct sockaddr *)&client, &addrlen);
        /*epoll_ctl 把每一个 socket 的 fd 数据结构放到 epoll_create 创建的内存空间中(加入到红黑树)*/
        epoll_ctl(epfd, EPOLL_CTL_ADD, ev.data.fd, &ev);
    }

    while (1)
    {
        puts("round again");
        /*epoll_wait 阻塞,只有当 epoll_create 中创建的内存空间中的 fd 有事件发生,才会把这些 fd 放在就绪列表中,返回就绪 fd 的个数*/
        nfds = epoll_wait(epfd, events, 5, 10000);

        /*遍历就绪列表,读取数据*/
        for (int i = 0; i < nfds; i++)
        {
            memset(buffer, 0, MAXBUF);
            read(events[i].data.fd, buffer, MAXBUF);
            puts(buffer);
        }
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

# 使用epoll编程主流程骨架

三步调用

  1. epoll_create创建一个epoll句柄

    int epoll_create(int size);
    
    1
  2. epoll_ctl向内核添加、修改或删除要监控的文件描述符

    int epoll_ctl(int epfd, int op, int fd,
                         struct epoll_event *_Nullable event);
    
    1
    2
  3. epoll_wait发起类似select()调用

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

骨架实例:

    // 创建一个 epoll句柄
    int epfd = epoll_create(1000);

    // 将 listen_fd 添加进 epoll 中
    epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &listen_event);

    while (1)
    {
        // 阻塞等待 epoll 中 的fd 触发
        int active_cnt = epoll_wait(epfd, events, 1000, -1);

        for (i = 0; i < active_cnt; i++)
        {
            if (evnets[i].data.fd == listen_fd)
            {
                // accept. 并且将新accept 的fd 加进epoll中.
            }
            else if (events[i].events & EPOLLIN)
            {
                // 对此fd 进行读操作
            }
            else if (events[i].events & EPOLLOUT)
            {
                // 对此fd 进行写操作
            }
        }
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

# 触发模式

水平触发

水平触发的主要特点是,如果用户在监听epoll事件,当内核有事件的时候,会拷贝给用户态事件,但是如果用户只处理了一次,那么剩下没有处理的会在下一次epoll_wait再次返回该事件。

这样如果用户永远不处理这个事件,就导致每次都会有该事件从内核到用户的拷贝,耗费性能,但是水平触发相对安全,最起码事件不会丢掉,除非用户处理完毕。

边缘触发

边缘触发,相对跟水平触发相反,当内核有事件到达, 只会通知用户一次,至于用户处理还是不处理,以后将不会再通知。这样减少了拷贝过程,增加了性能,但是相对来说,如果用户马虎忘记处理,将会产生事件丢的情况。

# 优点

  • 解决了select和poll的问题
  • 连接数没有限制
  • 减少了用户态和内核态的文件句柄拷贝,因为客户端fd已经在epoll_ctl交给内核
  • 减少了对可读可写文件句柄的遍历,因为epoll只返回可读可写的文件句柄
  • IO 性能不会随着监听的文件描述的数量增长而下降
  • 使用红黑树存储fd,以及对应的回调函数,其插入,查找,删除的性能不错,相比于 hash,不必预先分配很多的空间

# 三者的区别

select poll epoll
操作方式 遍历 遍历 回调
数据结构 bitmap 数组 红黑树
最大连接数 1024(x86)或2048(x64) 无上限 无上限
最大支持文件描述符 一搬由最大值限制 65535 65535
fd拷贝 每次调用 select,都需要把 fd 集合从用户态拷贝到内核态 每次调用 poll,都需要把 fd 集合从用户态拷贝到内核态 fd 首次调用 epoll_ctl 拷贝,每次调用 epoll_wait 不拷贝
工作效率 每次调用都进行线性遍历,时间复杂度为 O(n) 每次调用都进行线性遍历,时间复杂度为 O(n) 事件通知方式,每当 fd 就绪,系统注册的回调函数就会被调用,将就绪 fd 放到 readList 里面,时间复杂度 O(1)

# 小结

  • select:select方式,既做到了一个线程处理多个客户端连接(文件描述符),又减少了系统调用的开销(多个文件描述符只有一次 select 的系统调用 + N次就绪状态的文件描述符的read系统调用);
  • poll:poll方式解决了select因为bitmap大小的限制,最大连接数没有限制;
  • epoll:epoll是现在最先进的IO多路复用器,Redis、Nginx,linux 中的 Java NIO 都使用的是epoll。

# 参考

  • man 2 select (opens new window)
  • man 2 poll (opens new window)
  • man 7 epoll (opens new window)
  • Example: Nonblocking I/O and select() (opens new window)
  • select v.s. poll v.s. epoll (opens new window)
  • 流?I/O操作?阻塞?epoll? (opens new window)
上次更新: 2024/05/29, 06:25:22
Linux的IO模型
sendfile、aio和directIO

← Linux的IO模型 sendfile、aio和directIO→

最近更新
01
FunctionCalling原理
05-27
02
VXLAN互通实验
05-13
03
VXLAN
05-13
更多文章>
Theme by Vdoing | Copyright © 2018-2025 FeelingLife | 粤ICP备2022093535号-1
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式