基于 I/O 复用的服务器端构建是并发服务器的第二种实现方法。
什么是 I/O 复用
上一章节提到的多进程服务器端每个新连接都要创建一个新的进程,这会导致大量的资源消耗,尤其是在连接数较多的情况下。进程创建和销毁的开销较大,包括内存分配、内核数据结构的维护等。
下图是多进程服务器端的模型:
“那有何解决方案?能否在不创建进程的同时向多个客户端提供服务?”
I/O 复用就是这样一种技术。简单来说,I/O 复用是提高系统并发能力的一种关键技术,通过让一个线程同时监控多个 I/O 操作,减少了系统资源的消耗,提升了处理效率。
下图是 I/O 复用服务器端模型:
I/O 复用实现方法之一:select()
函数
运用 select()
函数是最具代表性的实现复用服务器端的方法。
(一)select()
函数的功能
使用 select()
函数时可以将多个文件描述符集中到一起统一监视,项目如下:
- 是否存在套接字接收数据?(即是否存在可读的数据)
- 这是指某个文件描述符上是否有数据可读,例如在网络编程中,某个套接字上是否有数据到达。如果有数据可读,那么
select()
函数会将该文件描述符标记为可读,允许程序执行相应的读取操作。
- 这是指某个文件描述符上是否有数据可读,例如在网络编程中,某个套接字上是否有数据到达。如果有数据可读,那么
- 无需阻塞传输数据的套接字有哪些?(即是否可以写入数据)
- 这是指某个文件描述符是否可以执行写操作而不会导致阻塞。在网络编程中,这意味着可以将数据写入套接字,例如发送数据。如果套接字准备好写入数据,
select()
函数会将该文件描述符标记为可写。
- 这是指某个文件描述符是否可以执行写操作而不会导致阻塞。在网络编程中,这意味着可以将数据写入套接字,例如发送数据。如果套接字准备好写入数据,
- 哪些套接字发生了异常?(即是否发生了异常)
- 这是指某个文件描述符上是否发生了异常条件,如带外数据(OOB 数据)或套接字错误。
select()
函数可以监视这些异常情况,当异常发生时,它会将该文件描述符标记为异常状态。
- 这是指某个文件描述符上是否发生了异常条件,如带外数据(OOB 数据)或套接字错误。
(二)select()
函数的调用方法和顺序
(1)select()
函数原型
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
-
int nfds
:- 监视的文件描述符数量,它的值是待监视的文件描述符集合中最大文件描述符加1。
-
fd_set *readfds
:- 指向一个
fd_set
类型的集合,表示需要监视的文件描述符是否可读(即是否有数据可读取)。如果不监视任何文件描述符的读状态,可以传递NULL
。
- 指向一个
-
fd_set *writefds
:- 指向一个
fd_set
类型的集合,表示需要监视的文件描述符是否可写(即是否可以向文件描述符写入数据而不会阻塞)。如果不监视任何文件描述符的写状态,可以传递NULL
。
- 指向一个
-
fd_set *exceptfds
:- 指向一个
fd_set
类型的集合,表示需要监视的文件描述符是否有异常情况(如带外数据到达)。如果不监视任何文件描述符的异常状态,可以传递NULL
。
- 指向一个
-
struct timeval *timeout
:- 指向一个
timeval
结构体,用于指定select()
函数等待的超时时间。如果指定的时间内没有文件描述符发生状态变化,select()
函数将返回。如果timeout
为NULL
,select()
将无限期地阻塞,直到有一个文件描述符准备好。
- 指向一个
返回值
- 成功:返回值是就绪的文件描述符数量,即
readfds
、writefds
和exceptfds
中标记为就绪的文件描述符的总数。 - 超时:返回值为
0
,表示在指定的时间内没有文件描述符状态发生变化。 - 失败:返回
-1
,并设置errno
以指示错误原因。
(2)设置文件描述符
select()
函数使用三个 fd_set
类型的集合来监视多个文件描述符。每个集合对应不同的 I/O 操作:
- 读集合(readfds) :用于监视是否有数据可读。
- 写集合(writefds) :用于监视是否可以执行非阻塞写操作。
- 异常集合(exceptfds) :用于监视是否发生了异常情况。
fd_set
结构如下图:
最左端的位表示文件描述符0(fd0 所在位置)。如果该位设置为1,则表示该文件描述符是监视对象,为0则不是监视对象。
fd_set
变量中注册或更改值的操作都由下列宏完成:
-
FD_ZERO(fd_set *set)
用于初始化文件描述符集合。 -
FD_SET(int fd, fd_set *set)
用于向集合中添加文件描述符。 -
FD_CLR(int fd, fd_set *set)
用于从集合中移除文件描述符。 -
FD_ISSET(int fd, fd_set *set)
用于检查文件描述符是否在集合中。
下图解释了这些函数的功能:
(3)设置监视范围及超时
监视范围:
文件描述符的监视范围与 select
函数的第一个参数有关,将其设置为文件描述符集合中最大文件描述符加1。
超时时间:
select
函数的超时时间与其最后一个参数 struct timeval *timeout
有关,其中 timeval
结构体定义如下:
#include <sys/time.h>
struct timeval {
time_t tv_sec; // 秒数
suseconds_t tv_usec; // 微秒数
};
本来 select
函数只有在监视的文件描述符发生变化时才返回。如果未发生变化,就会进入阻塞状态。指定超时时间就是为了防止这种情况发生。 在这种情况下,select
函数返回0。如果不想设置超时,传递 NULL
即可。
(4)调用 select
函数后查看结果
select()
函数返回后,你需要检查每个文件描述符是否准备好进行相应的操作。这是通过 FD_ISSET()
宏来实现的:
if (FD_ISSET(socket_fd, &readfds)) {
// 文件描述符 socket_fd 可读
}
if (FD_ISSET(socket_fd, &writefds)) {
// 文件描述符 socket_fd 可写
}
if (FD_ISSET(socket_fd, &exceptfds)) {
// 文件描述符 socket_fd 发生异常
}
(三)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 集合
FD_SET(0, &reads); // 将标准输入 (文件描述符 0) 添加到监视集合中
// timeout.tv_sec=5;
// timeout.tv_usec=5000;
while(1)
{
temps = reads; // 复制原始的集合到临时集合中
timeout.tv_sec = 5; // 设置超时时间为 5 秒
timeout.tv_usec = 0;
result = select(1, &temps, NULL, NULL, &timeout); // 监视标准输入的可读状态
if (result == -1)
{
perror("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;
}
-
初始化
fd_set
集合FD_ZERO(&reads); // 清空 `reads` 集合 FD_SET(0, &reads); // 将标准输入文件描述符 0 添加到 `reads` 集合中
FD_ZERO()
用于初始化fd_set
集合,将其清空。FD_SET()
将文件描述符 0(标准输入)添加到reads
集合中,表示我们要监视这个文件描述符的可读状态。
-
监视文件描述符的状态
temps = reads; // 将 `reads` 集合复制到 `temps` 集合中 timeout.tv_sec = 5; // 设置超时时间为 5 秒 timeout.tv_usec = 0;
temps
是一个临时集合,用于保存select()
调用时的文件描述符状态。timeout
结构体定义了select()
的超时时间,单位是秒和微秒。
result = select(1, &temps, NULL, NULL, &timeout); // 监视标准输入的可读状态
select()
函数的第一个参数1
表示文件描述符的最大值加 1(在这里是 1,表示只监视文件描述符 0)。&temps
是你希望监视的文件描述符集合(可读集合)。- 由于只监视可读状态,
writefds
和exceptfds
参数设置为NULL
。
-
处理
select()
的返回值if (result == -1) { perror("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); // 输出从标准输入读取的数据 } }
- 如果
select()
返回-1
,表示出现错误,通过perror()
打印错误信息。 - 如果
select()
返回0
,表示超时,打印 "Time-out!"。 - 如果
select()
返回一个正值,表示有文件描述符准备好了。在这里,我们检查FD_ISSET(0, &temps)
来判断标准输入是否有数据可以读取。
- 如果
-
注意第18、19行,超时时间不能放在这里,如果希望每次
select()
调用都有相同的超时时间(例如 5 秒),应该在每次select()
调用之前重新设置timeout
的值。如果放在这里,则超时时间将会被替换成超时前剩余时间。
运行后若无任何输入
实现 I/O 复用服务器端
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <sys/select.h>
#define BUF_SIZE 100
void error_handling(char *buf);
int main(int argc, char *argv[])
{
int serv_sock, clnt_sock;
struct sockaddr_in serv_adr, clnt_adr;
struct timeval timeout;
fd_set reads, cpy_reads;
socklen_t adr_sz;
int fd_max, str_len, fd_num, i;
char buf[BUF_SIZE];
if(argc!=2) {
printf("Usage : %s <port>\n", argv[0]);
exit(1);
}
serv_sock=socket(PF_INET, SOCK_STREAM, 0);
memset(&serv_adr, 0, sizeof(serv_adr));
serv_adr.sin_family=AF_INET;
serv_adr.sin_addr.s_addr=htonl(INADDR_ANY);
serv_adr.sin_port=htons(atoi(argv[1]));
if(bind(serv_sock, (struct sockaddr*) &serv_adr, sizeof(serv_adr))==-1)
error_handling("bind() error");
if(listen(serv_sock, 5)==-1)
error_handling("listen() error");
FD_ZERO(&reads);
FD_SET(serv_sock, &reads);
fd_max=serv_sock;
while(1)
{
cpy_reads=reads;
timeout.tv_sec=5;
timeout.tv_usec=5000;
if((fd_num=select(fd_max+1, &cpy_reads, 0, 0, &timeout))==-1)
break;
if(fd_num==0)
continue;
for(i=0; i<fd_max+1; i++)
{
if(FD_ISSET(i, &cpy_reads))
{
if(i==serv_sock) // connection request!
{
adr_sz=sizeof(clnt_adr);
clnt_sock=
accept(serv_sock, (struct sockaddr*)&clnt_adr, &adr_sz);
FD_SET(clnt_sock, &reads);
if(fd_max<clnt_sock)
fd_max=clnt_sock;
printf("connected client: %d \n", clnt_sock);
}
else // read message!
{
str_len=read(i, buf, BUF_SIZE);
if(str_len==0) // close request!
{
FD_CLR(i, &reads);
close(i);
printf("closed client: %d \n", i);
}
else
{
write(i, buf, str_len); // echo!
}
}
}
}
}
close(serv_sock);
return 0;
}
void error_handling(char *buf)
{
fputs(buf, stderr);
fputc('\n', stderr);
exit(1);
}
-
if (i == serv_sock)
:如果事件发生在服务器套接字上,则有新的连接请求,调用accept()
接受连接,并将客户端套接字添加到监视集合中。 -
else
:如果事件发生在其他套接字上,则读取数据并将其回写(即回显)。如果客户端关闭连接,则从监视集合中删除相应的文件描述符,并关闭套接字。
基于 Windows 的实现
(一)select()
函数
# include <winsock2.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
其返回值、参数顺序以及含义和 Linux 中的完全相同。
(二)timeval
结构体
struct timeval {
long tv_sec; // Seconds
long tv_usec; // Microseconds
};
(三)基于 Windows 实现 I/O 复用服务器端
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <winsock2.h>
#define BUF_SIZE 1024
void ErrorHandling(char *message);
int main(int argc, char *argv[])
{
WSADATA wsaData;
SOCKET hServSock, hClntSock;
SOCKADDR_IN servAdr, clntAdr;
TIMEVAL timeout;
fd_set reads, cpyReads;
int adrSz;
int strLen, fdNum, i;
char buf[BUF_SIZE];
if(argc!=2) {
printf("Usage : %s <port>\n", argv[0]);
exit(1);
}
if(WSAStartup(MAKEWORD(2, 2), &wsaData)!=0)
ErrorHandling("WSAStartup() error!");
hServSock=socket(PF_INET, SOCK_STREAM, 0);
memset(&servAdr, 0, sizeof(servAdr));
servAdr.sin_family=AF_INET;
servAdr.sin_addr.s_addr=htonl(INADDR_ANY);
servAdr.sin_port=htons(atoi(argv[1]));
if(bind(hServSock, (SOCKADDR*) &servAdr, sizeof(servAdr))==SOCKET_ERROR)
ErrorHandling("bind() error");
if(listen(hServSock, 5)==SOCKET_ERROR)
ErrorHandling("listen() error");
FD_ZERO(&reads);
FD_SET(hServSock, &reads);
while(1)
{
cpyReads=reads;
timeout.tv_sec=5;
timeout.tv_usec=5000;
if((fdNum=select(0, &cpyReads, 0, 0, &timeout))==SOCKET_ERROR)
break;
if(fdNum==0)
continue;
for(i=0; i<reads.fd_count; i++)
{
if(FD_ISSET(reads.fd_array[i], &cpyReads))
{
if(reads.fd_array[i]==hServSock) // connection request!
{
adrSz=sizeof(clntAdr);
hClntSock=
accept(hServSock, (SOCKADDR*)&clntAdr, &adrSz);
FD_SET(hClntSock, &reads);
printf("connected client: %d \n", hClntSock);
}
else // read message!
{
strLen=recv(reads.fd_array[i], buf, BUF_SIZE-1, 0);
if(strLen==0) // close request!
{
FD_CLR(reads.fd_array[i], &reads);
closesocket(cpyReads.fd_array[i]);
printf("closed client: %d \n", cpyReads.fd_array[i]);
}
else
{
send(reads.fd_array[i], buf, strLen, 0); // echo!
}
}
}
}
}
closesocket(hServSock);
WSACleanup();
return 0;
}
void ErrorHandling(char *message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
问答
(一)select 函数的观察对象中应包含服务器端套接字(监听套接字),那么应将其包含到哪一类监听对象集合?
对于服务器端的套接字(监听套接字)来说,它的主要作用是接受新的连接请求。因此,它应被包含在 readfds
集合中,因为当有新的客户端连接到服务器时,监听套接字会变得可读。