介绍I/O复用
构建并发服务器时,只要有客户端连接请求就会创建新进程,但是创建进程时需要付出极大代价(需要大量的运算和内存空间),I/O复用使用于在不创建进程的同时向多个客户端提供服务
。
系统复用技术有时分复用技术和频分复用技术。
select函数
运用select函数时最具代表性的实现复用服务端方法。Windows平台也有同名函数提供相同功能,因此具有良好的移植性。
使用select函数时可以将多个文件描述符集中到一起统一监视,项目如下。
- 是否存在套接字接收数据?
- 无需阻塞传输数据的套接字有哪些?
- 哪些套接字发生了异常?
监视项称为"事件"(event):
提示:上述监视项称为"事件"。发生监视项对应情况时,称"发生了事件"这是最常见的表达,希望各位熟悉。另外,本章不会使用术语“事件"而与本章密切相关的第17章将使用该术语,希望大家理解“事件”的含以及"发生事件"的意义。
select函数调用过程
Linux
设置文件描述符
利用select函数可以同时监视多个文件描述符。当然,监视文件描述符可以视为监视套接字。此时首先需要将要监视的文件描述符集中到一起。集中时也要按照监视项(接收、传输、异常
)进行区分,即按照上述3种监视项分成3类。
使用fd_set数组变量执行此项操作,如下图所示。该数组是存有0和1的位数组
。
图中最左端的位表示文件描述符0(所在位置)。如果该位设置为1,则表示该文件描述符是监视对象。那么图中哪些文件描述符是监视对象呢?很明显,是文件描述符1和3。
由于fd_set数组是以位位单位进行的,因此为方便操作,在fd_set变量中注册或更改值得操作都可以由下列宏完成。
- FD ZERO(fd_set * fdset):将fd_set变量的所有位初始化为0。
- FD SET(int fd, fd_set * fdset):在参数fdset指向的变量中注册文件描述符fd的信息。
- FD_CLR(int fd, fd_set * fdset):从参数fdset指向的变量中清除文件描述符fd的信息。
- FD_ISSET(int fd, fd_set * fdset):若参数fdset指问的变量中包含文件描述符fd得信息,则返回"真"。
上述函数中,FD_ISSET用于验证select函数的调用结果。通过下图来解释这些函数的功能。
设置检查(监视)范围及超时
#include <sys/select.h>
#include <sys/time.h>
int select(int maxfd, fd_set* readset, fd_set * writeset, fd_set * exceptset, const struct timeval * timeout);
//成功时返回大于日的值,失败时返回-1。
- maxfd:监视对象文件描述符数量。
- readset:将所有关注“是否存在待读取数据”的文件描述符注册到fd_set型变量,并传递其地址值。
- writeset:将所有关注“是否可传输无阻塞数据”的文件描述符注册到fd_set型变量,并传递其地址值。
- exceptset:将所有关注“是否发生异常”的文件描述符注册到fd_set型变量,并传递其地址值。
- timeout:调用select函数后,为防止陷入无限阻塞的状态,传递超时(time-out)信息。返回值发生错误时返回-1,超时返回时返回0。因发生关注的事件返回时,返回大于0的值,该值是发生事件的文件描述符数。
select函数用于验证3种监视项得变化情况。根据监视项声明3个fd_set型变量,分别向其注册文件描述符信息,并把变量得地址值传递到上述函数得第二到第四个参数。
但在此调用select函数前需要决定下面2件事。
- “文件描述符的监视(检查)范围是?”
- “如何设定select函数的超时时间?”
第一,文件描述符的监视范围与select函数的第一个参数有关。实际上,select函数要求通过第一个参数传递监视对象文件描述符的数量。因此,需要得到注册在fd_set变量中的文件描述符数。但每次新建文件描述符时,其值都会增1,故只需将最大的文件描述符值加1再传递到select函数即可。加1是因为文件描述符的值从0开始。
第二,select函数的超时时间与select函数的最后一个参数有关,其中timeval结构体定义如下。
struct timeval
{
long tv_sec;// seconds
long tv_usec;//microseconds
}
本来select函数只有在监视的文件描述符发生变化时才返回。如果未发生变化,就会进入阻塞状态。指定超时时间就是为了防止这种情况的发生。通过声明上述结构体变量,将秒数填人tv_sec成员,将毫秒数填入tv_usec成员,然后将结构体的地址值传递到select函数的最后一个参数。此时,即使文件描述符中未发生变化,只要过了指定时间,也可以从函数中返回。不过这种情况下,select函数返回0
。因此,可以通过返回值了解返回原因。如果不想设置超时,则传递NULL参数
。
调用select函数后查看结果
select函数调用完成后,向其传递的fd_set变量中将发生变化。原来为1的所有位均变为0,但发生变化的文件描述符对应位除外。因此,可以认为值仍为1的位置上的文件描述符发生了变化
。
select函数调用示例(重要)
#include <stdio.h>
#include <unistd.h>
#include <sys/time.h>
#include <sys/select.h>
#define BUF_SIZE 30
int main(int argc,char * argv[])
{
fd_set reads,temps;
int result,str_len;
char buf[BUF_SIZE];
struct timeval timeout;
FD_ZERO(&reads);
FD_SET(0,&reads); //0 is standard input(console)
/*
timeout.tv_sec = 5;
timeout.tv_usec = 5000;
*/
while(1)
{
temps = reads;
timeout.tv_sec = 5;
timeout.tv_usec = 5000;
result = select(1,&temps,0,0,&timeout);
if(result == -1)
{
puts("select() error!");
break;
}
else if (result == 0)
{
puts("Time-out!");
}
else
{
if(FD_ISSET(0,&temps))
{
str_len = read(0,buf,BUF_SIZE);
buf[str_len] = 0;
printf("message from console:%s",buf);
}
}
}
return 0;
}
- 第14、15行:看似复杂,实则简单。首先在第14行初始化fd_set变量,第15行将文件描述符0对应的位设置为1。换言之,需要监视标准输入的变化。
- 第24行:将准备好的fd_set变量reads的内容复制到temps变量,因为之前讲过,调用select函数后,除发生变化的文件描述符对应位外,剩下的所有位将初始化为0。因此,为了记住初始值,必须经过这种复制过程。这是使用select函数的通用方法,希望各位牢记。
- 第18、19行:请观察被注释的代码,这是为了设置select函数的超时而添加的。但不能在此时设置超时。因为调用select函数后,结构体timeval的成员tv_sec和tv_usec的值将被替换为超时前剩余时间。因此,调用select函数前,每次都需要初始化timeval结构体变量。
- 第25、26行:将初始化timeval结构体的代码插入循环后,每次调用select函数前都会初始化新值。
- 第27行:调用select函数。如果有控制台输入数据,则返回大于0的整数;如果没有输入数据而引发超时,则返回0。
- 第39~44行:select函数返回大于O的值时运行的区域。验证发生变化的文件描述符是否为标准输入。若是,则从标准输入读取数据并向控制台输出。
输出:
运行后若无任何输入,经5秒将发生超时。若通过键盘输入字符串,则可看到相同字符串输出。
Windows
函数说明
Windows同样提供select函数,而且所有参数与Linux的select函数完全相同。只不过Windows平台select函数的第一个参数是为了保持与(包括Linux的)UNIX系列操作系统的兼容性而添加的,并没有特殊意义。
#include <winsock2.h>
int select(int nfds, fd_set *readfds,fd_set *writefds, fd_set *excepfds, const structtimeval *timeout);
//成功时返回0,失败时返回-1。
返回值、参数的顺序及含义与之前的Linux中的select函数完全相同,故省略。下面给出timeval结构体定义。
typedef struct timeval{
long tv_sec;// seconds
long tv_usec;//microseconds
}TIMEVAL;
可以看到,基本结构与之前Linux中的定义相同,但Windows中使用的是typedef声明。接下来观察fd_set结构体。Windows中实现时需要注意的地方就在于此。可以看到,Windows的fd_set并非像Linux中那样采用了位数组。
typedef struct fd_set
{
u_intfd_count;
SOCKET fd_array[FD_SETSIZE];
} fd_set;
Windows的fd_set由成员fd_count和fd_array构成, fd_count用于套接字句柄数,fd_array用于保存套接字句柄。只要略加思考就能理解这样声明的原因。Linux的文件描述符从O开始递增,因此可以找出当前文件描述符数量和最后生成的文件描述符之间的关系。但Windows的套按字句柄并非从0开始,而且句柄的整数值之间并无规律可循,因此需要直接保存句柄的数组和记求句衲数的变量。幸好处理fd_set结构体的FD_XXX型的4个宏的名称、功能及使用方法与Linux完全相同(故省略)
,这也许是微软为了保证兼容性所做的考量。
send & recv函数
send函数和recv函数的最后一个参数是收发数据时的可选项。该可选项可利用位或(bit OR )运算(|运算符)同时传递多个信息。下表整理可选项的种类及含义。
另外,不同操作系统对上述可选项的支持也不同。
因此,为了使用不同可选项,各位需要对实际开发中采用的操作系统有一定了解。下面选取上表中的一部分(主要是不受操作系统差异影响的)进行详细讲解。
- MSG_OOB:发送紧急消息
- MSG_PEEK和MSG_DONTWAIT:检查输入缓冲
Linux
send函数
#include <sys/socket.h>
ssize_t send(int sockfd,const void * buf,size_t nbytes,int flags);
//成功时返回发送的字节数,失败时返回-1。
- sockfd:表示与数据传输对象的连接的套接字文件描述符。
- buf:保存待传输数据的缓冲地址值。
- nbytes:待传输的字节数。
- flags:传输数据时指定的可选项信息。
recv函数
#include <sys/socket.h>
ssize_t recv(int sockfd, void * buf, size_t nbytes,int flags);
//成功时返回接收的字节数(收到EOF时返回0),失败时返回-1。
readv & writev函数
readv & writev函数的功能可概括如下:
“对数据进行整合传输及发送的函数。”
也就是说,通过writev函数可以将分散保存在多个缓冲中的数据一并发送,通过readv函数可以由多个缓冲分别接收。因此,适当使用这2个函数可以减少IO函数的调用次数。
writev函数
#include <sys/uio.h>
ssize_t writev(int filedes,const struct iovec * iov,int iovcnt);
//成功时返回发送的字节数,失败时返回-1。
- filedes:表示数据传输对象的套接字文件描述符。但该函数并不只限于套接字,因此,可以像read函数一样向其传递文件或标准输出描述符。
- iov:iovec结构体数组的地址值,结构体iovec中包含待发送数据的位置和大小信息。
- iovcnt:向第二个参数传递的数组长度。
数组iovec结构体
声明:
struct iovec{
void * iov_base;//缓冲地址
size_t iov_len;//缓冲大小
}
结构体iovec由保存待发送数据的缓冲(char型数组)地址值和实际发送的数据长度信息构成。
writev函数示例
#include <stdio.h>
#include <sys/uio.h>
int main(int argc,char * argv[])
{
struct iovec vec[2];
char buf1[] = "ABCDEFG";
char buf2[] = "1234567";
int str_len;
vec[0].iov_base = buf1;
vec[0].iov_len = 3;
vec[1].iov_base = buf2;
vec[1].iov_len = 4;
str_len = writev(1,vec,2);
puts("");
printf("write bytes:%d\n",str_len);
return 0;
}
readv函数
#include <sys/uio.h>
ssize_t readv(int filedes,const struct iovec* iov, int iovcnt);
→成功时返回接收的字节数,失败时返回-1。
- filedes:传递接收数据的文件(或套接字)描述符。
- iov:包含数据保存位置和大小信息的iovec结构体数组的地址值。
- iovcnt:第二个参数中数组的长度。
readv函数示例
#include <stdio.h>
#include <sys/uio.h>
#define BUF_SIZE 100
int main(int argc,char * argv)
{
struct iovec vec[2];
char buf1[BUF_SIZE] = {0,};
char buf2[BUF_SIZE] = {0,};
int str_len;
vec[0].iov_base = buf1;
vec[0].iov_len = 5;
vec[1].iov_base = buf2;
vec[1].iov_len = BUF_SIZE;
str_len = readv(0,vec,2);
printf("Read byte:%d\n",str_len);
printf("First messag:%s\n",buf1);
printf("Second message:%s\n",buf2);
return 0;
}
合理使用readv & writev函数
实际上,能使用使用readv和writev函数的所有情况都适用。
例如,需要传输的数据分别位于不同缓冲(数组)时,需要多次调用write函数。此时可以通过1次writev函数调用替代操作,当然会提高效率。同样,需要将输入缓冲中的数据读入不同位置时,可以不必多次调用read函数,而是利用1次readv函数就能大大提高效率。
Windows讲解
对send & reav函数可选项处理
在Windows中无法完成针对该可选项的事件处理,需要考虑使用其他方法。我们通过select函数解决这一问题。之前讲过的select函数的3种监视对象如下所示。
- 是否存在套接字接收数据?
- 无需阻塞传输数据的套接字有哪些?
- 哪些套接字发生了异常?
其中,"异常"是不同寻常的程序执行流,因此,收到Out-of-band数据也属于异常。也就是说,利用select函数的这一特性可以在Windows平台接收Out-of-band数据。
对readv & writev函数处理
Windows中并没有函数与writev & readv函数直接对应,但可以通过"重叠IO"( Overlapped I/O)得到相同效果。关于重叠I/O需要处理不少细节问题,将在谈谈Windows网络编程中进行讲解。各位只需记住Linux的writev & readv函数的功能可以通过Windows的"重叠I/O"实现。