IO多路复用select

参考:《UNIX 网络编程 · 卷1 : 套接字联网API》

IO复用概念

在网络编程中,我们需要一种预先告知内核的能力,使得内核一旦发现进程指定的一个或多个 I/O 条件就绪,就通知进程,这个能力叫 IO 复用,一般由 select 或 poll 两个函数支持,但是还有比较新的 pselect 的POSIX变种。最后还有更高级的平台相关的 epoll 和 kqueue 以及 windwos 的 IOCP 等。

select 函数

select 允许进程指示内核等待多个事件中的任何一个发生,并在有一个或多个事件发生后才唤醒它。使用 select 告诉内核对哪些描述符感兴趣以及要等待的时间。感兴趣的描述符不仅是套接字,任何描述符都可以使用 select 来测试。

select函数定义

#include <sys/select.h>
#include <sys/time.h>
int select(int maxfdp1, fd_set* readset, fd_set* writeset, fd_set* exceptset, const struct timeval timeout);

参数说明

  • timeout 告诉内核等待指定的描述符集合其中任何一个就绪可花的最长时间。

    strucg timeval 结构体用于指定这段时间的秒数和微妙数。

    struct timeval 
    {
        long tv_sec;	//秒数
        long tv_usec;	//毫秒数
    }
    

这个参数有以下三种可能:

  1. NULL,仅在有一个描述符准备好 IO 时才返回。
  2. > 0,不超过设置的时间,有一个描述符准备好 IO 时返回。
  3. = 0,检查描述符后立即返回,称为轮询。

前两种情况通常会被进程在等待期间捕捉的信号中断,并从信号处理函数返回。

虽然指定的是一个微秒级的分辨率,但是内核真实的分辨率粗糙的多。Unix 把超时值向上舍入成 10ms 的倍数。还有调度延迟。

  • readsetwritesetexceptset 指定我们让内核测试读、写、异常描述符集合。

    select 使用的描述集合,通常是一个数组,每个整数中的每一位对应一个描述符。

    操作这些集合有以下四个宏:

    void FD_ZERO(fd_set* fdset);	//清除集合所有位的设置
    void FD_SET(int fd, fd_set* fdset);	//将fd描述符对应的位设置1
    void FD_CLR(int fd, fd_set* fdset); //将fd描述符对应的位设置0
    int FD_ISSET(int fd, fd_set* fdset);	//检查fd对应的位是否设置
    

我们可以用 C 语言的赋值语句将一个描述符集赋值给另一个描述符集。

这三个参数中,如果对某一个条件不感兴趣,就可以把它置为 NULL。如果我们将其全部置为 NULL,就有了一个比Unix 的 sleep 函数更加精确的定时器(因为 sleep 是以秒为最小单位)。

这三个参数都是值-结果参数。调用函数时,指定所关心的描述符的值,该函数返回后,将指示哪些描述符已就绪。可以使用 FS_ISSET 宏来测试 fd_set 数据类型中的描述符。注意:该返回后的集合中没有任何就绪事件发生的描述符对应的位均被清 0,所以,每次调用 select 函数时,都需要再次把所有描述符集合内关心的位都设置为 1。

  • maxfdp1 指定待测试的描述符的个数,它的值是待测试的最大描述符的表示的数字 +1,表示描述符 0, 1, 2 … 一直到 maxfdp1 -1 均被测试。

如果最大描述符为 5,那么我们就要指定 maxfdp1 参数值为 6,原因是:我们指定的是位图中描述符的个数,而不是描述符的最大值,而描述符是从 0 开始的。

需要注意:在头文件 <sys/select.h> 中,FS_SETSIZE 常数值是数据类型 fd_set 中描述符的总数,其值通常为 1024。

返回值

  1. > 0,跨所有描述符集的已就绪的总位数。
  2. = 0,在定时器到了,还没有任何描述符就绪。
  3. -1,表示出错。(比如本函数被一个所捕获的信号中断)。

select的位图集合

当服务器创建好监听套接字描述符,将监听套接字加入到读描述符集中。如果服务器是在前台启动的,那么描述符 0,1,2 将被设置为标准输入、标准输出和标准错误输出。那么监听套接字的第一个可用描述符为 3。位图集合 rset 的下标为 3 的值置为 1。使用一个名为 client 整形数组,用来存储所有客户端已连接套接字描述符,所有元素初始化为 -1。

仅有一个监听套接字的select数据结构

当第一个客户端与服务器建立连接时,监听描述符变为可读,服务器调用 accept,返回新的已连接描述符将是 4。位图集合 rset 的下标为 4 的值置为 1。

第一个客户端连接后的select数据结构

然后第二个客户端与服务器建立连接,新的描述符 5 也被数组记录。位图集合 rset 中下标为 5 的值置为 1。

第二个客户端连接后的select数据结构

现在,如果第一个客户端终止连接。使得服务器描述符 4 变为可读。但是服务器读取返回 0,就关闭该套接字,并更新数据接口:将 client[0] 的值重新置为 -1,把位图集合 rset 中下标为 4 的值置为 0。注意:maxfd 的值仍然没有改变。

第一个客户端终止连接后的select数据结构

总结一下就是:当有客户端到达,就在 client 数组中找到第一个值为 -1 的位置将接收的已连接套接字存储起来,并且还要把这个已连接描述符加到读描述符集 rset 中,而 select 的第一个参数 maxfd 是描述符集合位图中置为 1 的最大下标 +1,从上面可以看到,即使第一个客户端断开链接了,而 maxfd 取值还是 5+1=6。

最大客户端连接数的限制是以下两个值的较小者:1. FD_SETSIZE 的值。2. 内核允许本进程打开的最大描述符数。

描述符的就绪条件

虽然可读性和可写性对于普通的文件的描述符显而易见,但是对于 select 返回套接字的就绪条件就需要讨论明确了。

  • 套接字可读条件

    1. 该套接字接收缓冲区中的数据字节数大于等于套接字接收缓冲区低水位标记的当前大小。对这样的套接字执行读操作不会阻塞并将返回一个大于0的值。可以使用 SO_RCVLOWAT 套接字选项设置该套接字的低水位标记。对于 TCP 和 UDP 套接字而言,其默认值为 1。
    2. 该连接的读半部分关闭。这样的套接字的读操作将不阻塞并返回0。
    3. 该套接字是一个监听套接字并且已完成的连接数不为 0。对这样的套接字 accept 通常不会阻塞。
    4. 其上有一个套接字错误待处理。对这样的套接字读操作将不阻塞并返回 -1,同时设置 errno 的值。这些待处理错误也可以通过指定 SO_ERROR 套接字选项调用 getsockopt 获取并清除。
  • 套接字可写条件

    1. 该套接字发送缓冲区中的可用空间字节数大于等于套接字发送缓冲区低水位标记的当前大小,并且或者该套接字已连接,或者该套接字不需要连接(UDP)。这意味着可以把这样的套接字设置为非阻塞,写操作将不阻塞并返回一个正值(传输层接受的字节数)。可以使用 SO_SNDLOWAT 套接字选项来设置该套接字的低水位标记。对于 TCP 和 UDP 而言,通常为 2048。
    2. 该连接的写半部分关闭。这样的套接字写操作将产生 SIGPIPE 信号。
    3. 使用非阻塞式 connect 套接字已建立连接,或者 connect 已经以失败告终。
    4. 其上有一个套接字处理错误。对这样的套接字写操作将不阻塞并返回 -1,并设置 errno 的值。这些待处理错误也可以通过指定 SO_ERROR 套接字选项调用 getsockopt 获取并清除。

    注意:当某个套接字上发生错误时,它将由 select 标记为即可读又可写。

  • 异常条件

    1. 某个套接字的带外数据到达。
    2. 某个已置为分组模式的伪终端存在可从其主端读取的控制状态信息。

接收低水位标记和发送低水位标记的目的:

允许应用进程控制在 select 返回可读或可写条件之前有多少数据可读或有多大空间可用于写。比如:至少存在 64 个字节的数据,否则我们的应用进程没有任何有效的工作可做,那就可以把接收低水位标记设置为 64,以防止少于 64 字节的数据时 select 唤醒。

任何 UPD 套接字只要其发送低水位标记小于等于发送缓冲区大小就总是可写的,这是因为 UDP 套接字不需要连接。

select 的最大描述符数

在早期,大多数应用不会用到许多描述符。但是确实有用到很多描述符的应用程序。最初设计 select 时,操作系统通常对每个进程可用的最大描述符数设置了上限,select 就使用相同的限制数。但是现在 Unix 允许每个进程使用事实上无限个数目的描述符(受限于内存总量和管理性限制),对 select 有什么影响?

在 <sys/types.h> 头文件定义了:

#ifndef FD_SETSIZE
#define FS_SETSIZE 256
#endif

我们可以想到,可以包括该头文件之前把 FD_SETSIZE 定义成某个更大的值以增加 select 所用描述符集的大小。但是,这样通常不行。通常需要先增大 FD_SETSIZE 的值,然后再编译内核。不重新编译内核而改变值是不够的。

某些厂商正在将 select 的实现修改为允许进程将 FD_SETSIZE 定义为比默认值更大的某个值。但是从移植性考虑,谨慎使用大描述符集合。

select 实例

#include <arpa/inet.h>
#include <errno.h>
#include <fcntl.h>
#include <netinet/in.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/select.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

#define ERR_EXIT(m)         \
    do                      \
    {                       \
        perror(m);          \
        exit(EXIT_FAILURE); \
    } while (0)

int main(int argc, char **argv)
{
    signal(SIGPIPE, SIG_IGN);
    signal(SIGCHLD, SIG_IGN);
    int idlefd = open("/dev/null", O_RDONLY | O_CLOEXEC);

    //创建套接字
    int listenfd;
    if ((listenfd = socket(PF_INET, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, IPPROTO_TCP)) < 0)
        ERR_EXIT("socket.");

    //填充服务器地址信息
    struct sockaddr_in servaddr;
    memset(&servaddr, 0, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    servaddr.sin_port = htons(8000);
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY);

    //地址&端口复用
    int on = 1;
    if (setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on)) < 0)
        ERR_EXIT("setsockopt reuseaddr.");
    if (setsockopt(listenfd, SOL_SOCKET, SO_REUSEPORT, &on, sizeof(on)) < 0)
        ERR_EXIT("setsockopt reuseport.");

    //绑定
    if (bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) < 0)
        ERR_EXIT("bind.");

    //监听
    if (listen(listenfd, SOMAXCONN) < 0)
        ERR_EXIT("listen.");

    int maxfd = listenfd;                //记录最大的描述符
    int client[FD_SETSIZE];              //用于保存描述符的数组
    for (int i = 0; i < FD_SETSIZE; i++) //初始化数组所有值为-1
    {
        client[i] = -1;
    }
    //初始化两个集合,allset用于保存所有已设置的套接字
    //rset每次调用select前从allset赋值,再传入select函数参数中,因为rset在select返回后会改变
    fd_set allset, rset;
    FD_ZERO(&allset);
    FD_SET(listenfd, &allset);

    int maxindex = -1; //记录数组的最大下标
    int nready = 0;    //记录select返回值
    //记录接收客户端的地址信息
    struct sockaddr_in cliaddr;
    socklen_t cliaddr_len = sizeof(cliaddr);
    int connfd = -1;
    int sockfd = -1;
    char ip_str[INET_ADDRSTRLEN];
    while (1)
    {
        rset = allset;
        nready = select(maxfd + 1, &rset, NULL, NULL, NULL);
        if (nready < 0)
            ERR_EXIT("select error.");
        int index = 0;
        //如果listenfd在返回的rset中,有新的客户端连接
        if (FD_ISSET(listenfd, &rset))
        {
            connfd = accept4(listenfd, (struct sockaddr *)&cliaddr, &cliaddr_len, SOCK_NONBLOCK | SOCK_CLOEXEC);
            if (-1 == connfd)
            {
                if (errno == EMFILE) //描述符被使用完,应该关闭与对方的连接
                {
                    close(idlefd);
                    idlefd = accept(listenfd, NULL, NULL);
                    close(idlefd);
                    idlefd = open("dev/null", O_RDONLY | O_CLOEXEC);
                    continue;
                }
                else
                {
                    ERR_EXIT("accept4.\n");
                }
            }
            printf("new client[%s:%d]\n", inet_ntop(AF_INET, &cliaddr.sin_addr, ip_str, sizeof(ip_str)), ntohs(cliaddr.sin_port));
            for (index = 0; index < FD_SETSIZE; index++)
            {
                if (client[index] < 0)
                {
                    client[index] = connfd; //保存接收客户端的描述符到client数组里
                    break;
                }
            }
            //达到了select能监听的描述符最大个数
            if (index == FD_SETSIZE)
                ERR_EXIT("to many clients.");
            //设置客户端已连接描述符到监听集合中
            FD_SET(connfd, &allset);
            //更新maxfd
            if (connfd > maxfd)
                maxfd = connfd;
            //更新maxindex
            if (index > maxindex)
                maxindex = index;
            if (--nready == 0)
                continue;
        }
        for (index = 0; index <= maxindex; index++)
        {
            if ((sockfd = client[index]) < 0)
                continue;
            char buf[1024] = {0};
            //遍历client数组中值不为-1的描述符,检测是否在返回后的rset中设置
            if (FD_ISSET(sockfd, &rset))
            {
                int ret = read(sockfd, buf, 1024);
                if (ret == -1)
                {
                    ERR_EXIT("read.");
                }
                if (ret == 0) //对方断开连接
                {
                    printf("client close.\n");
                    FD_CLR(sockfd, &allset);
                    close(sockfd);
                    client[index] = -1;
                    continue;
                }
                else //将数据回射给客户端
                {
                    printf("recv client:%s\n", buf);
                    write(sockfd, buf, strlen(buf));
                }
                if (--nready == 0)
                    break;
            }
        }
    };
}
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

code_peak

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值