《TCP/IP网络编程》学习笔记 | Chapter 12:I/O 复用
- [《TCP/IP网络编程》学习笔记 | Chapter 12:I/O 复用](#《TCP/IP网络编程》学习笔记 | Chapter 12:I/O 复用)
-
- [基于 I/O 复用的服务器端](#基于 I/O 复用的服务器端)
- [理解 select 函数并实现服务器端](#理解 select 函数并实现服务器端)
-
- [select 函数的功能和调用顺序](#select 函数的功能和调用顺序)
- 设置文件描述符
- 设置检查(监视)范围及超时
- 调用select函数后查看结果
- [select 函数调用示例](#select 函数调用示例)
- 实现I/O复用服务器端
- [基于 Windows 的实现](#基于 Windows 的实现)
-
- [Windows 平台下的 select 函数](#Windows 平台下的 select 函数)
- [基于 Windows 实现 I/O 复用服务器端](#基于 Windows 实现 I/O 复用服务器端)
- 习题
《TCP/IP网络编程》学习笔记 | Chapter 12:I/O 复用
基于 I/O 复用的服务器端
多进程服务器端的缺点和解决方法
为了构建并发服务器、只要有客户端连接请求就会创建新进程。这的确是实际操作中采用的一种方案,但并非十全十美,因为创建进程时需要付出极大代价。这需要大量的运算和内存空间,由于每个进程都具有独立的内存空间,所以相互间的数据交换也要求采用相对复杂的方法。
I/O 复用可以在不创建进程的同时向多个客户端提供服务。
理解复用
复用在通信领域的定义:在1个通信频道中传递多个数据(信号)的技术。
复用的定义:为了提高物理设备的效率,用最少的物理要素传递最多数据时使用的技术。
复用技术在服务器端的应用
多进程服务器端模型:
引入复用技术,可以减少进程数。重要的是,无论连接多少客户端,提供服务的进程只有1个。
I/O复用服务器端模型:
理解 select 函数并实现服务器端
运用select函数是最具代表性的实现复用服务器端方法。Windows平台下也有同名函数提供相同功能,因此具有良好的移植性。
select 函数的功能和调用顺序
使用select函数时可以将多个文件描述符集中到一起统一监视,项目如下。
- 是否存在套接字接收数据?
- 无需阻塞传输数据的套接字有哪些?
- 哪些套接字发生了异常?
上述监视项称为"事件"。发生监视项对应情况时,称"发生了事件"。
下面介绍 select 函数的调用方法和顺序,如下图所示:
可以看到,调用selet函数前需要一些准备工作,调用后还需查看结果。接下来按照上述顺序逐一讲解。
设置文件描述符
利用select函数可以同时监视多个文件描述符。当然,监视文件描述符可以视为监视套接字。此时首先需要将要监视的文件描述符集中到一起。集中时也要按照监视项(接收、传输、异常)进行区分,即按照上述3种监视项分成3类。
使用fd_set数组变量执行此项操作,如下图所示。该数组是存有0和1的位数组。哪位为1,对应的文件描述符就是监视对象。
针对fd_set变量的操作是以位为单位进行的,在fd_set变量中注册或更改值的操作都由下列宏完成:
- FD_ZERO(fd_set *fdset):将fd_set变量的所有位初始化为0。
- FD SET(int fd,fd_set *fdset):在参数fdset指向的变量中注册文件描述符fa的信息。
- FD_CLR(int fd,fd_set *fdset): 从参数fdset指向的变量中清除文件描述符fd的信息。
- FD_ISSET(int fd,fd_set *fdset):若参数fiset指向的变量中包含文件描述符d的信息,则返回"真"。
下图解释了这些函数的功能:
设置检查(监视)范围及超时
简单介绍一下 select 函数。
cpp
#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,超时返回时返回0。因发生关注的事件返回时,返回大于0的值,该值是发生事件的文件描述符数。
参数:
- maxfd:监视对象文件描述符数量。
- readset:将所有关注"是否存在待读取数据"的文件描述符注册到fd_set型变量,并传递其地址值。
- writeset:将所有关注"是否可传输无阻塞数据"的文件描述符注册到fd_set型变量,并传递其地址值。
- exceptset:将所有关注"是否发生异常"的文件描述符注册到fd_set型变量,并传递其地址值。
- timeout:调用select函数后,为防止陷入无限阻塞的状态,传递超时(time-out)信息。
select函数用来验证3种监视项的变化情况。根据监视项声明3个fd set型变量,分别向其注册文件描述符信息,并把变量的地址值传递到上述函数的第二到第四个参数。但在调用select函数前还需要决定下面2件事。
第一,确定文件描述符的监视范围,它与select函数的第一个参数有关。实际上,select函数要求通过第一个参数传递监视对象文件描述符的数量。因此,需要得到注册在fd_set变量中的文件描述符数。但每次新建文件描述符时,其值都会增1,故只需将最大的文件描述符值加1再传递到select函数即可。加1是因为文件描述符的值从0开始。
第二,设定select函数的超时时间,它与select函数的最后一个参数有关。其中timeval结构体定义如下:
cpp
struct timeval
{
long tv_sec; // seconds
long tv_usec; // microseconds
}
本来select函数只有在监视的文件描述符发生变化时才返回。如果未发生变化,就会进入阻塞状态。指定超时时间就是为了防止这种情况的发生。通过声明上述结构体变量,将秒数填人tv_sec成员,将毫秒数填入tv_usec成员,然后将结构体的地址值传递到select函数的最后一个参数。此时,即使文件描述符中未发生变化,只要过了指定时间,也可以从函数中返回。不过这种情况下,select函数返回0。因此,可以通过返回值了解返回原因。如果不想设置超时,则传递NULL。
调用select函数后查看结果
虽未给出具体示例,但步骤一"select函数调用前的所有准备工作"已讲解完毕,同时也介绍了select函数。而函数调用后查看结果也同样重要。我们已讨论过select函数的返回值,如果返回大于0的整数,说明相应数量的文件描述符发生变化,也就是监视的文件描述符中发生了相应的监视事件。
示例:
select函数调用完成后,向其传递的fd_set变量中将发生变化。原来为1的所有位均变为0,但发生变化的文件描述符对应位除外。因此,可以认为值仍为1的位置上的文件描述符发生了变化。
select 函数调用示例
cpp
#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;
// 初始化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);
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);
buf[str_len] = 0;
printf("message from console: %s\n", buf);
}
}
}
return 0;
}
运行后若无任何输入,经5秒将发生超时。若通过键盘输入字符串,则可看到相同字符串输出。
实现I/O复用服务器端
下面通过select函数实现I/O复用的回声服务器端。
cpp
#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[])
{
// 套接字相关变量
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)
{ // 错误的情况
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
{
write(i, buf, str_len);
}
}
}
}
}
close(serv_sock);
return 0;
}
void error_handling(char *message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
基于 Windows 的实现
Windows 平台下的 select 函数
cpp
#include <winsock2.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *excefds, const struct timeval *timeout);
返回值、参数的顺序及含义与之前的 Linux 中的select 函数完全相同,故省略。
timeval 基本结构体与之前Linux 中的定义相同,但 Windows 中使用的是 typedef 声明。
cpp
typedef struct timeval
{
long tv_sec; // seconds
long tv_usec; // microseconds
} TIMEVAL;
接下来观察 fd_set 结构体,Windows 的 fd_set 并非像 Linux 中那样采用了位数组。
cpp
typedef struct fd_set {
u_int fd_count;
SOCKET fd_array[FD_SETSIZE];
} fd_set;
Windows 的fd_set 由成员 fd_count 和 fd_array 构成,fd_count 用于套接字句柄数,fd_array 用于保存套接字句柄。
只要略加思考就能理解这样声明的原因。Linux 的文件描述符从0开始递增,因此可以找到当前文件描述符数量和最后生成的文件描述符之间的关系。但 Windows 的套接字句柄并非从 0 开始,而且句柄的整数值之间并无规律可循,因此需要直接保存句柄的数组和记录句柄数的变量。
处理 fd_set 结构体的4个宏的名称、功能及使用方法与Linux 完全相同,也省略。
基于 Windows 实现 I/O 复用服务器端
下面将示例 echo_selectserv.c 改为在 Windows 平台运行。
cpp
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <winsock2.h>
#define BUF_SIZE 1024
void ErrorHanding(char *message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
int main(int argc, char *argv[])
{
WSADATA wsaData;
SOCKET serverSock, clientSock;
SOCKADDR_IN serverAddr, clientAddr;
int clientAddrSize;
char message[BUF_SIZE];
int strLen, fdNum;
TIMEVAL timeout;
fd_set reads, copyReads;
if (argc != 2)
{
printf("Usage: %s <port>\n", argv[0]);
exit(1);
}
if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
ErrorHanding("WSAStartup() error!");
serverSock = socket(PF_INET, SOCK_STREAM, 0);
if (serverSock == INVALID_SOCKET)
ErrorHanding("socket() error!");
memset(&serverAddr, 0, sizeof(serverAddr));
serverAddr.sin_family = AF_INET;
serverAddr.sin_addr.s_addr = htonl(INADDR_ANY);
serverAddr.sin_port = htons(atoi(argv[1]));
if (bind(serverSock, (SOCKADDR *)&serverAddr, sizeof(serverAddr)) == SOCKET_ERROR)
ErrorHanding("bind() error!");
if (listen(serverSock, 5) == SOCKET_ERROR)
ErrorHanding("listen() error!");
FD_ZERO(&reads);
FD_SET(serverSock, &reads); // 注册服务器端套接字文件描述符
while (1)
{
copyReads = reads;
timeout.tv_sec = 5, timeout.tv_usec = 5000;
if ((fdNum = select(0, ©Reads, 0, 0, &timeout)) == SOCKET_ERROR)
break;
if (fdNum == 0)
continue;
for (int i = 0; i < reads.fd_count; i++)
{
if (FD_ISSET(reads.fd_array[i], ©Reads))
{
if (reads.fd_array[i] == serverSock) // 服务器端套接字有变化,受理连接请求
{
clientAddrSize = sizeof(clientAddr);
clientSock = accept(serverSock, (SOCKADDR *)&clientAddr, &clientAddrSize);
FD_SET(clientSock, &reads); // 注册与客户端连接的套接字文件描述符
printf("connected client: %d\n", clientSock);
}
else // 有要接收的数据
{
strLen = recv(reads.fd_array[i], message, BUF_SIZE - 1, 0);
if (strLen == 0)
{ // 接收的数据为 EOF,关闭套接字,并删除与客户端连接的套接字文件描述符
closesocket(reads.fd_array[i]);
printf("closed client: %d\n", copyReads.fd_array[i]);
FD_CLR(reads.fd_array[i], &reads);
}
else
{ // echo
send(reads.fd_array[i], message, strLen, 0);
}
}
}
}
}
closesocket(serverSock);
WSACleanup();
return 0;
}
编译:
cpp
gcc echo_selectserver_win.c -lwsock32 -o echoSelectServ
gcc echo_client_win.c -lwsock32 -o echoClnt
运行结果:
// 服务器端
C:\Users\81228\Documents\Program\TCP IP Project\Chapter 12>echoSelectServ 9190
connected client: 240
connected client: 244
closed client: 240
closed client: 244
// 客户端1
C:\Users\81228\Documents\Program\TCP IP Project\Chapter 12>echoClnt 127.0.0.1 9190
Connected......
Input message(Q to quit): Hi~
Message from server: Hi~
Input message(Q to quit): Good bye
Message from server: Good bye
Input message(Q to quit): q
// 客户端2
C:\Users\81228\Documents\Program\TCP IP Project\Chapter 12>echoClnt 127.0.0.1 9190
Connected......
Input message(Q to quit): Nice to meet you~
Message from server: Nice to meet you~
Input message(Q to quit): Bye~
Message from server: Bye~
Input message(Q to quit): q
习题
(1)请解释复用技术的通用含义,并说明何为I/O复用。
复用的定义:为了提高物理设备的效率,用最少的物理要素传递最多数据时使用的技术。
创建进程的开销很大。引入复用技术,可以减少进程数。重要的是,无论连接多少客户端,提供服务的进程只有1个。
(2)多进程并发服务器的缺点有哪些?如何在I/O复用服务器端中弥补?
创建进程时需要付出极大代价,这需要大量的运算和内存空间。由于每个进程都具有独立的内存空间,所以相互间的数据交换也要求采用相对复杂的方法。
I/O复用服务器通过一个进程向多个客户端提供服务,避免了这些问题。
(3)复用服务器端需要select函数。下列关于select函数使用方法的描述错误的是?
a. 调用select函数前需要集中I/O监视对象的文件描述符。
b. 若已通过select函数注册为监视对象,则后续调用select函数时无需重复注册。
c. 复用服务器端同一时间只能服务于1个客户端,因此,需要服务的客户端接入服务器端后只能等待。
d. 与多进程服务器端不同,基于select的复用服务器端只需要1 个进程。因此,可以减少因创建进程产生的服务器端的负担。
答:b、c。
(4)select函数的观察对象中应包含服务器端套接字(监听套接字),那么应将其包含到哪一类监听对象集合?请说明原因。
包含到"是否存在待读取数据"这一类监听对象集合,因为服务端套接字中有接收的数据说明有新的连接请求。
(5)select函数中使用的fd_set结构体在Windows和Linux中具有不同声明。请说明区别,同时解释存在区别的必然性。
Windows 的fd_set 由成员 fd_count 和 fd_array 构成,fd_count 用于套接字句柄数,fd_array 用于保存套接字句柄。
Linux 中的是位数组。
Linux 的文件描述符从0开始递增,因此可以找到当前文件描述符数量和最后生成的文件描述符之间的关系。但 Windows 的套接字句柄并非从 0 开始,而且句柄的整数值之间并无规律可循,因此需要直接保存句柄的数组和记录句柄数的变量。