UNP_6_IO复用

Posted on Mar 6, 2020

6.1 概述

  上一章的问题在于TCP客户端同时处理两个套接字,标准输入和TCP套接字。在客户阻塞于fgets调用期间,服务器进程会被杀死。服务器TCP虽然正确的给客户TCP发送了一个FIN,但是客户进程既然阻塞于标准输入读入的过程,他将看不到这个EOF。这样的进程需要一种预先告知内核的能力,使得内核一旦发现进程指定的一个或多个I/O条件接续,就通知进程,这被称为IO复用

IO服用的典型使用场合

  1. 当客户处理多个描述符(通常是交互式输入和网络套接字),必须使用IO复用。这是我们上一章强调的
  2. 一个客户同时处理多个套接字是可能的,但较为少见。可以使用select等方式来进行处理
  3. 如果一个TCP服务器既要处理监听套接字,又要处理已连接套接字。一般要使用IO复用
  4. 如果一个服务器既要处理TCP又要处理UDP,一般就要使用IO复用
  5. 如果一个服务器要处理多个服务或者多个协议,一般要使用IO复用

I/O 复用并非只限于网络编程,许多应用程序也使用这项技术(eg:redis 的事件处理)

6.2 I/O模型

Unix下可用的I/O模型有五种

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

一个输入操作通常包括两个不同的阶段:

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

对于套接字来说:

  1. 通常是等待数据从网络中到达,当到达后,被复制到内核的缓冲区。
  2. 是把数据从内核缓冲区复制到应用进程缓冲区。

阻塞式I/O模型

    在阻塞式IO模型中,所有套接字都是阻塞的(在内核空间中等待数据),在该函数开始调用以及返回的时间内。

非阻塞式IO模型

    进程把一个套接字设置成非阻塞是在通知内核:当请求的IO操作非要把本进程投入睡眠中才可完成时,不要把进程投入睡眠,而是返回一个错误  
当进程循环进行确认时,称之为轮询,这么做十分耗费 CPU

IO复用模型

    有IO复用了,我们就可以调用 select 或 poll,阻塞在这两个系统调用的某一个上,而不是阻塞在真正的IO系统调用上
    使用了IO复用,阻塞于 select 的调用,等待数据报套接字变为可读。当 select 返回套接字可读这一条件时,我们调用 recvfrom 把所有数据报复制到应用进程缓冲区。
对于单个的描述符来说,Io复用甚至效果还不如阻塞IO,但它在多描述符的等待上会更有优势。  
与 I/O 复用类似的是使用多线程来进行阻塞式 I/O

信号驱动式IO模型

我们可以使用信号,让内核在描述符就绪时发送SIGIO信号通知我们。
这样我们就不会阻塞在数据的等待是,只会阻塞在复制数据的过程上。

异步IO模型

    告知内核某个操作,,并让内核在整个操作完成后进行通知即可,信号驱动式IO告诉我们可以进行一个IO操作,而异步IO告诉我们IO操作已经结束

各种IO模型比较

前四种模型主要区别在第一阶段,第二阶段均相同:在数据从内核复制到调用者的缓冲区期间,进程阻塞于recvfrom调用,相反,异步IO模型在这两个阶段都要处理,从而不同于其余的模型

同步IO和异步IO的对比

同步IO导致请求进程阻塞,直到IO操作完成 异步IO不导致请求阻塞

同步与异步,阻塞与非阻塞

同步与异步:同步与异步是针对应用程序与内核的交互而言的。同步过程中进程触发IO操作并等待或者轮询的去查看IO操作是否完成。异步过程中进程触发IO操作以后,直接返回,做自己的事情,IO交给内核来处理,完成后内核通知进程IO完成,这里是针对第二个阶段的区分

阻塞与非阻塞:应用进程请求I/O操作时,如果数据未准备好,如果请求立即返回就是非阻塞,不立即返回就是阻塞。简单说就是做一件事如果不能立即获得返回,需要等待,就是阻塞,否则就可以理解为非阻塞。这里主要为第一个阶段的区分

虽然吧同步异步,阻塞非阻塞放在一起,但二者无必然联系

6.3 select函数

该函数允许进程指示内核等待多个事件中的任何一个发生,并只有在一个或多个事件发生或经历一段指定的时间后才能唤醒它。

#include <sys/select.h>
#include <sys/time.h>

int select(int maxfdpl, fd_set* readset, fd_set* writeset, fd_set* exceptset, const struct                  timeval* timeout);
//返回:若有就绪描述符则返回其数目,若超时则为0,若出错则为-1

参数解析:

  • timeout:告知内核等待所指定描述符中的任何一个就绪可花多长时间,其timeval结构如下
    struct timeval{
        long tv_sec;    //秒数
        long tv_usec;   //微秒数
    }
  • timeout 参数有以下用法:
    1. 永远等待下去:仅在有一个描述符转备好IO时才返回,此时应置位空指针
    2. 等待一个固定的时间:在有一个描述符准备好IO时返回,但是不能超过该参数所指向的timeval结构中指定的秒数和微秒数
    3. 根本不等待:检查描述符之后立即返回,称为轮询(polling),该指针需要指向一个值为0的结构
  • 中间的三个参数告诉内核我们指定在该描述符上的操作,读,写,异常条件的判断 目前的异常条件只有两个:
    1. 某个套接字的带外数据的到达
    2. 某个已置位分组模式的伪终端存在可从其主端读取的控制状态信息
  • 要想给这三个参数指定描述符集,select使用的是一个整数数组,其中每一位对应一个描述符。

*使用select时一定要注意对最大描述符加一

描述符就绪条件(UNP.p130)

满足以下四个条件之一,一个套接字准备好读

  1. 该套接字接收缓冲区中的数据字节数大于等于套接字接收缓冲区低水位标记的当前大小。对这样的套接字执行读操作不会阻塞并返回一个大于0的值(也就是返回准备好读入的数据)。我们可以使用SO_RCLOWAT套接字选项设置套接字的低水位标记。对于TCP,UDP来说,默认为1.
  2. 该链接的读半部关闭(也就是接受了FIN的TCP连接)。对于这样的操作读操作不会阻塞并返回0(EOF)。
  3. 该套接字是一个监听套接字,并且已完成的连接数不为0.对于这样的套接字accept通常不会阻塞
  4. 其上有一个套接字错误待处理,对于这样的套接字的读操作不阻塞并返回-1(返回一个错误),同时设置error为确切的错误操作

满足以下四个条件之一,一个套接字准备好写

  1. 该套接字发送缓冲区中的可用空间大于等于套接字发送缓冲区低水位标记的当前大小,并且或者该套接字已经连接,或者该套接字不需要连接。如果我们把这样的套接字设置为非阻塞的,写操作不会阻塞一个正值。我们可以使用SO_SNDLOWAT套接字选项设置套接字的低水位标记。对于TCP,UDP来说,默认为2048.
  2. 该连接的写半部关闭。对这样的套接字的写操作将产生SIGPIPE信号。
  3. 使用非阻塞式connect的套接字以建立连接,或者connect已经以失败告终
  4. 其上有一个套接字错误待处理,对于这样的套接字的写操作不阻塞并返回-1(返回一个错误),同时设置error为确切的错误操作

如果一个套接字存在外带数据或者仍处于带外标记,那么它有异常条件待处理

  • 注意:如果某个套接字上发生错误时,它将由select标记为既可读又可写

总结表:

条件 可读吗? 可写吗? 异常吗?
有数据可读
关闭连接的读一半
给监听套接口转备好新链接
有可用于写的空间
关闭连接的写一半
待处理错误
TCP带外数据

select的最大描述符

最开始select设置为进程中最大支持描述符个数。但随着系统的版本的迭代,单进程所支持的描述符个数已经大大增加,但是select并不能随意增加。

6.4 str_cli函数

使用select来重新写str_cli函数,这样服务一旦终止,客户就可以马上得到通知

客户的套接字上三个条件处理如下

  1. 如果对端TCP发送数据,那么该套接字变为可读,并且read返回一个大于0的值(即读入数据的字节数)
  2. 如果对端TCP发送一个FIN(对端进程终止),那么该套接字变为可读,并且read返回0(EOF)
  3. 如果对端TCP发送一个RST(对端主机崩溃并重现启动)那么该套接字变为可读,并且read返回-1,而errno中含有确切的错误代码

使用select来重写该函数

#include "unp.h"
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), &rest);  //fileno函数把标准IO读写文件转换为对应的描述符
        FD_SET(sockfd, &rest);
        maxfdp1 = max(fileno(fp), sockfd) + 1;
        Select(maxfdp1, &rest, NULL, NULL, NULL);

        if(FD_ISSET(sockfd, &rset)) {
            if(Readline(sockfd, recvline, MAXLINE) == 0)
                err_quit("str_cli :...");
            Fputs(recvline, stdout);
        }
        if(FD_ISSET(fileno(fp), &rset)) {
            if(Fgets(sendline, MAXLINE, fp) == NULL)
                return;
            Writen(sockfd, sendline, strlen(sendline));
        }
    }
}

6.5 批量输入

我们最初的版本中,以停-等的方式工作,这对交互式使用来说是合适的:发送一行文本给服务器,然后等待应答。这段时间是往返时间,加服务器处理时间。 本节不完整,查看UNP6.5节。

6.6 shutdown函数

终止网络连接的方式一把使用close函数,但close函数有两个限制,可以使用shutdown避免

  1. close把描述符的引用计数减一,仅在该计数变为0时关闭套接字。使用shutdown可以不管引用计数就激发TCP的正常连接中止序列。
  2. close终止读和写两个方向的数据传送。既然TCP连接是全双工的,有时我们需要告诉对端我们已经完成了数据发送,即使对端仍有数据要发送给我们。这就是前一节遇到的str_cli函数在批量输入时的情况 调用shutdown关闭一半的连接
#include <sys/socket.h>
int shutdown(int sockfd, int howto);

该函数的行为依赖于howto参数的值

  • SHUT_RD(0): 关闭连接读这一半–套接字中不再有数据可接收,而且套接字接收缓冲区中的现有数据都被丢弃。进程不在对这样的套接字调用任何函数。对一个TCP套接字这样调用shutdown函数后,由该套接字接受的来自对端的任何数据都会被确认,然后悄然丢弃
  • SHUT_WR(1): 关闭连接的写部分–对于TCP套接字,这叫半关闭。当前留在套接字发送缓冲区的数据将会被发送,之后进程不能再对改套接字调用任何写函数
  • SHUE_RDWR(2): 连接的读半部和写半部均关闭–这与调用shutdown两次等效:第一次调用指定SHUT_RD,第二次调用指定SHUT_WR

6.7 str_cli函数再修改

这次加入了 select 和 shutdown 函数,前者允许只要服务器关闭就会通知我们,后者允许我们正确的处理批量输入,该版本还废弃了以文本行为中心的代码,从而针对缓冲区操作

#include "unp.h"
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), sockrd) + ;
        // 每次循环都要重新调用 SELECT 来进行判断
        Select(maxfdp1, &rset, NULL, NULL, NULL);
        if(FD_ISSET(sockfd, &rset)){   // socket 可读
            if( (n = Read(sockfd, buf, MAXLINE)) == 0){
                if(stdineof == 1)
                    return;
                else
                    err_quit("");
            }
            Write(fileno(stdout), buf, n);
        }
        if(FD_ISSET(fileno(fp), &rset)){   // input 可读
            if( n = (Read(fileno(fp), buf, MAXLINE)) == 0){
                stdineof = 1;
                Shutdown(sockfd, SHUT_WR);
                FD_CLR(fileno(fp), &rset);
                continue;
            }
            Writen(sockfd, buf, n);
        }
    }

}

6.8 TCP 回射服务器程序(修订)

代码见书,这里不做 copy

拒绝服务型攻击 当一个服务器处理多个客户时,绝对不能阻塞于单个客户的相关函数调用。否则可能导致服务器挂起,拒绝为所有客户端服务。

  • 解决方法
    • 使用非阻塞式 I/O
    • 让每个客户由单独线程提供服务
    • 对 I/O 操作设置一个定时

6.9 pselect 函数

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

int pselect(int macfdp1, fd_set *readset, fd_set *writeset, fd_set* exceptset,
            const struct timespec * timeout, const sigset_t* sigmask)
// 返回:若有已经就绪的描述符,返回数目,超时返回 0,出错返回 -1

相比 select 的变化:

  • pselect 使用 timespec 结构,而不是 timeval 结构。仅仅是第二个时间值从微秒转换为纳秒。
  • pselect 增加了一个参数,指向信号掩码的指针。

6.10 poll 函数

poll 函数提供的功能与 select 类似。但在处理流设备时,能够提供额外的信息

#include<poll.h>
int poll(struct pollfd* fdarray, unsigned long nfds, int timeout)
// 返回:若有已经就绪的描述符,返回数目,超时返回 0,出错返回 -1

struct pollfd{
    int fd;
    short events;
    short revents;
}

fdarray 时指向 pollfd 结构体数组的指针,用来保存一系列的描述符,返回后判断相应 pollfd 结构体中的 revents 来确定是否有何种类型的输出即可。

6.11 TCP 回射服务器程序(修改)

使用 poll 代替 select 来重写服务器程序
代码见书,这里不做 copy

小结

unix 有五种不同的 I/O 模型:

  • 阻塞式 I/O 模型
  • 非阻塞式 I/O 模型
  • I/O 复用模型
  • 信号驱动式 I/O 模型
  • 异步 I/O 模型

默认是阻塞式 I/O 模型, I/O 复用模型常使用 select,但 epoll 是较好的选择