IO多路复用之select

1. 概念

        IO多路复用也称IO多路转接,它是一种网络通信的手段(机制),通过这种方式可以同时监测多个文件描述符并且这个过程是阻塞的,一旦检测到有文件描述符就绪( 可以读数据或者可以写数据)程序的阻塞就会被解除,之后就可以基于这些(一个或多个)就绪的文件描述符进行通信了。通过这种方式在单线程/进程的场景下也可以在服务器端实现并发。常见的IO多路转接方式有:select、poll、epoll。

2. select

2.1 函数原型

        使用select这种IO多路转接方式需要调用一个同名函数select,这个函数是跨平台的,Linux、Mac、Windows都是支持的。程序猿通过调用这个函数可以委托内核帮助我们检测若干个文件描述符的状态,其实就是检测这些文件描述符对应的读写缓冲区的状态:

  • 读缓冲区:检测里边有没有数据,如果有数据该缓冲区对应的文件描述符就绪
  • 写缓冲区:检测写缓冲区是否可以写(有没有容量),如果有容量可以写,缓冲区对应的文件描述符就绪
  • 读写异常:检测读写缓冲区是否有异常,如果有该缓冲区对应的文件描述符就绪

        委托检测的文件描述符被遍历检测完毕之后,已就绪的这些满足条件的文件描述符会通过select()的参数分3个集合传出,程序猿得到这几个集合之后就可以分情况依次处理了。

下面来看一下这个函数的函数原型:

#include <sys/select.h>
struct timeval {
    time_t      tv_sec;         /* seconds */
    suseconds_t tv_usec;        /* microseconds */
};

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

函数参数:

nfds:委托内核检测的这三个集合中最大的文件描述符 + 1

  • 内核需要线性遍历这些集合中的文件描述符,这个值是循环结束的条件
  • 在Window中这个参数是无效的,指定为-1即可

readfds:文件描述符的集合, 内核只检测这个集合中文件描述符对应的读缓冲区

  • 传入传出参数,读集合一般情况下都是需要检测的,这样才知道通过哪个文件描述符接收数据

writefds:文件描述符的集合, 内核只检测这个集合中文件描述符对应的写缓冲区

  • 传入传出参数,如果不需要使用这个参数可以指定为NULL

exceptfds:文件描述符的集合, 内核检测集合中文件描述符是否有异常状态

  • 传入传出参数,如果不需要使用这个参数可以指定为NULL

timeout:超时时长,用来强制解除select()函数的阻塞的

  • NULL:函数检测不到就绪的文件描述符会一直阻塞。
  • 等待固定时长(秒):函数检测不到就绪的文件描述符,在指定时长之后强制解除阻塞,函数返回0
  • 不等待:函数不会阻塞,直接将该参数对应的结构体初始化为0即可。

函数返回值:

  • 大于0:成功,返回集合中已就绪的文件描述符的总个数
  • 等于-1:函数调用失败
  • 等于0:超时,没有检测到就绪的文件描述符

另外初始化fd_set类型的参数还需要使用相关的一些列操作函数,具体如下:

// 将文件描述符fd从set集合中删除 == 将fd对应的标志位设置为0        
void FD_CLR(int fd, fd_set *set);
// 判断文件描述符fd是否在set集合中 == 读一下fd对应的标志位到底是0还是1
int  FD_ISSET(int fd, fd_set *set);
// 将文件描述符fd添加到set集合中 == 将fd对应的标志位设置为1
void FD_SET(int fd, fd_set *set);
// 将set集合中, 所有文件文件描述符对应的标志位设置为0, 集合中没有添加任何文件描述符
void FD_ZERO(fd_set *set);

2.2 细节描述

        在select()函数中第2、3、4个参数都是fd_set类型,它表示一个文件描述符的集合,类似于信号集 sigset_t,这个类型的数据有128个字节,也就是1024个标志位,和内核中文件描述符表中的文件描述符个数是一样的。

sizeof(fd_set) = 128 字节 * 8 = 1024 bit      // int [32]

        这并不是巧合,而是故意为之。这块内存中的每一个bit 和 文件描述符表中的每一个文件描述符是一一对应的关系,这样就可以使用最小的存储空间将要表达的意思描述出来了。

  • 如果集合中的标志位为0代表不检测这个文件描述符状态
  • 如果集合中的标志位为1代表检测这个文件描述符状态

        内核在遍历这个读集合的过程中,如果被检测的文件描述符对应的读缓冲区中没有数据,内核将修改这个文件描述符在读集合fd_set中对应的标志位,改为0,如果有数据那么这个标志位的值不变,还是1。

        当select()函数解除阻塞之后,被内核修改过的读集合通过参数传出,此时集合中只要标志位的值为1,那么它对应的文件描述符肯定是就绪的,我们就可以基于这个文件描述符和客户端建立新连接或者通信了。

2.3 处理流程

如果在服务器基于select实现并发,其处理流程如下:

1.创建监听的套接字 lfd = socket();

2.将监听的套接字和本地的IP和端口绑定 bind()

3.给监听的套接字设置监听 listen()

4.创建一个文件描述符集合 fd_set,用于存储需要检测读事件的所有的文件描述符

  • 通过 FD_ZERO() 初始化
  • 通过 FD_SET() 将监听的文件描述符放入检测的读集合中

5. 循环调用select(),周期性的对所有的文件描述符进行检测

6. select() 解除阻塞返回,得到内核传出的满足条件的就绪的文件描述符集合

        通过FD_ISSET() 判断集合中的标志位是否为 1

  • 如果这个文件描述符是监听的文件描述符,调用 accept() 和客户端建立连接,将得到的新的通信的文件描述符,通过FD_SET() 放入到检测集合中
  • 如果这个文件描述符是通信的文件描述符,调用通信函数和客户端通信,如果客户端和服务器断开了连接,使用FD_CLR()将这个文件描述符从检测集合中删除,如果没有断开连接,正常通信即可

7. 重复第6步

2.4 通信代码

服务器端代码如下:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <arpa/inet.h>

int main()
{
    // 1. 创建监听的fd
    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);

    // 将监听的fd的状态检测委托给内核检测
    int maxfd = lfd;
    // 初始化检测的读集合
    fd_set rdset;
    fd_set rdtemp;
    // 清零
    FD_ZERO(&rdset);
    // 将监听的lfd设置到检测的读集合中
    FD_SET(lfd, &rdset);
    // 通过select委托内核检测读集合中的文件描述符状态, 检测read缓冲区有没有数据
    // 如果有数据, select解除阻塞返回
    // 应该让内核持续检测
    while(1)
    {
        // 默认阻塞
        // rdset 中是委托内核检测的所有的文件描述符
        rdtemp = rdset;
        int num = select(maxfd+1, &rdtemp, NULL, NULL, NULL);
        // rdset中的数据被内核改写了, 只保留了发生变化的文件描述的标志位上的1, 没变化的改为0
        // 只要rdset中的fd对应的标志位为1 -> 缓冲区有数据了
        // 判断
        // 有没有新连接
        if(FD_ISSET(lfd, &rdtemp))
        {
            // 接受连接请求, 这个调用不阻塞
            struct sockaddr_in cliaddr;
            int cliLen = sizeof(cliaddr);
            int cfd = accept(lfd, (struct sockaddr*)&cliaddr, &cliLen);

            // 得到了有效的文件描述符
            // 通信的文件描述符添加到读集合
            // 在下一轮select检测的时候, 就能得到缓冲区的状态
            FD_SET(cfd, &rdset);
            // 重置最大的文件描述符
            maxfd = cfd > maxfd ? cfd : maxfd;
        }

        // 没有新连接, 通信
        for(int i=0; i<maxfd+1; ++i)
        {
			// 判断从监听的文件描述符之后到maxfd这个范围内的文件描述符是否读缓冲区有数据
            if(i != lfd && FD_ISSET(i, &rdtemp))
            {
                // 接收数据
                char buf[10] = {0};
                // 一次只能接收10个字节, 客户端一次发送100个字节
                // 一次是接收不完的, 文件描述符对应的读缓冲区中还有数据
                // 下一轮select检测的时候, 内核还会标记这个文件描述符缓冲区有数据 -> 再读一次
                // 	循环会一直持续, 知道缓冲区数据被读完位置
                int len = read(i, buf, sizeof(buf));
                if(len == 0)
                {
                    printf("客户端关闭了连接...\n");
                    // 将检测的文件描述符从读集合中删除
                    FD_CLR(i, &rdset);
                    close(i);
                }
                else if(len > 0)
                {
                    // 收到了数据
                    // 发送数据
                    write(i, buf, strlen(buf)+1);
                }
                else
                {
                    // 异常
                    perror("read");
                }
            }
        }
    }

    return 0;
}

        在上面的代码中,创建了两个fd_set变量,用于保存要检测的读集合:

// 初始化检测的读集合
fd_set rdset;
fd_set rdtemp;

        rdset用于保存要检测的原始数据,这个变量不能作为参数传递给select函数,因为在函数内部这个变量中的值会被内核修改,函数调用完毕返回之后,里边就不是原始数据了,大部分情况下是值为1的标志位变少了,不可能每一轮检测,所有的文件描述符都是就行的状态。因此需要通过rdtemp变量将原始数据传递给内核,select() 调用完毕之后再将内核数据传出,这两个变量的功能是不一样的。

客户端代码:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <arpa/inet.h>

int main()
{
    // 1. 创建用于通信的套接字
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    if(fd == -1)
    {
        perror("socket");
        exit(0);
    }

    // 2. 连接服务器
    struct sockaddr_in addr;
    addr.sin_family = AF_INET;     // ipv4
    addr.sin_port = htons(9999);   // 服务器监听的端口, 字节序应该是网络字节序
    inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr.s_addr);
    int ret = connect(fd, (struct sockaddr*)&addr, sizeof(addr));
    if(ret == -1)
    {
        perror("connect");
        exit(0);
    }

    // 通信
    while(1)
    {
        // 读数据
        char recvBuf[1024];
        // 写数据
        // sprintf(recvBuf, "data: %d\n", i++);
        fgets(recvBuf, sizeof(recvBuf), stdin);
        write(fd, recvBuf, strlen(recvBuf)+1);
        // 如果客户端没有发送数据, 默认阻塞
        read(fd, recvBuf, sizeof(recvBuf));
        printf("recv buf: %s\n", recvBuf);
        sleep(1);
    }

    // 释放资源
    close(fd); 

    return 0;
}

        客户端不需要使用IO多路转接进行处理,因为客户端和服务器的对应关系是 1:N,也就是说客户端是比较专一的,只能和一个连接成功的服务器通信。

虽然使用select这种IO多路转接技术可以降低系统开销,提高程序效率,但是它也有局限性:

1.待检测集合(第2、3、4个参数)需要频繁的在用户区和内核区之间进行数据的拷贝,效率低

2.内核对于select传递进来的待检测集合的检测方式是线性的

  • 如果集合内待检测的文件描述符很多,检测效率会比较低
  • 如果集合内待检测的文件描述符相对较少,检测效率会比较高

3.使用select能够检测的最大文件描述符个数有上限,默认是1024,这是在内核中被写死了的。

3. poll函数

3.1 函数原型

        poll的机制与select类似,与select在本质上没有多大差别,使用方法也类似,下面的是对于二者的对比:

  • 内核对应文件描述符的检测也是以线性的方式进行轮询,根据描述符的状态进行处理
  • poll和select检测的文件描述符集合会在检测过程中频繁的进行用户区和内核区的拷贝,它的开销随着文件描述符数量的增加而线性增大,从而效率也会越来越低。
  • select检测的文件描述符个数上限是1024,poll没有最大文件描述符数量的限制
  • select可以跨平台使用,poll只能在Linux平台使用

poll函数的函数原型如下:

#include <poll.h>
// 每个委托poll检测的fd都对应这样一个结构体
struct pollfd {
    int   fd;         /* 委托内核检测的文件描述符 */
    short events;     /* 委托内核检测文件描述符的什么事件 */
    short revents;    /* 文件描述符实际发生的事件 -> 传出 */
};

struct pollfd myfd[100];
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

函数参数:

fds: 这是一个struct pollfd类型的数组, 里边存储了待检测的文件描述符的信息,这个数组中有三个成员:

  • fd:委托内核检测的文件描述符
  • events:委托内核检测的fd事件(输入、输出、错误),每一个事件有多个取值
  • revents:这是一个传出参数,数据由内核写入,存储内核检测之后的结果

nfds: 这是第一个参数数组中最后一个有效元素的下标 + 1(也可以指定参数1数组的元素总个数)

timeout: 指定poll函数的阻塞时长

  • -1:一直阻塞,直到检测的集合中有就绪的文件描述符(有事件产生)解除阻塞
  • 0:不阻塞,不管检测集合中有没有已就绪的文件描述符,函数马上返回
  • 大于0:阻塞指定的毫秒(ms)数之后,解除阻塞

函数返回值:

  • 失败: 返回-1
  • 成功:返回一个大于0的整数,表示检测的集合中已就绪的文件描述符的总个数

3.2 测试代码

服务器端:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <arpa/inet.h>
#include <sys/select.h>
#include <poll.h>

int main()
{
    // 1.创建套接字
    int lfd = socket(AF_INET, SOCK_STREAM, 0);
    if(lfd == -1)
    {
        perror("socket");
        exit(0);
    }
    // 2. 绑定 ip, port
    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);
    }
    
    // 4. 等待连接 -> 循环
    // 检测 -> 读缓冲区, 委托内核去处理
    // 数据初始化, 创建自定义的文件描述符集
    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("select");
            exit(0);
        }

        // 检测的度缓冲区有变化
        // 有新连接
        if(fds[0].revents & POLLIN)
        {
            // 接收连接请求
            struct sockaddr_in sockcli;
            int len = sizeof(sockcli);
            // 这个accept是不会阻塞的
            int connfd = accept(lfd, (struct sockaddr*)&sockcli, &len);
            // 委托内核检测connfd的读缓冲区
            int i;
            for(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("客户端say: %s\n", buf);
                    write(fds[i].fd, buf, strlen(buf)+1);
                }
            }
        }
    }
    close(lfd);
    return 0;
}

        从上面的测试代码可以得知,使用poll和select进行IO多路转接的处理思路是完全相同的,但是使用poll编写的代码看起来会更直观一些,select使用的位图的方式来标记要委托内核检测的文件描述符(每个比特位对应一个唯一的文件描述符),并且对这个fd_set类型的位图变量进行读写还需要借助一系列的宏函数,操作比较麻烦。而poll直接将要检测的文件描述符的相关信息封装到了一个结构体struct pollfd中,我们可以直接读写这个结构体变量。

另外poll的第二个参数有两种赋值方式,但是都和第一个参数的数组有关系:

  • 使用参数1数组的元素个数
  • 使用参数1数组中存储的最后一个有效元素对应的下标值 + 1

        内核会根据第二个参数传递的值对参数1数组中的文件描述符进行线性遍历,这一点和select也是类似的。

客户端:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <arpa/inet.h>

int main()
{
    // 1. 创建用于通信的套接字
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    if(fd == -1)
    {
        perror("socket");
        exit(0);
    }

    // 2. 连接服务器
    struct sockaddr_in addr;
    addr.sin_family = AF_INET;  // ipv4
    addr.sin_port = htons(9999);   // 服务器监听的端口, 字节序应该是网络字节序
    inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr.s_addr);
    int ret = connect(fd, (struct sockaddr*)&addr, sizeof(addr));
    if(ret == -1)
    {
        perror("connect");
        exit(0);
    }

    // 通信
    while(1)
    {
        // 读数据
        char recvBuf[1024];
        // 写数据
        // sprintf(recvBuf, "data: %d\n", i++);
        fgets(recvBuf, sizeof(recvBuf), stdin);
        write(fd, recvBuf, strlen(recvBuf)+1);
        // 如果客户端没有发送数据, 默认阻塞
        read(fd, recvBuf, sizeof(recvBuf));
        printf("recv buf: %s\n", recvBuf);
        sleep(1);
    }
    // 释放资源
    close(fd); 
    return 0;
}

        客户端不需要使用IO多路转接进行处理,因为客户端和服务器的对应关系是 1:N,也就是说客户端是比较专一的,只能和一个连接成功的服务器通信。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值