Loading

《Unix 网络编程》06:IO复用之select/poll

IO复用之select/poll

系列文章导航:《Unix 网络编程》笔记

概述

进程需要一种预先告知内核的能力,使得内核一旦发现进程指定的一个或多个 I/O 条件准备就绪,他就通知进程。这个能力被称为 I/O 复用。

典型应用场景有:

  • 客户处理多个描述符(如之前的应用那样)
  • 客户同时处理多个套接字,不过这种情况比较少见
  • 服务器既要处理监听套接字,又要处理已经连接的套接字
  • 服务器既要处理 TCP,又要处理 UDP
  • 服务器要处理多个服务或多个协议
  • 许多重要的应用程序也需要这种技术

IO模型

Unix 下的 5 种 I/O 模型:

  1. 阻塞式 I/O
  2. 非阻塞式 I/O
  3. I/O 复用(select 和 poll)
  4. 信号驱动式 I/O (SIGIO)
  5. 异步 I/O (aio_)

输入操作的阶段

  1. 等待数据准备好
  2. 从内核向进程复制数据

不同的 I/O 模型就是这两个阶段的行为的不同

阻塞式 I/O

阻塞 I/O 就是两个阶段都等待。

可以看出,这种方式比较缓慢,进程在等待期间不能做其他的事情。

非阻塞式 I/O

非阻塞 I/O 在所查询的数据没有就绪时不会阻塞(上述阶段一),而是直接返回一个错误

用户进程需要不断地进行调用,询问是否准备就绪,一旦就绪才开始处理。

当一个应用进程像这样对一个非阻塞描述符循环调用查询操作时,我们称之为轮询(pollling)。

这种做法往往耗费大量 CPU 时间,反而不太好用。但是在特定的场合下也能发挥其作用。

I/O 复用

I/O 复用会等待在上述两个阶段。但是,第一个阶段的等待是等待在如 select 这些方法上的,而不是 recvfrom,而 select 可以同时等待多个套接字,所以说即使准备就绪 socket 的排在后面,也可以“插队”得到响应。

这种方式也类似于多线程的阻塞式 I/O 模式。

而根据具体的细节,I/O 复用又可以分为:

  • select
  • poll
  • epoll

信号驱动式 I/O

与内核建立 SIGIO 的信号关联并设置回调,当内核有 FD 准备就绪时,发送 SIGIO 信号通知用户,期间用户应用可以执行其他业务,无需阻塞等待。

这种方式在阶段一不会发生阻塞,而且和非阻塞式 I/O 相比也不会发生轮询现象。

缺点是:

  • 当有大量 IO 操作时,信号较多,SIGIO 处理函数不能及时处理可能导致信号队列溢出
  • 内核空间与用户空间的频繁信号交互性能比较低

异步 I/O

告知内核启动某个操作,并让内核完成整个操作后通知我们。(像是在给下属布置任务)

这种方式在上述两个阶段都是非阻塞的,其缺点有:

  • 由于调用方只需调用而无需等待,而具体工作可能很消耗资源,所以多线程高并发环境下可能会耗尽系统资源导致系统崩溃
  • 为了限制并发,需要对上述情况进行处理,从而导致回调函数会变得复杂

问题:消息阻塞

在上一章中已经描述了这个问题发生的原因和造成的原因。

这里侧重问题的解决。

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 :最长等待时间

struct timeval {
  long tv_sec;
  long tv_usec;
}

分别代表秒和微秒:

  • 如果结构体的两个参数均设为 0,则不等待
  • 如果结构体为空,则一直等待

readset、writeset、exceptset

设置具体关心的描述符集合,通常是一个整数数组,不同的位标识不同的描述符。

void FD_ZERO(fd_set *fdset); /* clear all bits in fdset */
void FD_SET(int fd, fd_set *fdset); /* turn on the bit for fd in fdset */
void FD_CLR(int fd, fd_set *fdset); /* turn off the bit for fd in fdset */
int FD_ISSET(int fd, fd_set *fdset); /* is the bit for fd on in fdset ? */

例如:

fd_set rset;
FD_ZERO(&rset); /* initialize the set: all bits off */
FD_SET(1, &rset); /* turn on bit for fd 1 */
FD_SET(4, &rset); /* turn on bit for fd 4 */
FD_SET(5, &rset); /* turn on bit for fd 5 */

如果把这三个参数都设置为空,相当于获得了一个比 sleep 更为精准的定时器。

maxfdp1

(max file descriptor plus 1)

最大描述符增加 1,这样我们就不用遍历完所有的位了

返回信息

  • 有事件触发,返回事件的数量
  • 定时器到时,返回 0
  • 出错,如打断,返回 -1

就绪条件

套接字准备好读和写的条件:

参考:socket - 描述符就绪条件

套接字异常的条件:

如果一个套接字存在带外数据或仍处于带外标记,那么它有异常要处理(24章中讲解)

最大描述符数

一般不会使用太多的描述符,在一些特殊应用场景下确实需要担心这个问题

限制数量:

  • 早先的系统会限制最大描述符的数量,现在的 Unix 版本往往没有这个限制
  • select 一般会定义一个 FD_SETSIZE 来限制最大描述符的数量,如果想要修改这个值,可能要重新编译内核
  • 有些厂家正在扩展该值,以支持更大的连接数

如果强制修改造成的问题:

  • 描述符数量增大时可能存在扩展性问题

其他方法:

  • 改用 poll 代替 select,可以避免描述符有限的问题

str_cli 改进

void str_cli(FILE *fp, int sockfd)
{
    int maxfdp1;
    fd_set rset;
    char sendline[MAXLINE], recvline[MAXLINE];

    FD_ZERO(&rset);
    for (;;)
    {
        FD_SET(fileno(fp), &rset); // fileno 把标准IO文件指针转换成描述符
        FD_SET(sockfd, &rset);
        maxfdp1 = max(fileno(fp), sockfd) + 1;
        Select(maxfdp1, &rset, NULL, NULL, NULL);

        if (FD_ISSET(sockfd, &rset))
        { /* socket is readable */
            if (Readline(sockfd, recvline, MAXLINE) == 0)
                err_quit("str_cli: server terminated prematurely");
            Fputs(recvline, stdout);
        }

        if (FD_ISSET(fileno(fp), &rset))
        { /* input is readable */
            if (Fgets(sendline, MAXLINE, fp) == NULL)
                return; /* all done */
            Writen(sockfd, sendline, strlen(sendline));
        }
    }
}

问题:半关闭和缓冲

批量输入

在前面的 echo 例子中,交互式地发送消息是很合适的,但是在该例子中,管道容量的利用率并不高,管道上始终只有少量的消息在流动。

假如我们需要传输一个比较大的消息,那么用批量方式可以大大提高管道的利用效率

我们可以利用 Unix 的重定向实现,将文件内容重定向到套接字中,如下图所示:

但是批量输入中有一个问题:对于文件末尾的 EOF,strcli 函数会结束自身执行,并返回到 main 函数中。main 函数也执行完毕,程序随之关闭。

在这种输入方式下,标准输入中的 EOF 并不意味着我们同时也完成了从套接字的读取,可能还有消息还在去或回来的路上。

我们需要的是一种关闭 TCP 连接一半的方法,也就是说,我们想给服务器发送一个 FIN,告诉它我们已经完成了数据的发送,但是仍然保持套接字描述符的打开以便继续读取服务器发来的响应。这将由 shutdown 函数来完成。

缓冲机制

  • 我们的客户端代码 str_cli 会在有数据时调用 fgets 读取,并读入 stdio 的缓冲区中
  • 然而 fgets 只返回其中第一行,其余输入行仍在 stdio 缓冲区中
  • 之后 write 把这一行输入写给服务器
  • 但是尽管缓冲区中还有数据,select 却不会被触发了:因为 select 监听的是我们指定的某一个描述符,而对 stdio 中的各种函数自带的缓冲区毫无察觉
  • 可以把 fgets 之类的函数换成系统调用 read 和 write,从而避免这种问题

Select 和 stdio 一起用所产生的问题

shutdown

之前用的 close 函数的缺陷:

  • close 只是把相关的引用计数 -1,而不能起到立刻关闭的效果,而 shutdown 则是直接关闭(四次挥手)
  • close 终止两个方向的数据传送,shutdown 在这方面的控制更灵活一些

#include <sys/socket.h>

int shutdown(int sockfd, int howto);

howto 的可选值:

含义 说明
SHUT_RD 关闭读 丢弃套接字接收缓冲区数据,之后接收的消息都被确认,然后丢弃
SHUT_WR 关闭写 留在发送缓冲区的数据被发送,后跟 TCP 的正常连接终止序列
SHUT_RDWR 读写都关闭 等同于上面两个一起的作用

str_cli 改进

void str_cli(FILE *fp, int sockfd)
{
    int maxfdp1, stdineof;
    fd_set rset;
    char buf[MAXLINE];
    int n;

    stdineof = 0;
    FD_ZERO(&rset);
    for (;;)
    {
        if (stdineof == 0)
            FD_SET(fileno(fp), &rset);
        FD_SET(sockfd, &rset);
        maxfdp1 = max(fileno(fp), sockfd) + 1;
        Select(maxfdp1, &rset, NULL, NULL, NULL);

        if (FD_ISSET(sockfd, &rset))
        { /* socket is readable */
            if ((n = Read(sockfd, buf, MAXLINE)) == 0)
            {
                if (stdineof == 1)
                    return; /* normal termination */
                else
                    err_quit("str_cli: server terminated prematurely");
            }

            Write(fileno(stdout), buf, n);
        }

        if (FD_ISSET(fileno(fp), &rset))
        { /* input is readable */
            if ((n = Read(fileno(fp), buf, MAXLINE)) == 0)
            {
                stdineof = 1;
                Shutdown(sockfd, SHUT_WR); /* send FIN */
                FD_CLR(fileno(fp), &rset);
                continue;
            }

            Writen(sockfd, buf, n);
        }
    }
}
  • 我们用 Read 和 Write 代替了 Fgets 和 Fputs
  • 用 Shutdown 代替了直接终止

改进:select 代替多进程

多进程会占用大量的系统资源,影响系统的吞吐量,或许我们可以改用 Select 来同时为多个客户提供服务。

改进代码

int main(int argc, char** argv) {
    int i, maxi, maxfd, listenfd, connfd, sockfd;
    int nready, client[FD_SETSIZE];
    ssize_t n;
    fd_set rset, allset;
    char buf[MAXLINE];
    socklen_t clilen;
    struct sockaddr_in cliaddr, servaddr;

    listenfd = Socket(AF_INET, SOCK_STREAM, 0);

    bzero(&servaddr, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
    servaddr.sin_port = htons(SERV_PORT);

    Bind(listenfd, (SA*)&servaddr, sizeof(servaddr));

    Listen(listenfd, LISTENQ);

    maxfd = listenfd; /* initialize */
    maxi = -1;        /* index into client[] array */
    for (i = 0; i < FD_SETSIZE; i++)
        client[i] = -1; /* -1 indicates available entry */
    FD_ZERO(&allset);
    FD_SET(listenfd, &allset);
    /* end fig01 */

    /* include fig02 */
    for (;;) {
        rset = allset; /* structure assignment */
        nready = Select(maxfd + 1, &rset, NULL, NULL, NULL);

        if (FD_ISSET(listenfd, &rset)) { /* new client connection */
            clilen = sizeof(cliaddr);
            connfd = Accept(listenfd, (SA*)&cliaddr, &clilen);
            printf("new client: %s, port %d\n",
                   Inet_ntop(AF_INET, &cliaddr.sin_addr, 4, NULL),
                   ntohs(cliaddr.sin_port));

            for (i = 0; i < FD_SETSIZE; i++) {
                if (client[i] < 0) {
                    client[i] = connfd; /* save descriptor */
                    break;
                }
            }
            if (i == FD_SETSIZE)
                err_quit("too many clients");

            FD_SET(connfd, &allset); /* add new descriptor to set */
            if (connfd > maxfd)
                maxfd = connfd; /* for select */
            if (i > maxi)
                maxi = i; /* max index in client[] array */

            if (--nready <= 0)
                continue; /* no more readable descriptors */
        }

        for (i = 0; i <= maxi; i++) { /* check all clients for data */
            if ((sockfd = client[i]) < 0)
                continue;
            if (FD_ISSET(sockfd, &rset)) {
                if ((n = Read(sockfd, buf, MAXLINE)) == 0) {
                    /*4connection closed by client */
                    Close(sockfd);
                    FD_CLR(sockfd, &allset);
                    client[i] = -1;
                } else
                    Writen(sockfd, buf, n);

                if (--nready <= 0)
                    break; /* no more readable descriptors */
            }
        }
    }
}

拒绝服务攻击

在上述模型中,如果有恶意用户不断建立新的链接,但是不发送有用的信息,则服务会被一直阻塞,耗费服务器的资源。

当一个服务器在处理多个客户时,它绝对不能阻塞于只与单个客户相关的某个函数调用。否则,可能会遭受 DoS 型攻击。

可能的解决方法包括:

  1. 使用非阻塞 IO
  2. 让每个客户由单独的控制线程提供服务
  3. 对 IO 操作设置一个超时

pselect

#include <sys/socket.h>
#include <signal.h>
#include <time.h>

int pselect(
  int maxfdp1,
  fd_set *readset, fd_set *writeset, fd_set *exceptset,
  const struct timespec * timeout,
  const sigset_t *sigmask
);

select 对比的参数解释:

  • 使用 timespec 代替 timeval ,最高精确到纳秒

    struct timespec {
    time_t tv_sec;
    long tv_nsec;
    }
    
  • pselect 增加了第六个参数:一个指向信号掩码的指针。该

poll

函数介绍

事件如下:

poll 程序修改

略,参见原文

posted @ 2022-05-29 08:31  樵仙  阅读(49)  评论(0编辑  收藏  举报