第十二章: I/O复用
之前我们采用多进程的方式实现了并发服务器端的构建,
然而并发服务器端的构建并非只有这一种方法,下面我们在本章中通过I/O复用的方式,构建并发服务器端。
重点思考一下,这两种方法有什么不同呢? 各自的优势又是什么呢?
12.1 基于I/O复用的服务器端
之前采用的多进程方法构建服务器端,在每次客户端请求连接时,就会创建新的进程
因为创建进程需要大量的运算和内存空间,由于每个进程都具有独立的内存空间,因此相互间的数据交换也要求采用相对复杂的方法(IPC属于相对复杂的通信方法)。
接下来要说的这个I/O复用能够有效减少上面提到的缺点,
但是,该模型并不适用于所有情况,应根据目标服务器的特点采用不同实现方法。
12.1.1 复用是什么?
“复用”,用行内话说是:在一个通信频道中传递多个数据(信号)的技术
定义来说:为了提高物理设备的效率,用最少的物理要素传递最多数据时使用的技术
上面的意思是一致的。
复用技术类似于下面的图,减少纸杯和连线的方式就是复用,这意味着减少了进程数
=================》
会不会产生无法同事说话的情况呢,声音(数据)都交织在了一起?
实际上很少发生这样的情况,上述西永采用的是“时分复用技术”
而且,不同人的声音频率不同,系统同时采用了“频分复用技术”
(上面这两句话,其实感觉没啥用,往下看就能动了)
12.1.2 复用技术在服务器端的应用
将复用技术与服务器端相配合,能够减少所需进程数,不论有多少客户端连接,提供服务的进程只有1 个,如下图所示(左图为多进程服务器端模型 右图为I/O复用服务器端模型)
I/O复用的理解:
多进程服务器端类似一个学生配一个老师
I/O复用服务器端类似多个学生配一个老师,
老师必须通过确认有无学生举手,同样I/O复用服务器端进程需要确认举手(收到数据)的套接字。
12.2 理解select函数并实现服务器端
select函数是最具有代表性的实现复用服务器端方法。
12.2.1 select函数的功能和调用顺序
使用select函数可以将多个文件描述符集中到一起统一监视,需要监视的内容如下
- 是否存在套接字接收数据?
- 无需阻塞传输数据的套接字有哪些?
- 哪些套接字发生了异常?
(监视项称为事件:发生监视项对应的情况是,称为发生了事件。)
下面介绍select函数的调用方法和顺序。如下图所示:
可以看出select函数从 调用 到 获取结果所经历3个步骤。下面仔细讲解
先放个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);
-> 成功时返回大于0的值,失败返回-1
maxfd: 监视对象 文件描述符数量
readset: 将所有关注“是否存在待读数据”的文件描述符 注册到fd_set型变量,并传递其地址值
writeset: 将所有关注“是否可传输无阻塞数据”的文件描述符 注册到fd_set型变量,并传递其地址值
exceptset: 将所有关注“是否发生异常”的文件描述符 注册到fd_set型变量,并传递其地址值
timeout: 调用select函数后,为防止陷入无限阻塞的状态,传递超时(time-out)信息。
返回值: 发生错误时返回-1,超时返回0, 因发生关注的事件返回时,返回大于0的值,该值等于 发生事件的文件描述符的数量。
12.2.2 步骤一:初始化过程
1)设置文件描述符
select函数可以同时监控多个文件描述符,或者说是套接字。
我们需要将这些文件描述符集中到一起,同时进行分类,按照类别进行监视
这里的3个类别分别对应上面3点的监视内容:
- 接收
- 传输
- 异常
使用fd_set数组变量执行此项操作,对于每一类的监视,我们都采用这样的结构作为select函数的输入参数,如下图,fd_set数组存有 0 和 1的位数组
其中存放了很多文件描述符,fd0,fd1,fd2…
如果该位置为1,则表示该文件描述符是监视对象,可见图中的情况,文件描述符1和3是监控对象。
由于是位数组,因此我们如果自己去对fd_set
变量进行注册和更改,这会很繁琐
因此,我们采用下列的宏来完成:
FD_ZERO(fd_set* fdset)
:将fd_set变量的所有位初始化为0FD_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的信息,返回 真。
2)设置监视范围(检查范围)及超时
#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);
-> 成功时返回大于0的值,失败返回-1
maxfd: 监视对象 文件描述符数量
readset: 将所有关注“是否存在待读数据”的文件描述符 注册到fd_set型变量,并传递其地址值
writeset: 将所有关注“是否可传输无阻塞数据”的文件描述符 注册到fd_set型变量,并传递其地址值
exceptset: 将所有关注“是否发生异常”的文件描述符 注册到fd_set型变量,并传递其地址值
timeout: 调用select函数后,为防止陷入无限阻塞的状态,传递超时(time-out)信息。
返回值: 发生错误时返回-1,超时返回0, 因发生关注的事件返回时,返回大于0的值,该值等于 发生事件的文件描述符的数量。
如上所示,select函数用来验证3种监视项的变化情况,根据监视项声明3个fd_set型变量,分别向其中注册文件描述符信息,并把变量的地址值传递到上述函数中的第二到第四个参数中。
那么如何设置监视范围 和 如何设定超时时间呢?
监视范围:
这与select函数的第一个参数有关,select函数要求第一个参数传递监视对象文件描述符的数量。
因此需要得到注册在fd_set变量中的文件描述符数量。
但是每次新建文件描述符时都会增加 1 ,所以只需要将最大的文件描述符值(最后创建的)再加1 传递到select函数中即可。
+1 是因为文件描述符从0开始(这个程序中创建的第一个文件描述符为0 第二个为1…)
超时设置:
这与最后一个参数有关,其中timeval结构体的定义如下
struct timeval
{
long tv_sec; //second
long tv_usec; //microseconds
};
本来select函数只有在监视的 文件描述符发生变化时,才会返回。
如果没变化就进入阻塞状态。 指定超时时间就是为了防止这种无休止的阻塞
通过将 秒数 填入 tv_sec成员
将毫秒数填入tv_usec成员,
将结构体变量的地址值传递给select函数的最后一个参数。
此时,如果监视的 文件描述符没有变化,在超过设置的时间后,将会返回 0
如果不想设置超时。让他一直阻塞,只需要传递NULL参数即可。
12.2.3 步骤二、三:调用select函数并查看结果
上面的步骤一已经完成了select函数调用前的所有准备工作,下面我们来看看调用和结果的查看。
前面介绍了一下返回值
- 返回0:超时
- 返回-1:错误
- 返回大于0的整数:说明有这些文件描述符发生变化
(什么是文件描述符的变化:指监视的文件描述符中发生了相应的监视时间。 read 、 write、except)
select函数返回正整数时,如何知道是那些文件描述符发生变化了呢?
看下图中,调用select函数前后,fd_set变量的变化情况
由上图可知,select函数调用完成后,向其传递的fd_set变量中将发生变化。
原来为1 的所有位均变为0,但发生变化的文件描述符对应位除外。因此可以认为值仍为1的位置上的文件描述符发生了变化。
(为1 代表的是这个文件描述符 是监视对象,也就是说,返回之后,只有发生变化的继续为监视对象,其他的都为0,也就是未注册状态)。
12.2.4 select函数调用示例
下面把知识点进行整合
select.c
请仔细阅读代码注释
#include <stdio.h>
#include <unistd.h>
#include <sys/time.h>
#include <sys/select.h>
#define BUF_SIZE 30
int main(int argc, char *argv[])
{
/* code */
// 首先定义一些变量
fd_set reads, temps;
int result, str_len;
char buf[BUF_SIZE];
struct timeval timeout;
// 初始化select参数变量
FD_ZERO(&reads); // 初始化reads 中的各位为0
FD_SET(0, &reads); // 设置reads中的第一个位置设置为 1,这个位置是控制台标准输入的文件描述符
/*
timeout.tv_sec = 5;
timeout.tv_usec = 5000; // 不能在这里初始化 timeout变量的值, 因为调用select函数后其中的成员的值会被替换为 超时前剩余时间,因此在下面进行初始化
*/
while(1)
{
temps = reads; // 这里很重要,需要把监听的结构体变量进行保存,因为调用select函数之后,处发生变化的文件描述符外,剩余位都会变成0;
timeout.tv_sec = 5; // 在这里进行timeout变量初始化,每次循环都会重新初始化变量的值。
timeout.tv_usec = 0;
result = select(1, &temps, 0, 0, &timeout); // 1个文件描述符,temp结构体中的描述符监听read ,write和except没有,超时函数时最后的timeout
// 监听read中的0位置 -》 代表控制台标准输入
if(result == -1){ // 错误的情况
puts("select() error!");
break;
}
else if(result == 0){ // 超时的情况
puts("Time out !");
}
else{ // 正常情况
if(FD_ISSET(0, &temps)) // 如果temp中 包含有 0号文件描述符的信息
{
str_len = read(0, buf, BUF_SIZE); // 从 0号文件描述符中读取信息。
buf[str_len] = 0;
printf("message from console: %s\n", buf);
}
}
}
return 0;
}
请仔细阅读代码注释内容,下面为测试结果。
12.2.5 实现I/O复用服务器端
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <unistd.h>
#include <sys/time.h>
#include <sys/select.h>
#define BUF_SIZE 100
void error_handling(char* message);
int main(int argc, char *argv[])
{
/* code */
// 首先定义变量
// 套接字相关变量
int serv_sock, client_sock;
struct sockaddr_in serv_addr, client_addr;
socklen_t client_addr_sz;
char buf[BUF_SIZE];
// select监视相关变量
fd_set reads, cpy_reads;
struct timeval timeout;
int fd_max, str_len, fd_num;
if(argc!= 2){
printf("Usage : %s <port>\n", argv[0]);
exit(1);
}
// 初始化套接字
serv_sock = socket(PF_INET, SOCK_STREAM, 0);
if(serv_sock == -1){
error_handling("socket() error");
}
memset(&serv_addr,0,sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_addr.sin_port = htons(atoi(argv[1]));
// 服务器套接字一条龙
if(bind(serv_sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) == -1){
error_handling("bind() error");
}
if(listen(serv_sock, 5) == -1){
error_handling("listen error");
}
// 初始化select参数变量
FD_ZERO(&reads); // 初始化reads 中的各位为0
FD_SET(serv_sock, &reads); // 设置reads中的第一个位置设置为 1,这个位置是控制台标准输入的文件描述符
fd_max = serv_sock;
while(1)
{
cpy_reads = reads; // 这里很重要,需要把监听的结构体变量进行保存,因为调用select函数之后,处发生变化的文件描述符外,剩余位都会变成0;
timeout.tv_sec = 5; // 在这里进行timeout变量初始化,每次循环都会重新初始化变量的值。
timeout.tv_usec = 5000;
if((fd_num = select(fd_max + 1, &cpy_reads, 0, 0, &timeout) )== -1) // 1个文件描述符,temp结构体中的描述符监听read ,write和except没有,超时函数时最后的timeout
{ // 错误的情况 // 监听read中的0位置 -》 代表控制台标准输入
puts("select() error!");
break;
}
if(fd_num == 0){ // 超时的情况
puts("Time out !");
continue;
}
// 正常情况
for(int i = 0; i < fd_max + 1; i++) // 对监视的每一个文件描述符进行循环
{
if(FD_ISSET(i, &cpy_reads)) // 在这里寻找有 接收数据的 文件描述符
{
if(i== serv_sock) // 如果是服务器端文件描述符有接收数据 那就建立连接! 并把新的套接字放进我们的监视中
{ // 证明有人进行了 connect 操作,我们看门的serv_sock有动静了
client_addr_sz = sizeof(client_addr);
client_sock = accept(serv_sock, (struct sockaddr*)&client_addr, &client_addr_sz);
FD_SET(client_sock, &reads); // 注册于客户端相连接的套接字
if(fd_max < client_sock){ // 同时 第一个参数更新
fd_max = client_sock;
}
printf("connected client:%d\n", client_sock);
}
else{ // 如果不是服务器端文件描述符有变动,那就是有数据来了,直接开读
str_len = read(i, buf, BUF_SIZE);
if(str_len == 0){ // 读完之后,如果读到了末尾的 EOF,那就清空监视中的文件描述符
FD_CLR(i, &reads);
close(i);
printf("closed client: %d\n", i);
}
else{ // 如果read的不是结束符呢? 那就写入到buf中,回声!!!
write(i, buf ,str_len);
}
}
}
}
}
// while 结束
close(serv_sock);
return 0;
}
void error_handling(char* message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}