TCP/IP 网络编程(十一)--- I/O 复用

135 阅读10分钟

基于 I/O 复用的服务器端构建是并发服务器的第二种实现方法

什么是 I/O 复用

上一章节提到的多进程服务器端每个新连接都要创建一个新的进程,这会导致大量的资源消耗,尤其是在连接数较多的情况下。进程创建和销毁的开销较大,包括内存分配、内核数据结构的维护等。

下图是多进程服务器端的模型:

image.png

“那有何解决方案?能否在不创建进程的同时向多个客户端提供服务?”

I/O 复用就是这样一种技术。简单来说,I/O 复用是提高系统并发能力的一种关键技术,通过让一个线程同时监控多个 I/O 操作,减少了系统资源的消耗,提升了处理效率。

下图是 I/O 复用服务器端模型:

image.png

I/O 复用实现方法之一:select() 函数

运用 select() 函数是最具代表性的实现复用服务器端的方法。

(一)select() 函数的功能

使用 select() 函数时可以将多个文件描述符集中到一起统一监视,项目如下:

  • 是否存在套接字接收数据?(即是否存在可读的数据
    • 这是指某个文件描述符上是否有数据可读,例如在网络编程中,某个套接字上是否有数据到达。如果有数据可读,那么 select() 函数会将该文件描述符标记为可读,允许程序执行相应的读取操作。
  • 无需阻塞传输数据的套接字有哪些?(即是否可以写入数据
    • 这是指某个文件描述符是否可以执行写操作而不会导致阻塞。在网络编程中,这意味着可以将数据写入套接字,例如发送数据。如果套接字准备好写入数据,select() 函数会将该文件描述符标记为可写。
  • 哪些套接字发生了异常?(即是否发生了异常
    • 这是指某个文件描述符上是否发生了异常条件,如带外数据(OOB 数据)或套接字错误。select() 函数可以监视这些异常情况,当异常发生时,它会将该文件描述符标记为异常状态。

(二)select() 函数的调用方法和顺序

image.png

(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() 函数将返回。如果 timeoutNULLselect() 将无限期地阻塞,直到有一个文件描述符准备好。

返回值

  • 成功:返回值是就绪的文件描述符数量,即 readfdswritefdsexceptfds 中标记为就绪的文件描述符的总数。
  • 超时:返回值为 0,表示在指定的时间内没有文件描述符状态发生变化。
  • 失败:返回 -1,并设置 errno 以指示错误原因。

(2)设置文件描述符

select() 函数使用三个 fd_set 类型的集合来监视多个文件描述符。每个集合对应不同的 I/O 操作:

  • 读集合(readfds) :用于监视是否有数据可读。
  • 写集合(writefds) :用于监视是否可以执行非阻塞写操作。
  • 异常集合(exceptfds) :用于监视是否发生了异常情况。

fd_set 结构如下图:

image.png

最左端的位表示文件描述符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) 用于检查文件描述符是否在集合中。

下图解释了这些函数的功能:

image.png

(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 是你希望监视的文件描述符集合(可读集合)。
    • 由于只监视可读状态,writefdsexceptfds 参数设置为 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 的值。如果放在这里,则超时时间将会被替换成超时前剩余时间。

image.png

运行后若无任何输入

实现 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 集合中,因为当有新的客户端连接到服务器时,监听套接字会变得可读。