Linux Select IO 模型

在 Linux 下,select 函数通常用于多路复用 I/O,可以同时监视多个文件描述符的状态,当其中任何一个文件描述符准备就绪时,select 函数就会返回。

通过同时监控多个 I/O 流的状态来实现对多个 I/O 任务的处理。它可以让单个线程同时处理多个 I/O 任务,从而提高系统的并发处理能力。

Linux select IO 模型的具体工作过程如下:

  1. 首先,初始化一个 fd_set 集合,这个集合是一个包含了 1024 个bit 位,对应了 select 最大能处理 1024 个文件描述符,将每个位设置为 0
  2. 接着,将监听套接字添加到 fd_set 集合中,所谓的添加就是将套接字值对应的 bit 位设置为 1
  3. 然后,再将其他所有的和客户端连接的套接字添加到 fd_set 中,此时,在 fd_set 中就保存了当前已连接的套接字和被动套接字
  4. 此时,再调用 select 函数,并将包含了文件描述符信息的 fd_set 集合传递进去。此时,select 函数会将在用户空间创建的 fd_set 拷贝到内核空间中,这时,内核就知道要监听那些文件描述符。
  5. 当内核某次遍历所有文件描述符时,发现至少有一个文件描述符有读写、异常事件产生,此时 select 函数就会返回,不再阻塞,并将记录了那些文件描述符就绪的 fd_set 集合拷贝到用户空间
  6. 用户空间这时就会遍历所有的文件描述符,并判断具体哪个文件描述符存在读写、异常事件,然后进行处理即可。

在这个过程中,用户空间如何判断到底是读、还是写、还是异常呢?

如果我们只监听读事件,我们就创建一个读的 fd_set 集合就成。如果需要监听 3 个事件,那么就需要创建 3 个 fd_set,把需要监听的 1 个或者多个 fd_set 传递给 select 函数即可

这里还有个注意点,每次调用 select 函数前,都需要重新设置 fd_set 集合。这是因为,当上一次 select 函数返回时,fd_set 内的数据就表示每个文件描述的状态了,已经不是描述哪些文件描述符需要监听了。如果重新设置 fd_set 集合,就会导致 select 判断失误,或者不必要的性能开销。

通过上面的过程,我们就会发现,select IO 模型有以下几个缺点:

  1. select 模型的并发能力受到了 1024 个文件描述符的限制
  2. 每次调用 select 函数,就需要从用户空间到内核空间、以及内核空间到用户空间的数据拷贝开销
  3. 当 select 函数返回时,就需要遍历所有文件描述符来知晓具体哪个文件描述符有读写任务需要处理

虽然 select IO 模型存在上述的一些问题,但是对于并发量并不太大的应用,仍然也是一个可选的解决方案。上面的过程中,我们有几个点需要看下:

  1. 文件描述符限制
  2. fd_set 如何操作
  3. select 函数参数

1. 文件描述符限制

我们前面提到,select IO 模型受到了 1024 个文件描述符数量的限制。我们可以从其位图 fd_set 的结构体定义来确定这一点,该结构体定义如下:

typedef struct
{
	__fd_mask fds_bits[__FD_SETSIZE / __NFDBITS];

} fd_set;

typedef long int __fd_mask;
  1. fds_bits 是一个 long int 类型的数组,long int 在 Linux 中占用 4 个字节,32 个二进制位
  2. __FD_SETSIZE 的值为 1024
  3. define __NFDBITS (8 * (int) sizeof (__fd_mask)),从这里可以看到 __NFDBITS 的值为 32
  4. 综上,可以知晓 __fd_mask fds_bits[__FD_SETSIZE / __NFDBITS] 其实就是 long fds_bits[32],也就是对应了具有 1024 个比特位

每一个比特位都对应一个唯一的索引编号,比如:从最低位开始,第一个位置就对应了 0 号文件描述符,第二个位置对应了 1 号文件描述符,依次类推… 这里,我们再重新对 fd_set 进行理解:

  1. select 函数返回之前,用户空间的 fd_set 表示的是有哪些文件描述符需要被监听
  2. select 函数返回之后,用户空间的 fd_set 会被内核中记录读写状态的 fd_set 覆盖,所以此时 fd_set 就表示哪些文件描述符发生读写事件,处于就绪状态,即:记录的是文件描述符的状态
  3. 之前和之后的 fd_set 中的内容可能是不同的,表示的含义不同。所以,每次调用 select 一定要重新设置 fd_set 才是正确的操作

2. fd_set 集合操作

#include <iostream>
#include <bitset>

extern "C"
{
    #include <sys/select.h>
    #include <stdio.h>
}


void print_bitmap(fd_set readfds)
{
    for (size_t i = 0; i < 32; ++i)
    {
        // 获得 32 个比特位
        int bit_four = readfds.fds_bits[i];
        int bit_mask = 0x01;
        // 从低到高打印 32 个比特位值
        for (size_t j = 0; j < 32; ++j)
        {
            int bit_value = bit_four & bit_mask;
            printf("%d", bit_value);

            bit_four >>= 1;
        }

    }
}

void test()
{
    // 创建 fd_set 对象
    fd_set readfds;
    printf("bit map:");
    print_bitmap(readfds);
    printf("\n");


    // 初始化 fd_set 对象
    FD_ZERO(&readfds);
    printf("bit map:");
    print_bitmap(readfds);
    printf("\n");

    // 将某个位置设置为 1
    FD_SET(2, &readfds);
    FD_SET(4, &readfds);
    printf("bit map:");
    print_bitmap(readfds);
    printf("\n");

    // 清除指定位置
    FD_CLR(2, &readfds);
    printf("bit map:");
    print_bitmap(readfds);
    printf("\n");

    // 判断指定位置是否为1
    printf("%d ", FD_ISSET(2, &readfds));
    printf("%d\n", FD_ISSET(4, &readfds));
}

int main()
{
    test();
    getchar();
    return 0;
}

程序执行结果:

bit map:00001001010000000000000000000000000000010000100000000010000000000001...
bit map:00000000000000000000000000000000000000000000000000000000000000000000...
bit map:00101000000000000000000000000000000000000000000000000000000000000000...
bit map:00001000000000000000000000000000000000000000000000000000000000000000...
0 1

3. select 函数参数

int select (int __nfds, 
		   fd_set *__restrict __readfds,
		   fd_set *__restrict __writefds,
		   fd_set *__restrict __exceptfds,
		   struct timeval *__restrict __timeout)
  • __nfds 表示最大的文件描述符 + 1
  • __readfds 表示需要监听读事件的文件描述符 fd_set 集合
  • __writefds 表示需要监写读事件的文件描述符 fd_set 集合
  • __exceptfds 表示需要监异常读事件的文件描述符 fd_set 集合
  • __timeout 表示 select 超时时间,默认情况下 select 会一直阻塞到有事件发生,设置该参数之后,达到该时间,select 函数也会返回

我们重点说说第一个参数 __nfds,假设:我们创建了 2 个文件描述符,一个值是 67、一个值是 84,那么 __nfds 的值就是 84 + 1。

有些同学可能想不明白,我们不是已经通过 fd_set 告诉内核要监听哪些文件描述符了吗?这个参数有什么用呢?

首先,需要明确一点,我们虽然只持有 67、84 两个文件描述符,但是内核并不是精准的只检测这两个文件描述符的状态,而是线性的在 0-85 之间去检测。如果没有这个参数,内核可能直接在 0-1023 之间去检测所有的文件描述符了,所以从实现上来讲,指定该参数,能让内核一定程度上减少要检测的描述符数量。

示例代码:

#include <iostream>
#include <set>


extern "C"
{
    #include <sys/socket.h>
    #include <netinet/in.h>
    #include <arpa/inet.h>
    #include <unistd.h>
}

#define MAX_SOCK_NUMS 1024

void test()
{
    // 创建套接字
    int server_sock = socket(AF_INET, SOCK_STREAM, 0);
    if (-1 == server_sock)
    {
        std::cout << "socket error" << std::endl;
        return;
    }

    // 套接字绑定 IP 地址和端口
    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = inet_addr("127.0.0.1");
    addr.sin_port = htons(9000);

    int ret = bind(server_sock, (const sockaddr*)&addr, sizeof(addr));
    if (-1 == ret)
    {
        std::cout << "bind error" << std::endl;
        return;
    }

    // 将套接字转换为被动套接字
    ret = listen(server_sock, 128);
    if (-1 == ret)
    {
        std::cout << "listen error" << std::endl;
        return;
    }

    std::set<int> connected_sockets = { server_sock };
    fd_set read_fds;


    while (true)
    {
        FD_ZERO(&read_fds);

        // 将监听套接字添加到集合中
        FD_SET(server_sock, &read_fds);
        int max_fd = server_sock;

        // 将所有套接字加入到文件描述符集合中
        for (auto fd : connected_sockets)
        {
            if (fd > 0)
            {
                FD_SET(fd, &read_fds);
            }

            // 计算最大的文件描述符数值
            if (fd > max_fd)
            {
                max_fd = fd;
            }

        }
        
        // 如果有文件描述符就绪,则返回,否则阻塞等待
        int active_number = select(max_fd + 1, &read_fds, nullptr, nullptr, nullptr);
        if (-1 == active_number)
        {
            std::cout << "select error" << std::endl;
            return;
        }

        printf("active_number: %d\n", active_number);

        // 处理其他连接
        for (auto it = connected_sockets.begin(); it != connected_sockets.end(); ++it)
        {
            int fd = *it;

            if (server_sock == fd)
            {
                continue;
            }

            // 处理读请求
            if (FD_ISSET(fd, &read_fds))
            {
                char recv_buf[1024] = { 0 };
                ssize_t num = recv(fd, recv_buf, sizeof(recv_buf), 0);
                if (num <= 0)
                {
                    if (errno == EINTR)
                    {
                        continue;
                    }
                    printf("close socket: %d\n", fd);
                    it++;
                    connected_sockets.erase(fd);
                    close(fd);
                }
                printf("%s\n", recv_buf);
            }
            
        }


        // 判断是否有新的连接
        int new_sock = -1;
        if (FD_ISSET(server_sock, &read_fds))
        {
            struct sockaddr_in client_addr;
            socklen_t len;
            new_sock = accept(server_sock, (struct sockaddr*)&client_addr, &len);
            if (-1 == new_sock)
            {
                std::cout << "accept error" << std::endl;
                return;
            }
            printf("Port: %s\n", inet_ntoa(client_addr.sin_addr));
            printf("Addr: %d\n", ntohs(client_addr.sin_port));

            connected_sockets.insert(new_sock);
        }

        printf("-------------------\n");

    }
    
    // 关闭套接字
    for (auto fd : connected_sockets)
    {
        close(fd);
    }
}


int main()
{
    test();
    getchar();
    return 0;
}
未经允许不得转载:一亩三分地 » Linux Select IO 模型
评论 (0)

8 + 3 =