IO复用 select函数

I/O复用使得程序能同时监听多个文件描述符,这对提高程序的性能至关重要。

通常,网络程序在下列情况下需要使用I/O复用技术:

客户端

  • 客户端程序要同时处理多个socket。比如非阻塞connect技术。
  • 客户端程序要同时处理用户输入和网络连接。比如聊天室程序。

服务器

  • TCP服务器要同时处理监听socket和连接socket。这是I/O复用使最多的场合。
  • 服务器要同时处理TCP请求和UDP请求。
  • 服务器要同时监听多个端口,或者处理多种服务。

阻塞性:需要指出的是,I/O复用虽然能同时监听多个文件描述符,但它本身是阻塞的。并且当多个文件描述符同时就绪时,如果不采取额外的措施,程序就只能按顺序依次处理其中的每一个文件描述符,这使得服务器程序看起来像是串行工作的。如果要实现并发,只能使用多进程或多线程等编程手段。

Linux下实现I/O复用的系统调用主要有select、poll和epoll。

select系统调用

select系统调用的用途是:在一段指定时间内,监听用户感兴趣的文件描述符上的可读、可写和异常等事件。(由内核执行)

select API

#include<sys/select.h>
int select(int nfds,fd_set*readfds,fd_set*writefds,fd_set*exceptfds,struct timeval*timeout);
  1. nfds:指定被监听的文件描述符的总数。它通常被设置为select监听的所有文件描述符中的最大值加1,因为文件描述符是从0开始计数的。

  2. readfds、writefds和exceptfds参数分别指向可读、可写和异常等事件对应的文件描述符集合。应用程序调用select函数时,通过这3个参数传入自己感兴趣的文件描述符。select调用返回时,内核将修改它们来通知应用程序哪些文件描述符已经就绪。这3个参数是fd_set结构指针类型。

fd_set结构体的定义如下:

#include<typesizes.h>
#define__FD_SETSIZE 1024
#include<sys/select.h>
#define FD_SETSIZE__FD_SETSIZE
typedef long int__fd_mask;
#undef__NFDBITS
#define__NFDBITS(8*(int)sizeof(__fd_mask))
typedef struct
{
#ifdef__USE_XOPEN
__fd_mask fds_bits[__FD_SETSIZE/__NFDBITS];
#define__FDS_BITS(set)((set)->fds_bits)
#else
__fd_mask__fds_bits[__FD_SETSIZE/__NFDBITS];
#define__FDS_BITS(set)((set)->__fds_bits)
#endif
}fd_set;

由以上定义可见,fd_set结构体仅包含一个整型数组,该数组的每个元素的每一位(bit)标记一个文件描述符。fd_set能容纳的文件描述符数量由FD_SETSIZE指定,这就限制了select能同时处理的文件描述符的总量。
由于位操作过于烦琐,我们应该使用下面的一系列宏来访问fd_set结构体中的位:

#include<sys/select.h>
FD_ZERO(fd_set*fdset);/*清除fdset的所有位*/
FD_SET(int fd,fd_set*fdset);/*设置fdset的位fd*/
FD_CLR(int fd,fd_set*fdset);/*清除fdset的位fd*/
int FD_ISSET(int fd,fd_set*fdset);/*测试fdset的位fd是否被设置*/
  1. timeout参数用来设置select函数的超时时间。它是一个timeval结构类型的指针,采用指针参数是因为内核将修改它以告诉应用程序select等待了多久。不过我们不能完全信任select调用返回后的timeout值,比如调用失败时timeout值是不确定的。

timeval结构体的定义如下:

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

由以上定义可见,select 给我们提供了一个微秒级的定时方式:

  • 如果给 timeout 变量的 tv_sec 成员和 tv_usec 成员都传递 0,则 select 将立即返回。
  • 如果给 timeout 传递 NULL,则 select 将一直阻塞,直到某个文件描述符就绪。

select 的返回值

  • 成功时,返回就绪(可读、可写和异常)文件描述符的总数。
  • 如果在超时时间内没有任何文件描述符就绪,select 将返回 0
  • 失败时,返回 -1 并设置 errno

如果在 select 等待期间,程序接收到信号,则 select 立即返回 -1,并设置 errnoEINTR

文件描述符就绪条件

哪些情况下文件描述符可以被认为是可读、可写或者出现异常,对于 select 的使用非常关键。

在网络编程中,下列情况下 socket 可读:

  • socket 内核接收缓存区中的字节数大于或等于其低水位标记 SO_RCVLOWAT。此时我们可以无阻塞地读该 socket,并且读操作返回的字节数大于 0。
  • socket 通信的对方关闭连接。此时对该 socket 的读操作将返回 0。
  • 监听 socket 上有新的连接请求。
  • socket 上有未处理的错误。此时我们可以使用 getsockopt 来读取和清除该错误。

下列情况下 socket 可写:

  • socket 内核发送缓存区中的可用字节数大于或等于其低水位标记 SO_SNDLOWAT。此时我们可以无阻塞地写该 socket,并且写操作返回的字节数大于 0。
  • socket 的写操作被关闭。对写操作被关闭的 socket 执行写操作将触发一个 SIGPIPE 信号。
  • socket 使用非阻塞 connect 连接成功或者失败(超时)之后。
  • socket 上有未处理的错误。此时我们可以使用 getsockopt 来读取和清除该错误。

网络程序中,select 能处理的异常情况只有一种:socket 上接收到带外数据。

主旨思想

  1. 首先要构造一个关于文件描述符的列表,将要监听的文件描述符添加到该列表中
  2. 调用一个系统函数(select),监听该列表中的文件描述符,直到这些描述符中的一个或者多个进行I/O操作时,该函数才返回
    • 这个函数是阻塞
    • 函数对文件描述符的检测的操作是由内核完成的
  3. 在返回时,它会告诉进程有多少(哪些)描述符要进行I/O操作

工作过程分析

  1. 初始设定

  1. 设置监听文件描述符,将fd_set集合相应位置为1

  1. 调用select委托内核检测

  1. 内核检测完毕后,返回给用户态结果

代码实现

注意事项

  • select中需要的监听集合需要两个
    • 一个是用户态真正需要监听的集合rSet
    • 一个是内核态返回给用户态的修改集合tmpSet
  • 需要先判断监听文件描述符是否发生改变
    • 如果改变了,说明有客户端连接,此时需要将新的连接文件描述符加入到rSet,并更新最大文件描述符
    • 如果没有改变,说明没有客户端连接
  • 由于select无法确切知道哪些文件描述符发生了改变,所以需要执行遍历操作,使用FD_ISSET判断是否发生了改变
  • 如果客户端断开了连接,需要从rSet中清除需要监听的文件描述符
  • 程序存在的问题:中间的一些断开连接后,最大文件描述符怎么更新?=>估计不更新,每次都会遍历到之前的最大值处,解决方案见高并发优化思考

服务端

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

#define SERVERIP "127.0.0.1"
#define PORT 6789


int main()
{
    // 1. 创建socket(用于监听的套接字)
    int listenfd = socket(AF_INET, SOCK_STREAM, 0);
    if (listenfd == -1) {
        perror("socket");
        exit(-1);
    }
    // 2. 绑定
    struct sockaddr_in server_addr;
    server_addr.sin_family = PF_INET;
    // 点分十进制转换为网络字节序
    inet_pton(AF_INET, SERVERIP, &server_addr.sin_addr.s_addr);
    // 服务端也可以绑定0.0.0.0即任意地址
    // server_addr.sin_addr.s_addr = INADDR_ANY;
    server_addr.sin_port = htons(PORT);
    int ret = bind(listenfd, (struct sockaddr*)&server_addr, sizeof(server_addr));
    if (ret == -1) {
        perror("bind");
        exit(-1);
    }
    // 3. 监听(TCP连接队列的上限)
    ret = listen(listenfd, 8);
        if (ret == -1) {
        perror("listen");
        exit(-1);
    }
    // 创建读检测集合
    // rSet用于记录正在的监听集合,tmpSet用于记录在轮训过程中由内核态返回到用户态的集合
    fd_set rSet, tmpSet;
    // 清空
    FD_ZERO(&rSet);
    // 将监听文件描述符加入
    FD_SET(listenfd, &rSet);
    // 此时最大的文件描述符为监听描述符
    int maxfd = listenfd;
    // 不断循环等待客户端连接
    while (1) {
        tmpSet = rSet;
        // 使用select,设置为永久阻塞,有文件描述符变化才返回
        int num = select(maxfd + 1, &tmpSet, NULL, NULL, NULL);
        if (num == -1) {
            perror("select");
            exit(-1);
        } else if (num == 0) {
            // 当前无文件描述符有变化,执行下一次遍历
            // 在本次设置中无效(因为select被设置为永久阻塞)
            continue;
        } else {
            // 首先判断监听文件描述符是否发生改变(即是否有客户端连接)
            if (FD_ISSET(listenfd, &tmpSet)) {
                // 4. 接收客户端连接
                struct sockaddr_in client_addr;
                socklen_t client_addr_len = sizeof(client_addr);
                int connfd = accept(listenfd, (struct sockaddr*)&client_addr, &client_addr_len);
                if (connfd == -1) {
                    perror("accept");
                    exit(-1);
                }
                // 输出客户端信息,IP组成至少16个字符(包含结束符)
                char client_ip[16] = {0};
                inet_ntop(AF_INET, &client_addr.sin_addr.s_addr, client_ip, sizeof(client_ip));
                unsigned short client_port = ntohs(client_addr.sin_port);
                printf("ip:%s, port:%d\n", client_ip, client_port);

                FD_SET(connfd, &rSet);
                // 更新最大文件符
                maxfd = maxfd > connfd ? maxfd : connfd;
            }

            // 遍历集合判断是否有变动,如果有变动,那么通信
            char recv_buf[1024] = {0};
            for (int i = listenfd + 1; i <= maxfd; i++) {
                if (FD_ISSET(i, &tmpSet)) {
                    ret = read(i, recv_buf, sizeof(recv_buf));
                    if (ret == -1) {
                        perror("read");
                        exit(-1);
                    } else if (ret > 0) {
                        printf("recv server data : %s\n", recv_buf);
                        write(i, recv_buf, strlen(recv_buf));
                    } else {
                        // 表示客户端断开连接
                        printf("client closed...\n");
                        close(i);
                        FD_CLR(i, &rSet);
                        break;
                    }
                }
            }
        }
    }

    close(listenfd);
    return 0;
}

客户端

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

#define SERVERIP "127.0.0.1"
#define PORT 6789

int main()
{
    // 1. 创建socket(用于通信的套接字)
    int connfd = socket(AF_INET, SOCK_STREAM, 0);
    if (connfd == -1) {
        perror("socket");
        exit(-1);
    }
    // 2. 连接服务器端
    struct sockaddr_in server_addr;
    server_addr.sin_family = PF_INET;
    inet_pton(AF_INET, SERVERIP, &server_addr.sin_addr.s_addr);
    server_addr.sin_port = htons(PORT);
    int ret = connect(connfd, (struct sockaddr*)&server_addr, sizeof(server_addr));
    if (ret == -1) {
        perror("connect");
        exit(-1);
    }
    // 3. 通信
    char recv_buf[1024] = {0};
    while (1) {
        // 发送数据
        char *send_buf = "client message";
        write(connfd, send_buf, strlen(send_buf));
        // 接收数据
        ret = read(connfd, recv_buf, sizeof(recv_buf));
        if (ret == -1) {
            perror("read");
            exit(-1);
        } else if (ret > 0) {
            printf("recv server data : %s\n", recv_buf);
        } else {
            // 表示客户端断开连接
            printf("client closed...\n");
        }
        // 休眠的目的是为了更好的观察,放在此处可以解决read: Connection reset by peer问题
        sleep(1);
    }
    // 关闭连接
    close(connfd);
    return 0;
}

高并发优化思考

问题

  • 每次都需要利用FD_ISSET轮训[0, maxfd]之间的连接状态,如果位于中间的某一个客户端断开了连接,此时不应该再去利用FD_ISSET轮训,造成资源浪费
  • 如果在处理客户端数据时,某一次read没有对数据读完,那么造成重新进行下一次时select,获取上一次未处理完的文件描述符,从0开始遍历到maxfd,对上一次的进行再一次操作,效率十分低下

解决

  • 考虑到select只有1024个最大可监听数量,可以申请等量客户端数组
    • 初始置为-1,当有状态改变时,置为相应文件描述符
    • 此时再用FD_ISSET轮训时,跳过标记为-1的客户端,加快遍历速度
  • 对于问题二:对读缓存区循环读,直到返回EAGAIN再处理数据

存在问题(缺点)

  • 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
  • 同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
  • select支持的文件描述符数量太小了,默认是1024,如果链接客户端过多,select 采用的是轮询模型,会大大降低服务器响应效率,不应在 select 上投入更多精力。
  • fds集合不能重用,每次都需要重置

参考

《Linux高性能服务器编程》
《牛客C++实战 webserver笔记》
connect及bind、listen、accept背后的三次握手
select源码剖析

posted @   丘狸尾  阅读(13)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 单元测试从入门到精通
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 上周热点回顾(3.3-3.9)
· winform 绘制太阳,地球,月球 运作规律
点击右上角即可分享
微信分享提示