概述
本章中我们需要学习如何为I/O操作设置超时、以及如何使用read write的若干个变体、还有如何确定缓冲区中的数据量
套接字超时
总的来说 ,有什么方法 ?
- 调用alarm,会在指定时间满了之后引发SIGALRM信号。这个方法缺点在于,需要涉及到信号处理,而信号处理在不同实现上有差异,而且可能干扰进程现有的alarm调用
- 在select上阻塞等待I/O,借用select内置的时间限制,来代替 直接阻塞在read或者write调用上
- 使用SO_RCVTIMEO和SO_SNDTIMEO套接字选项,问题在于不是所有的实现都支持
值得注意的是,对于connect而言,只能使用第一种方法,select只能作用于非阻塞connect,而SO_RCVTIMEO和SO_SNDTIMEO不作用于connect
使用SIGALRM设置超时
#include "../unp.h"
static void connect_alarm(int);
int connect_timeo(int sockfd, const struct sockaddr *saptr, socklen_t salen, int nsec)
{
Sigfunc *sigfunc;
int n;
sigfunc = Signal(SIGALRM, connect_alarm); //返回值是原来的信号处理函数,可以用于复原
if (alarm(nsec) != 0)//如果alarm返回值不为0,则是上一个报警时钟的剩余秒数
err_msg("connect_timeo: alarm already set");
if ((n = connect(sockfd, saptr, salen)) < 0)
{
close(sockfd);
if (errno == EINTR) //如果被打断,那就是被SIGALRM触发的,所以就是说明了已经超时
errno = ETIMEDOUT;
}
alarm(0); //关闭alarm
Signal(SIGALRM, sigfunc); //复原
return (n);
}
static void connect_alarm(int signo)
{
return;//只是简单的打断
}
通过上述的代码, 我们可以简单的实现一个可超时的connect,注意的是,一般而言,connect自带的超时是75s,我们可以用该方式缩短该时间,但是不能延长(如果设置的nsec大于75,那么connect还是会75s的时候超时)。
该例子问题在于,在多线程环境下正确使用信号是困难的,因此最好只在未线程化或者单线程化程序中使用。
使用select设置超时
#include "../unp.h"
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));
}
使用SO_RCVTIMEO套接字选项设置超时
#include "unp.h"
void dg_cli(FILE *fp, int sockfd, const struct sockaddr *pservaddr, socklen_t servlen)
{
int n;
char sendline[MAXLINE], recvline[MAXLINE + 1];
struct timeval tv;
tv.tv_sec = 5;
tv.tv_usec = 0;
Setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
while (Fgets(sendline, MAXLINE, fp) != NULL)
{
Sendto(sockfd, sendline, strlen(sendline), 0, pservaddr, servlen);
n = recvfrom(sockfd, recvline, MAXLINE, 0, NULL, NULL);
if (n < 0)
{
if (errno == EWOULDBLOCK)//代表着超时了
{
fprintf(stderr, "socket timeout\n");
continue;
}
else
err_sys("recvfrom error");
}
recvline[n]=0;
Fputs(recvline,stdout);
}
}
该方法优势在于,只需要设置SO_RCVTIMEO和SO_SNDTIMEO即可,不过不能用于connect
recv和send函数
函数定义
ssize_t send(int sockfd, void *buff, size_t nbytes, int flags);
ssize_t recv(int sockfd, void *buff, size_t nbytes, int flags);
函数解释
send和recv与之前的read、write很像 ,但是提供了第四个参数就是flags
flags可以选下面的常值的逻辑和
flags | 说明 | recv | send |
---|---|---|---|
MSG_DONTROUTE | 该选项和之前的SO_DONTROUTE基本类似,只不过该选项仅仅作用于单次输出。其作用是告知内核目的主机在某个直接连接的本地网络上,无需执行路由表查找 | ✔️ | |
MSG_DONTWAIT | 本标志可以在单次I/O操作中设置为非阻塞 | ✔️ | ✔️ |
MSG_OOB | 对于send,指示接下来将会发送带外数据,对于recv,指示接下来要读取的是带外数据 | ✔️ | ✔️ |
适用于recv和recvfrom,可以让我们查看已经可以读取的数据,但是不会在recv或者recvfrom返回之后丢弃(类似于peek和pop的区别) | ✔️ | ||
MSG_WAITALL | 在4.3BSD Reno中引入,告知内核不要在未读取请求数目的字节前返回(从而我们可以放弃之前的readn函数)当然,如果(1)捕获了一个信号(2)连接被终止(3)套接字发生了错误,那么仍然可能返回比请求字节数要少的数据 | ✔️ |
readv和writev函数
函数允许单个系统调用可以读入或者写出到一个或多个缓冲区,分别称之为分散读和集中写
函数定义
ssize_t readv(int filedes, const struct iovec *iov, int iovcnt);
ssize_t writev(int filedes, const struct iovec *iov, int iovcnt);
struct iovec {
void * iov_base; /* [XSI] Base address of I/O memory region */
size_t iov_len; /* [XSI] Size of region iov_base points to */
};
具体解释
iov长度受限制,一般是1024,具体由IOV_MAX指定(在mac os中就是1024)
writev是单个原子操作,这意味着它只会发出一个数据报(比方说对于UDP而言)
iovec中的iov_base是缓冲区本身,iov_len则是读取长度
recvmsg和sendmsg函数
这两个是最通用的函数,任何其它调用都可以替换成这两个函数调用
函数定义
ssize_t recvmsg(int sockfd, struct msghdr *msg, int flags);
ssize_t sendmsg(int sockfd, const struct msghdr *msg, int flags);
struct msghdr {
void *msg_name; //用于套接字未连接的情形,类似于sendto和recvfrom的第五个参数,存储地址结构,对于TCP和已连接UDP,可以设置为NULL
socklen_t msg_namelen; //用于套接字未连接的情形,类似于sendto和recvfrom的第六个参数,注意对于recvmsg来说,该值为值-结果参数
struct iovec *msg_iov; //类似readv和writev的第二个参数
int msg_iovlen; //类似readv和writev的第三个参数
void *msg_control; //辅助数据
socklen_t msg_controllen; //辅助数据长度
int msg_flags; //只有recvmsg会使用,recvmsg调用的时候,会用flags复制到msg_flags,然后内核会根据recvmsg结果更新msg_flags值。sendmsg会直接使用flags的值
};
具体解释
flags具体有
标志 | send、sendto或者sendmsg函数可用 | recv、recvfrom或者recvmsg可用 | 内核可以返回告知 | 解释 |
---|---|---|---|---|
MSG_DONTROUTE | ✔️ | |||
MSG_DONTWAIT | ✔️ | ✔️ | ||
MSG_PEEK | ✔️ | |||
MSG_WAITALL | ✔️ | |||
MSG_EOR | ✔️ | ✔️ | 返回数据结束一个逻辑记录。TCP不使用该标记,因为TCP是字节流协议 | |
MSG_OOB | ✔️ | ✔️ | ✔️ | 不用于TCP带外数据返回,而是用于其他协议族(OSI协议) |
MSG_BCAST | ✔️ | 返回条件是本数据报作为链路层广播收取或者其目的IP地址是一个广播地址。用于判定UDP数据报是否发往一个广播地址 | ||
MSG_MCAST | ✔️ | 返回条件是本数据报作为链路层多播收取 | ||
MSG_TRUNC | ✔️ | 返回条件是内核预备返回的数据超过进程实现分配的空间(所有iov_len成员之和),即该数据报被截断 | ||
MSG_CTRUNC | ✔️ | 返回条件是本数据报的辅助数据被截断(超过预定分配空间) | ||
MSG_NOTIFICATION | ✔️ | 由SCTP接收者返回,只是读入的消息是一个时间通知而不是数据消息 |
具体实例
传入的结构体msghdr如下图所示
如果从192.6.38.100端口2000到达一个170字节的UDP数据报,目的IP地址是206.168.112.96,那么调用recvmsg后返回的msghdr结构中返回的是
其中变更的地方有
- msg_name指向的缓冲区被填充了套接字地址结构,包含了数据报的源IP地址和端口号
- msg_namelen成员被更新为存放在msg_name缓冲区中数据量,这里由于前后都是16,所以没有发生变更
- 数据报的前100字节在第一个缓冲区,中60字节在第二个缓冲区,最后10个字节在第三个缓冲区,msg_iovlen表示该数据报的大小
- msg_control指向的缓冲区被一个cmsghdr结构体填充,这里面由于我们开启了IP_RECVDSTADDR套接字选项所以我们可以获取目的IP地址
- msg_controllen被更新为所存放辅助数据的实际数据量,这里就是16
- msg_flags也被更新,不过这里没有要返回的flag,所以还是0
辅助数据
辅助数据另一个名称就是控制信息,可以通过msg_control和msg_controllen两个成员来发送和接收。
辅助数据的结构
struct cmsghdr {
socklen_t cmsg_len; /* [XSI] data byte count, including hdr */
int cmsg_level; /* [XSI] originating protocol */
int cmsg_type; /* [XSI] protocol-specific type */
/* followed by unsigned char cmsg_data[]; */
};
在实际实现中,很多实现允许在一个缓冲区内加入多个辅助数据,前提是用填充数据(填充数据会在cmsg_type和数据之间,以及两个cmsghdr之间出现)
辅助数据的作用
协议 | cmsg_level | cmsg_type | 说明 |
---|---|---|---|
IPv4 | IPPROTO_IP | IP_RECVDSTADDR | 数据报的接收目的地址IP |
IPv4 | IPPROTO_IP | IP_RECVIF | 数据报接收接口索引 |
除此之外还有涉及到IPv6和Unix域的控制信息
辅助数据的使用
为了屏蔽填充字节等实现细节,我们可以用下面的宏来操作辅助数据
宏 | 作用 |
---|---|
struct cmsghdr *CMSG_FIRSTHDR(struct msghdr *ptr) |
返回第一个辅助数据,如果没有就返回NULL |
struct cmsghdr *CMSG_NXTHDR(struct msghdr *ptr,struct cmsghdr *cmsgptr) |
返回下一个,如果没有就返回NULL |
unsigned char *CMSG_DATA(struct cmsghdr*cmsgptr) |
返回cmsghdr相关的数据的第一个字节的指针 |
unsigned int CMSG_LEN(unsigned int length) |
给定数据量下存放在cmsg_len的值 |
unsigned int CMSG_SPACE(unsigned int length) |
给定数据两下一个辅助数据对象总的大小 |
如何获知已排队的数据量
-
如果只是想避免读操作阻塞(因为有其他操作需要完成),我们可以用非阻塞I/O
-
如果我们想读取数据,但是又希望数据留在队列以便进程其他部分读取,那么我们可以使用MSG_PEEK标志。如果我们与此同时并不希望读操作阻塞,那么我们可以同时使用MSG_PEEK和MSG_DONTWAIT标志
值得注意的是,对于一个TCP套接字而言,两次相继的recv调用是可以返回不同的长度的,其原因在于两次相继recv操作之间可能新增了一些数据。而对于UDP套接字而言,如果不考虑中间被进程其他部分读取的可能,两次recv之间是不会有差异的,因为UDP读取是以数据报为单位
-
某一些实现支持ioctl函数的FIONREAD命令,该命令第三个参数是指向一个整数的指针,调用后该整数就是套接字接收队列的当前字节数总和,对于UDP套接字而言则包括了所有已排队的数据报。值得注意的是,Berkeley的实现中,UDP套接字返回的值包括了一个套接字地址结构的空间,包括了IP地址和端口号
套接字和标准I/O
标准I/O可以帮助我们解决一些问题,如自动缓冲,但是也会带来一些新的问题。
一般来说,调用fdopen我们可以从任意一个描述符创建一个标准I/O流,同样,调用fileno函数,我们可以从标准I/O流获取对应描述符
TCP和UDP套接字应用标准I/O的时候有什么注意点?
TCP和UDP都是全双工的,标准I/O虽然也可以是全双工(用r+打开就支持读写)。但是这种全双工下,我们必须在调用一个输出函数之后调用fflush, fseek, fsetpos或者rewind调用才可以调用一个输入函数。同样的,调用一个输入函数之后,我们也必须插入一个fseek,fsetpos或者rewind才能调用输出函数(除非输入函数遇到了EOF)。问题在于fseek、fsetpos和rewind三个函数都会调用lseek,而lseek在套接字上只会失败
因此一个比较简单的方法,就是一个套接字上打开两个标准I/O流,一个用于读,一个用于写
标准I/O的缓冲类型和函数对应
标准I/O执行以下三种缓冲
(1) 完全缓冲,意味着只在出现下列情况时才发生I/O:缓冲区满,显式调用fflush,或者进程调用exit终止自身。缓冲区通常大小是8192字节
(2)行缓冲,意味着只在出现下列情况时候发生I/O:碰到一个换行符,进程调用fflush,或者进程调用exit终止自身
(3)不缓冲,每次调用标准I/O函数都会发生I/O
而标准I/O函数库的大多数Unix实现是以下规则:
- 标准错误输出总是不缓冲
- 标准输入和标准输出完全缓冲,除非他们指代终端设备(此时他们行缓冲)
- 所有其他I/O流都是完全缓冲,除非指代终端设备(此时行缓冲)
使用标准I/O的str_echo函数
利用标准I/O,我们可以将回射服务器的str_echo函数改写成下面所属样子
#include "unp.h"
void str_echo(int sockfd)
{
char line[MAXLINE];
FILE *fpin,*fpout;
fpind = Fdopen(sockfd,"r");
fpout = Fdopen(sockfd,"w");
while (Fgets(line,MAXLINE,fpin)!=NULL)
Fputs(line,fpout);
}
如果我们调用原先的客户端,然后按照下面的方式输入,我们会发现结果有点奇怪
tcpcli02 ip地址
hello,world 键入此行,但是并没有返回
and hi 键入此行,但是并没有返回
hello?? 键入此行,但是并没有返回
^p 键入EOF字符
hello,world 至此才返回
and hi
hello??
我们会发现,直到键入了EOF,之前我们输入值才得到了返回,究其原因,在于标准I/O的缓冲。服务器虽然通过调用fputs输出了内容,但是内容一致存储在缓冲中,直到我们发送了EOF,触发了客户端发送FIN报文,服务端接收到该FIN报文之后fgets返回NULL退出,子进程调用exit终止,而exit会调用标准I/O清理函数,我们之前调用fputs存储在输出缓冲区的内容此时才被输出。
显然上面的问题就在于标准输出是完全缓冲的,解决办法有两个:1. 调用setvbuf迫使该输出流为行缓冲 2.每次调用fputs之后调用fflush强制输出
但是这两个解决办法实际应用的时候都容易犯错,与Nagle算法的交互也都可能有问题。
所以最好的办法是彻底避免在套接字上使用标准I/O函数库,并且在缓冲区(而不是文本行)上执行操作
高级轮询技术
/dev/poll接口
Solaris名为/dev/poll的特殊文件允许我们不需要传递待查询文件描述符(而不用每次调用都设置)
使用该接口,需要我们初始化pollfd数组(就是poll函数使用的结构),调用write往/dev/poll设备写入该结构数组,然后调用ioctl的DP_POLL命令阻塞自身以等待事件的发生,传递给ioctl的结构如下
struct dvpoll{
struct pollfd* dp_fds;
int dp_nfds;
int dp_timeout;
}
其中dp_fds就是指定了一个缓冲区,ioctl返回时候会存放一个pollfd结构数组。dp_nfds指定了该缓冲区大小。ioctl会阻塞到任何一个被轮询描述符上发生了所关心的事件或者流逝时间超过了dp_timeout指定的毫秒数为止。dp_timeout指定为0会立刻返回(非阻塞),-1表示不设置超时
相关代码
#include "unp.h"
#include <sys/devpoll.h> //注意mac不支持
void str_cli(FILE *fp,int sockfd)
{
int stdineof;
char buf[MAXLINE];
int n;
int wfs;
struct pollfd pollfd[2];
struct dvpoll dopoll;
int i;
int result;
wfd=Open("/dev/poll",O_RDWR,0);
pollfd[0].fd=fileno(fp);
pollfd[0].events=POLLIN;
pollfd[0].revents=0;
pollfd[1].fd=sockfd;
pollfd[1].events=POLLIN;
pollfd[1].revents=0;
Write(wfd,pollfd,sizeof(struct pollfd)*2);
stdineof=0;
for (;;){
dopoll.dp_timeout=-1; //阻塞
dopoll.dp_nfds=2; //因为我们只监听两个套接字
dopoll.dp_fds=pollfd;
result=Ioctl(wfd,DP_POLL,&dopoll);//返回值表示就绪的数目
for (i=0;i<result;i++){
if (dopoll.dp_fds[i].fd==sockfd)
{
if ( (n=Read(sockfd,buf,MAXLINE))==0){
if (stdineof==1)
return;
else
err_quit("str_cli:server terminated prematurely");
}
Write(fileno(stdout),buf,n);
}else{
if ( (n=Read(fileno(fp),buf,MAXLINE))==0){
stdineof=1;
Shutdown(sockfd,SHUT_WR);
continue;
}
Writen(sockfd,buf,n);
}
}
}
}
kqueue接口
kqueue比起select可以监听更多的事件,如异步I/O,文件修改通知(被删除或者修改),进程跟踪(进程调用exit或者fork)和信号处理
相关函数
int kqueue(void);
int kevent(int kq,
const struct kevent *changelist, int nchanges,
struct kevent *eventlist, int nevents,
const struct timespec *timeout);
void EV_SET(struct kevent *kev, uintptr_t ident,short filter, u_short flags, u_int fflags, intptr_t data, void *udata);
struct kevent {
uintptr_t ident; /* identifier for this event */
int16_t filter; /* filter for event */
uint16_t flags; /* general flags */
uint32_t fflags; /* filter-specific flags */
intptr_t data; /* filter-specific data */
void *udata; /* opaque user data identifier */
};
函数解释
kqueue函数返回一个新的kqueue描述符,用于后续的kevent函数调用
kevent既用于注册事件,也用于确定是否有所关注事件发生。changelist和nchanges是对关注事件做出的更改,如果没有更改,那么取值NULL和0,如果nchanges不为0,那么kevent函数会执行changelist数组中请求的事件过滤器更改。条件触发的事件(包括在changelist中增设的事件)由kevent函数通过eventlist返回,eventlist是一个由nevents个元素构成的kevent结构数组,kevent在eventlist中返回的事件数目由函数返回值返回,0表示发生了超时;timeout指定的timespec如果是0,表示非阻塞事件检查,如果是非0,表示超时时间,如果是NULL,表示阻塞
kevent结构体中,flags指定了过滤器更改行为,包括以下选择
flags | 说明 | 更改 | 返回 |
---|---|---|---|
EV_ADD | 增设事件;自动启用,除非指定了EV_DISABLE | ✔️ | |
EV_CLEAR | 用户获取后复位事件状态 | ✔️ | |
EV_DELETE | 删除事件 | ✔️ | |
EV_DISABLE | 禁用事件但是不删除 | ✔️ | |
EV_ENABLE | 重新启用之前禁用的事件 | ✔️ | |
EV_ONESHOT | 触发一次后删除事件 | ✔️ | |
EV_EOF | 发生EOF条件 | ✔️ | |
EV_ERROR | 发生错误;errno值在data成员中 | ✔️ |
filter则指定了过滤器类型
filter | 说明 |
---|---|
EVFILT_AIO | 异步I/O事件 |
EVFILT_PROC | 进程exit、fork或者exec事件 |
EVFILT_READ | 描述符可读,类似select |
EVFILT_SIGNAL | 收到信号 |
EVFILT_TIMER | 周期性或一次性的定时器 |
EVFILT_VNODE | 文件修改和删除事件 |
EVFILT_WRITE | 描述符可写,类似select |
例子
#include "unp.h"
void str_cli(FILE *fp,int sockfd)
{
int kq,i,n,nev,stdineof=0,isfile;
char buf[MAXLINE];
struct kevent kev[2];
struct timespec ts;
struct stat st;
isfile=( (fstat(fileno(fp),&st)==0) &&
(st.st_mode & S_IFMT) == S_IFREG);
EV_SET(&kev[0],fileno(fp),EVFILT_READ,EV_ADD,0,0,NULL);
EV_SET(&kev[1],sockfd,EVFILT_READ,EV_ADD,0,0,NULL);
kq=Kqueue();
ts.tv_sec=ts.tv_nsec=0;
Kevent(kq,kev,2,NULL,0,&ts); //设置监听事件
for(;;){
nev=Kevent(kq,NULL,0,kev,2,NULL); //监听,第一个NULL表示无新增监听,最后一个NULL表示阻塞直至有事件
for(i=0;i<nev;i++){
if (kev[i].ident==sockfd){
if ( (n=Read(sockfd,buf,MAXLINE))==0){
if (stdineof==1)
return;
else
err_quit("str_cli: server terminated prematurely");
}
Write(fileno(stdout),buf,n);
}
if (kev[i].ident==fileno(fp)){
n=Read(fileno(fp),buf,MAXLINE);
if (n>0)
Writen(sockfd,buf,n);
if (n==0 || (isfile && n==kev[i].data)){
stdineof=1;
Shutdown(sockfd,SHUT_WR);
kev[i].flags=EV_DELETE; //不再监听输入
Kevent(kq,&kev[i],1,NULL,0,&ts);
continue;
}
}
}
}
}