UNP-UNIX网络编程 第十四章:高级I/O函数

(一)概述

首先是在I/.O操作上设置超时,三种方法;
read和write这两个函数的三个变体:
recv和send运行通过第四个参数从进程到内核传递标志;
readvhe和writev允许指定往其中输入数据或从其中输出数据的缓冲区向量;
recvmsg和sendmsg结合了其他I/O函数的所有特性,并具备接收和发送辅助数据的新能力。

(二)套接字超时

1、套接字的I/O操作上设置超时操作:

1)使用信号处理函数alarm,不过这样会涉及到信号处理函数的问题,同时还有可能会引起程序中其他alarm函数的处理,
2)使用select函数,阻塞等待IO。
3)使用比较新颖的超时套接字选项SO_RCVTIMEO和SO_SENDTIMEO,属于套接字选项中的内容。并非所有实现都支持这两个套接字选项。
以上这三个技术都适用于输入和输出操作(read、write及其注入recvfrom、sendto之类的变体)。但是TCP内置的connect函数超时默认为75s。
select可用来在connect函数上设置超时的先决条件是相应的套接字处于非阻塞模式,而上述的两个套接字选项对connect并不适用。
前两种技术适用于任何技术,第三个技术适用于套接字描述符。(通过例子细细揣摩其中的意思)。

2、 使用上述三种技术设置超时

1) 使用SIGALRM为connect设置超时

这段代码考虑到了如果在这个函数体内已经存在报警函数的问题,如果存在报警,alarm(sec)返回值为当前报警的剩余时间,否则alarm的返回值为0。同时代码还透漏了一个消息:signal函数的返回值为一个函数指针,就是信号处理函数的函数指针,记录这个函数,如果已经存在报警,那么在函数的最后还需要恢复原来的信号处理函数。

int connect_timeo(int sockfd, const SA *saptr, socklen_t salen, int nsec)
{
    Sigfunc *sigfunc;//信号处理函数
    int     n;
    //注册信号处理函数
    //connect_alarm,收到SIGALRM信号return
    sigfunc = Signal(SIGALRM, connect_alarm);//signal返回connect_alarm函数指针
    //如果alarm返回不为0说明之前设置过报警时钟
    if (alarm(nsec) != 0)
        err_msg("connect_timeo: alarm was already set");

    if ( (n = connect(sockfd, saptr, salen)) < 0) //connect函数
    {
        close(sockfd);
        //这里if是防止如果调用被中断(EINTR错误),修改ETIMEOUT
        if (errno == EINTR)
            errno = ETIMEDOUT;
    }
    //关闭alarm
    alarm(0);
    //恢复原来的信号处理函数
    Signal(SIGALRM, sigfunc);
    return(n);
}
static void
connect_alarm(int signo)
{
    return;
}

注:signal函数return的是previous handler,也就是说上述sigfunc保存了previous handler,最后Signal(SIGALRM,sigfunc),实际上是恢复previous handler
可以直接试一试这个函数,在intro/daytimetcpcli.c中直接用connect_timeo替换connect,并且设置超时为3秒:

if (connect_timeo(sockfd, (SA *) &servaddr, sizeof(servaddr),3) < 0)
    err_sys("connect error,timeout!");

使用SIGALRM为recvfrom()设置超时

#include    "unp.h"
static void sig_alrm(int);
void
dg_cli(FILE *fp, int sockfd, const SA *pservaddr, socklen_t servlen)
{
    int n;
    char    sendline[MAXLINE], recvline[MAXLINE + 1];

    Signal(SIGALRM, sig_alrm);//返回sig_alarm函数指针

    while (Fgets(sendline, MAXLINE, fp) != NULL) 
    {

        Sendto(sockfd, sendline, strlen(sendline), 0, pservaddr, servlen);

        alarm(5);//recvfrom之前设置5s超时
        if ( (n = recvfrom(sockfd, recvline, MAXLINE, 0, NULL, NULL)) < 0)
        {
            if (errno == EINTR)//调用被信号处理函数中断
                fprintf(stderr, "socket timeout\n");
            else
                err_sys("recvfrom error");
        } 
        else  //读到来自服务器的文本
        {
            alarm(0);//关掉报警器时钟
            recvline[n] = 0;    /* null terminate */
            Fputs(recvline, stdout);
        }
    }
}
static void
sig_alrm(int signo)//中断被阻塞的recvfrom()
{
    return; /* just interrupt the recvfrom() */
}

这个方法与上述类似。过程就是注册信号处理函数(超时直接return),并在recvfrom之前调用alarm。

2) 使用select函数为recvfrom设置超时

//等待一个描述符最多在指定的秒数变为可读
int readable_timeo(int fd,int sec)
{
    fd_set rset;
    struct timeval tv;
    FD_ZERO(&rset);
    FD_SET(fd,&rset);

    tv.tv_sec =sec;
    tv.tv_usec =0;
    return (select(fd+1,&rset,NULL,NULL,&tv));
}
void dg_cli(FILE*fp,int sockfd,const struct sockaddr*pservaddr,socklen_t servlen)
{
    int n;
    char sendbuff[MAXLEN];
    char recvbuff[MAXLEN+1];
    while(fgets(sendbuff,MAXLEN,fp)!=NULL)
    {
        sendto(sockfd,sendbuff,strlen(sendbuff),0,pservaddr,servlen);
        if(readable_timeo(sockfd,5)==0)//设置超时等待5秒
        {
            fprintf(stderr,"socket timeout\n");
        }
        else//readable_timeo返回正值的时候
        {
                if((n=recvfrom(sockfd,recvbuff,MAXLEN,0,NULL,NULL))<=0)
                {
                    printf("recvfrom error\r\n");
                    return ;
                }
                recvbuff[n]='\0';
                fputs(recvbuff,stdout);
        }
    }
}

fgets一段文本后,等待5秒钟,终端打印出“socket timeout”消息,select超时。
直到readable_timeo()告诉描述符已经变为可读我们才调用recvfrom

3)使用套接字选项为recvfrom设置超时(仅用于套接字读/写)

SO_RCVTIMEO(读)SO_SENTIMEO(写)
优势:一次性设置选项,前面两个方法要求在 欲设置时间限制的每个操作 发生之前做些工作

void dg_cli(FILE*fp,int sockfd,const struct sockaddr*pservaddr,socklen_t servlen)
{
    int n;
    char sendbuff[MAXLEN];
    char recvbuff[MAXLEN+1];
    struct timeval tv;
    //第四个参数是指向timeval结构的一个指针,填入了期望的超时值
    tv.tv_sec=5;
    tv.tv_usec=0;
    Setsockopt(sockfd,SOL_SOCKET,SO_RCVTIMEO,&tv,sizeof(tv))

    while(fgets(sendbuff,MAXLEN,fp)!=NULL)
    {
        sendto(sockfd,sendbuff,strlen(sendbuff),0,pservaddr,servlen);
        n=recvfrom(sockfd,recvbuff,MAXLEN,0,NULL,NULL);
        if(n<0)
        {
            if(errno == EWOULDBLOCK)//如果函数超时返回EWOULDBLOCK错误
            {
                fprintf(stderr,"socket timeout\r\n");
                continue;
            }
            else
                fprintf(stderr,"recvfrom error\r\n");
        }
        recvline[n] = 0;
        fputs(recvbuff,stdout);
    }
}

(三)recv和send函数:跟read/write类似,不过需要额外的参数

#include <sys/socket.h>
ssize_t recv(int sockfd, void *buff,       size_t nbytes, int flags);
ssize_t send(int sockfd, const void *buff, size_t nbytes, int flags);
//返回:成功返回读入或写出的字节数,出错为-1

(四)readv和writev函数:跟read/write类似,不过允许 单个系统调用 读入到或写出自 一个或多个缓冲区。

这些操作被称作分散读和集中写。来自读操作的输入数据被分散到多个应用缓冲区,来自多个应用缓冲区的输出数据被集中提供给单个写操作。

#include <sys/uio.h>
ssize_t readv (int filedes, const struct iovec *iov ,int iovcnt);
ssize_t writev(int filedes, const struct iovec *iov ,int iovcnt);

第二个参数指向某个iovec结构数组的一个指针,可以设置缓冲区的起始地址和大小。
另外,这两个操作可以应用于任何描述符,而不是仅限于套接字。另外,writev是一个原子操作,意味着对于一个基于记录的协议(UDP协议)而言,一次调用只产生单个UDP数据报。

(五)recvmsg和sendmsg函数(我们可以把所有的read,readv,recv,recvfrom换成recvmsg,各种输出换成sendmsg)

这两个IO函数是最通用的IO函数

#include <sys/socket.h>
ssize_t recvmsg(int sockfd ,struct msghdr *msg, int flags);
ssize_t sendmsg(int sockfd ,struct msghdr *msg, int flags);
//返回:成功返回读入或写出的字节数,出错为-1
大部分参数封装到了msghdr,所以通用:
struct msghdr{
    void *msg_name; //指向套接字结构体sockaddr_in,用于UDP协议
    socklen_t msg_namelen;//长度16个字节
    //指定输入或输出缓冲区数组(起始地址,长度等)
    struct iovec * msg_iov;
    int msg_iovlen;//3因为分配了3个iovec结构构成的数组。
    void * msg_control;
    socketlen_t msg_controllen;
    int msg_flags;
};

(六)辅助数据

通过sendmsg/recvmsg两个函数的msg_control和msg_controllen两个成员发送和接收。辅助数据其实是控制信息。

(七)排队的数据量

如果我们想要在不真正读取数据的前提下知道一个套接字上已用多少数据排队等着读取。可用三个技术实现:
1. 可以使用非阻塞I/O。
2. 如果既想查看数据,又想数据仍然保留在接受队列中以供本进程其他部分稍后读取,那么可以使用MSG_PEEK标志。(需要注意的是:如果使用这个标志来读取套接字上可读数据的大小,在两次调用之间缓冲区可能会增加数据,如果第一次指定使用MSG_PEEK标志,而第二次调用没有指定使用MSG_PEEK标志,那么这两次调用的返回值是一样的,即使在这两次调用之间缓冲区已经增加了数据。)
3. 一些实现支持ioctl的FIONREAD命令。该命令的第三个ioctl参数是指向某个整数的一个指针,内核通过该整数返回的值就是套接字接受队列的当前字节数。

(八)套接字和标准I/O

unix I/O 包括read()和write()以及他们的变形,围绕描述符工作
标准I/O: fdopen:从任意描述符创建一个标准I/O流。fileno:获取一个给定标准I/O流对应的描述符
对于标准I/o,+r意味着读写,因为TCP和UDP是全双工的,但是我们一般不这么做,我们为给定的套接字打开两个标准的I/O流,一个读,一个写。
用fdopen打开标准输入和输出,修改服务器回射函数str_echo

void str_echo(int sockfd)
{
    char line[MAXLEN];
    FILE *fpin=Fdopen(sockfd,"r");//读
    FILE *fpout=Fdopen(sockfd,"w");//写
    char *x;
    while((x=fgets(line,MAXLEN,fpin))!=NULL)
        fputs(line,fpout);
}

fdopen创建两个标准I/O流,一个用于输入,一个用于输出,当运行客户,直到输入EOF,才回射所有文本。

实际发生的步骤如下:
1.键入第一行文本,客户端发送到服务器端;
2.服务器fgets到这段文本,并用fputs回射;
3.文本被回射到标准IO缓冲区,但不把缓冲区内容写到描述符,因为缓冲区未满;
4.直到输入EOF字符,str_cli调用shutdown,客户端发送一个FIN,服务器收取FIN被fgets读入,返回空指针;
5.str_echo函数结束,返回main函数;
6.exit调用标准的I/O清理函数,缓冲区中的内容被输出;
7.同时子进程终止,已连接套接字关闭,TCP四分组终止。

这里就有三个概念了:
1.完全缓冲:缓冲区满、fflush、exit,才发生I/O;
2.行缓冲:换行符、fflush、exit,才发生I/O;
3.不缓冲:每次标准I/O输出函数都发生I/O。

本章介绍几种不同的I/O方式,有些可能实际情况可能用不到,大概了解一蛤即可。
本章用到了signal和alarm函数,有必要稍微了解下他们的机制,比如返回值等等。

小结:

select的几大缺点:
(1)每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
(2)同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
(3)select支持的文件描述符数量太小了,默认是1024

  1. 对于第一个缺点,epoll的解决方案在epoll_ctl函数中。每次注册新的事件到epoll句柄中时(在epoll_ctl中指定EPOLL_CTL_ADD),会把所有的fd拷贝进内核,而不是在epoll_wait的时候重复拷贝。epoll保证了每个fd在整个过程中只会拷贝一次。

  2. 对于第三个缺点,epoll没有这个限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于2048,举个例子,在1GB内存的机器上大约是10万左右,具体数目可以cat /proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大。

总结:

(1)select,poll实现需要自己不断轮询所有fd集合,直到设备就绪,期间可能要睡眠和唤醒多次交替。而epoll其实也需要调用epoll_wait不断轮询就绪链表,期间也可能多次睡眠和唤醒交替,但是它是设备就绪时,调用回调函数,把就绪fd放入就绪链表中,并唤醒在epoll_wait中进入睡眠的进程。虽然都要睡眠和交替,但是select和poll在“醒着”的时候要遍历整个fd集合,而epoll在“醒着”的时候只要判断一下就绪链表是否为空就行了,这节省了大量的CPU时间。这就是回调机制带来的性能提升。

(2)select,poll每次调用都要把fd集合从用户态往内核态拷贝一次,并且要把current往设备等待队列中挂一次,而epoll只要一次拷贝,而且把current往等待队列上挂也只挂一次(在epoll_wait的开始,注意这里的等待队列并不是设备等待队列,只是一个epoll内部定义的等待队列)。这也能节省不少的开销。
epoll的工作模式

令人高兴的是,2.6内核的epoll比其2.5开发版本的/dev/epoll简洁了许多,所以,大部分情况下,强大的东西往往是简单的。唯一有点麻烦是epoll有2种工作方式:LT和ET。

LT(level triggered)是缺省的工作方式,并且同时支持block和no-block socket.在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你 的,所以,这种模式编程出错误可能性要小一点。传统的select/poll都是这种模型的代表.

epoll只有epoll_create,epoll_ctl,epoll_wait 3个系统调用,具体用法请参考http://www.xmailserver.org/linux-patches/nio-improve.html ,在http://www.kegel.com/rn/也有一个完整的例子,大家一看就知道如何使用了

Leader/follower模式线程pool实现,以及和epoll的配合。

epoll的使用方法:
首先通过create_epoll(int maxfds)来创建一个epoll的句柄,其中maxfds为你epoll所支持的最大句柄数。这个函数会返回一个新的epoll句柄,之后的所有操作 将通过这个句柄来进行操作。在用完之后,记得用close()来关闭这个创建出来的epoll句柄。之后在你的网络主循环里面,每一帧的调用
epoll_wait(int epfd, epoll_event events, int max events, int timeout)
来查询所有的网络接口,看哪一个可以读,哪一个可以写了。基本的语法为:

nfds = epoll_wait(kdpfd, events, maxevents, -1);

其中kdpfd为用epoll_create创建之后的句柄,events是一个epoll_event*的指针,当epoll_wait这个函数操作成功 之后,epoll_events里面将储存所有的读写事件。max_events是当前需要监听的所有socket句柄数。最后一个timeout是 epoll_wait的超时,为0的时候表示马上返回,为-1的时候表示一直等下去,直到有事件范围,为任意正整数的时候表示等这么长的时间,如果一直没有事件,则范围。一般如果网络主循环是单独的线程的话,可以用-1来等,这样可以保证一些效率,如果是和主逻辑在同一个线程的话,则可以用0来保证主循环的效率。

epoll_wait范围之后应该是一个循环,遍利所有的事件:

for(n = 0; n < nfds; ++n) {
  if(events[n].data.fd == listener) { //如果是主socket的事件的话,则表示有新连接进入了,进行新连接的处理。
    client = accept(listener, (struct sockaddr *) &local,  &addrlen);
    if(client < 0){
      perror("accept");
      continue;
    }
    setnonblocking(client); // 将新连接置于非阻塞模式
    ev.events = EPOLLIN | EPOLLET; // 并且将新连接也加入EPOLL的监听队列。
                                   //注意,这里的参数EPOLLIN | EPOLLET并没有设置对写socket的监听,
                                   //如果有写操作的话,这个时候epoll是不会返回事件的,
                                   //如果要对写操作也监听的话,应该是EPOLLIN | EPOLLOUT | EPOLLET
    ev.data.fd = client;
    if (epoll_ctl(kdpfd, EPOLL_CTL_ADD, client, &ev) < 0) 
    { // 设置好event之后,将这个新的event通过epoll_ctl加入到epoll的监听队列里面,这里用EPOLL_CTL_ADD
     //来加一个新的 epoll事件,通过EPOLL_CTL_DEL来减少一个epoll事件,通过EPOLL_CTL_MOD来改变一个事件的
     //监听方式。
      fprintf(stderr, "epoll set insertion error: fd=%d0", client);
      return -1;
    }
  }  else // 如果不是主socket的事件的话,则代表是一个用户socket的事件,
        do_use_fd(events[n].data.fd);
          //则来处理这个用户socket的事情,比如说read(fd,xxx)之类的,或者一些其他的处理   
}

对,epoll的操作就这么简单,总共不过4个API:epoll_create, epoll_ctl, epoll_wait和close。

16位源都口号,16为目的端口号用于寻找发送端和接收端的应用进程,加上IP首部的源端IP及终端IP,唯一的确认一个TCP连接。
32位序号:标识发送的数据字节流,标识在这个报文段中的第一个数据字节,2^3 - 1后重新从0开始。包含该主机选择的连接的ISN(Initial Sequence Number),要发送的第一个数据字节序号为ISN+1.
32位确认序号:ACK为1时有效,上次成功收到的数据字节序号+1(如接收到的为1024--2048,则返回2049)。
4位首部长度:首部中32bits字的数目,TCP最多有60字节的长度,除去任选字段,正常为20字节。
6bits:URG紧急指针;ACK确认序号有效;PSH接收方应尽快将此报文段交给应用层;RST重建连接;SYN同步序号,用来发起一个新连接;FIN发端完成发送任务。
16位窗口大小:TCP流量控制,字节数,起始于确认序列号指明的值,接收端期望收到的字节,最大为65535.
16位检验和:包括计算TCP首部和数据综合的二进制反码和检验和。
16位紧急指针:URG为1时有效,正向的偏移量,加上序号字段值表示最后一个字节的序号。
可选字段:例:MSS.

猜你喜欢

转载自blog.csdn.net/qiangzhenyi1207/article/details/79079246