【网络编程】多路复用的网络 I/O 服务器(C代码),select、poll 和多线程共三个版本

推荐一个零声教育学习教程,个人觉得老师讲得不错,分享给大家:[Linux,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK等技术内容,点击立即学习: https://github.com/0voice 链接

业务拆解

我的上一篇 文章 里头介绍了两个 网络 I/O 无法复用的案例,其中一个可以实现多路连接,而另一个不可以。我们通过使用 while 无限循环 配合接收函数 accept 以实现多路网络 I/O,但是由于没有使用好 recvsend 函数,导致了它不能 “复用”。

在本篇文章之中,我们要实现三个不同的多路复用网络 I/O 的服务器。这三个案例是要吸取这篇 文章 的教训,要改进 recvsend 函数的使用方法,以实现 多路 + 可复用 的网络服务器。

我们还是使用 NetAssist 软件来检测代码的效果。
在这里插入图片描述

准备工作

我们先要准备这些头文件

#include <errno.h>				// 这是全局变量 errno,用于健壮的读取功能
#include <stdio.h>
#include <sys/socket.h>			// 创建和管理套接字。绑定地址、监听连接和接受连接。发送和接收数据。设置和获取套接字选项。 socket()、connect()、sendto()、recvfrom()、accept()
#include <netinet/in.h>			// 提供了结构体 sockaddr_in
#include <string.h>
#include <unistd.h>				// close 函数,关闭套接字
#include <pthread.h>			// 一线程一请求的连接模式
#include <sys/select.h>			// select 事件触发机制 
#include <poll.h>				// POLL 事件触发机制
#include <sys/epoll.h>			// EPOLL 事件高并发机制

以及占用端口建立服务器,用以监听来访 IP 的网络 I/O 套接字的函数 init_server

int init_server(unsigned short port) {

	int sockfd = socket(AF_INET, SOCK_STREAM, 0);

	struct sockaddr_in servaddr;
	servaddr.sin_family = AF_INET;
	servaddr.sin_addr.s_addr = htonl(INADDR_ANY); // 0.0.0.0
	servaddr.sin_port = htons(port); // 0-1023, 

	// 设置端口复用
    // 当服务器主动关闭 TCP 连接时,会进入 TIME_WAIT 状态(通常持续 2MSL,约 1-4 分钟)。在此期间,操作系统会保留该端口绑定记录,防止延迟到达的数据包干扰新连接。
    // 问题:服务器崩溃或重启后尝试重新绑定端口时,会因 TIME_WAIT 状态导致 bind() 失败(错误:Address already in use) 
    // 以下处理措施:能避免再次启用服务器程序时,系统的宕机
    int reuse = 1;
    if (setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)) < 0) {
        //  如果未设置 SO_REUSEADDR,导致端口被占用后无法立即重用(TIME_WAIT 状态)
        printf("setsockopt failed: %s\n", strerror(errno));
        return -1;
    }
    // setsockopt 是一个用于设置套接字选项的系统调用函数。
    // 第二项参数 level:指定选项所在的协议级别。常见的值包括:SOL_SOCKET:表示套接字级别的选项。IPPROTO_TCP:表示 TCP 协议级别的选项。IPPROTO_IP:表示 IP 协议级别的选项。IPPROTO_IPV6:表示 IPv6 协议级别的选项。
    // 第三项参数 optname:指定要设置的选项名称。不同的协议级别有不同的选项名称。例如:在 SOL_SOCKET 级别,常见的选项包括 SO_REUSEADDR、SO_KEEPALIVE、SO_LINGER 等。在 IPPROTO_TCP 级别,常见的选项包括 TCP_NODELAY 等。
    // 第四项参数 optval:指向包含选项值的内存区域。选项值的类型和大小取决于 optname
    // 第五项参数 optlen:指定 optval 的长度(以字节为单位)。


	if (-1 == bind(sockfd, (struct sockaddr*)&servaddr, sizeof(struct sockaddr))) {
		printf("bind failed: %s\n", strerror(errno));	//	strerror 函数定义在 <string.h> 中,而 errno 是 <errno.h> 的全局变量
		return -1;
	}

	//	一次监听 10 个来访 IP:PORT
	//	printf("listen finshed: %d\n", sockfd); // 3 
	if (listen(sockfd, 10) < 0) {
        // 将套接字设置为被动模式:套接字从主动连接模式(用于客户端)转换为被动监听模式(用于服务器)。我们可以把这个 socket 想象成公司的前台小姐。
        // 5:是监听队列的最大长度,表示系统可以为该套接字排队的最大未完成连接数。当新的连接请求到达时,如果队列已满,新的连接请求将被拒绝。
        // 返回值:成功,返回 0。失败,返回 -1,并设置 errno 以指示错误原因。
        printf("listen finshed: %d\n", sockfd); 
        return -1;
    }

	return sockfd;

}

案例一:一 I/O 一线程的多线程版本

该案例下,一 I/O 一线程的服务器模式可以直观成以下流程图

Created with Raphaël 2.3.0 开始 init_server 占用端口建立服务器,使之处于监听状态 是否有来访 IP accept 接受客户端的连接 在系统中分配一个套接字,负责对接该 IP 的 I/O 任务 专门为该套接字分配一个线程,该线程所调用的回调函数是负责 I/O 任务的 是否有新的来访 IP 暂时挂起主线程,直至有来访 IP(不影响任务线程) yes no yes no

该模式下的网络 I/O 是由程序创建任务线程所调用的回调函数来负责的。

案例一的 C 代码

负责 I/O 任务的回调函数如下。

// 	这是一个针对于线程的回调函数
void *client_thread(void *arg) {

	int clientfd = *(int *)arg;

	while (1) {
		
		char buffer[1024] = {0};						// 不断地重置缓冲区
		int count = recv(clientfd, buffer, 1024, 0);	// 读取内容,“0” 是套接字 clientfd 对应网络 I/O 文件的默认权限模式
		if (count == 0) { // disconnect
			printf("client disconnect: %d\n", clientfd);
			close(clientfd);							// 关闭 I/O
			break;
		}
		// parser

		printf("RECV: %s\n", buffer);

		count = send(clientfd, buffer, count, 0);	// 对于服务器来说是先读内容后发回信;对于客户端来说
		printf("SEND: %s\n", buffer);

	}

}

该回调函数又内置了一个 while 无限循环 以达成 “可复用” 网络 I/O 的服务器。为了理解这一句话我们需要理解 recv 到底是一个什么样的函数。

如果套接字(socket)被设置为阻塞模式(默认模式),recv 函数会阻塞调用线程,直到满足以下条件之一:

  • 接收到数据:当有数据到达时,recv 函数会返回接收到的数据长度。
  • 发生错误:如果发生错误(如连接中断、套接字关闭等),recv 函数会返回一个负值,并设置相应的错误码。
  • 到达文件结束符(EOF):如果对端关闭了连接,recv 函数会返回0,表示没有更多数据可读。

也就是说本案例的套接字 clientfd 自建立以来,就一直处于阻塞模式下,只要网络连接所对应的套接字还在以及客户端不发消息,该任务线程便会一直阻塞在recv函数处,客户端发一下消息,该任务线程就动一动。是 while 无限循环 + 阻塞模式的套接字 + send/recv 才实现了本案例的网络 I/O 的复用。

服务器可以实现如下,while 无限循环 实现 “多路连接的关键”,加上前面的回调函数性质可不就是多路复用网络服务器。

int main() {


	int sockfd = init_server(PORT);

	struct sockaddr_in  clientaddr;			// 申请地址的内存空间
	socklen_t len = sizeof(clientaddr);

	while (1) {

		printf("accept\n");
		int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
		if (clientfd < 0) {
			printf("accept failed\n");
		} else {
			printf("accept finshed: %d\n", clientfd);
		}

		pthread_t thid;
		pthread_create(&thid, NULL, client_thread, &clientfd);		// 	每一个网络 I/O 的多次交流都在不同的线程栈上进行

	}
	printf("exit\n");

	return 0;
}

案例一的代码运行效果

代码编译(关于 Linux 多线程编程的使用案例,读者可以参考我的这篇 文章)。

qiming@qiming:~/share/CTASK/TCP_test$ gcc -o networkio networkio.c -lpthread

程序执行,一开始没有来访 IP 的时候如下

qiming@qiming:~/share/CTASK/TCP_test$ ./networkio
accept

连接该服务器
在这里插入图片描述
我们发现,该发送 按钮是可以重复按下的,可实现信息的多次发送,命令行中便会出现以下现象(我总共摁了5次)

qiming@qiming:~/share/CTASK/TCP_test$ ./networkio
accept
accept finshed: 4
accept
RECV: I'm qiming,the apprentice of 0voice school.
SEND: I'm qiming,the apprentice of 0voice school.
RECV: I'm qiming,the apprentice of 0voice school.
SEND: I'm qiming,the apprentice of 0voice school.
RECV: I'm qiming,the apprentice of 0voice school.
SEND: I'm qiming,the apprentice of 0voice school.
RECV: I'm qiming,the apprentice of 0voice school.
SEND: I'm qiming,the apprentice of 0voice school.
RECV: I'm qiming,the apprentice of 0voice school.
SEND: I'm qiming,the apprentice of 0voice school.

也是可以实现多路复用的,
在这里插入图片描述
命令行出现了以下现象

qiming@qiming:~/share/CTASK/TCP_test$ ./networkio
accept
accept finshed: 4
accept
RECV: I'm qiming,the apprentice of 0voice school.
SEND: I'm qiming,the apprentice of 0voice school.
RECV: I'm qiming,the apprentice of 0voice school.
SEND: I'm qiming,the apprentice of 0voice school.
RECV: I'm qiming,the apprentice of 0voice school.
SEND: I'm qiming,the apprentice of 0voice school.
RECV: I'm qiming,the apprentice of 0voice school.
SEND: I'm qiming,the apprentice of 0voice school.
RECV: I'm qiming,the apprentice of 0voice school.
SEND: I'm qiming,the apprentice of 0voice school.
accept finshed: 5
accept
RECV: I'm qiming,the apprentice of 0voice school.
SEND: I'm qiming,the apprentice of 0voice school.
RECV: I'm qiming,the apprentice of 0voice school.
SEND: I'm qiming,the apprentice of 0voice school.
RECV: I'm qiming,the apprentice of 0voice school.
SEND: I'm qiming,the apprentice of 0voice school.
RECV: I'm qiming,the apprentice of 0voice school.
SEND: I'm qiming,the apprentice of 0voice school.
RECV: I'm qiming,the apprentice of 0voice school.
SEND: I'm qiming,the apprentice of 0voice school.
RECV: I'm qiming,the apprentice of 0voice school.
SEND: I'm qiming,the apprentice of 0voice school.
RECV: I'm qiming,the apprentice of 0voice school.
SEND: I'm qiming,the apprentice of 0voice school.

只是该版本的多路复用网络服务器有一个致命的缺点:一个任务线程负责服务一个 IP 的 I/O 请求,一个线程对计算机资源的占用是极大的,8M,这导致了服务器的性能变差,多路连接的数量天花板很低,可实现连接数相对较少。

案例二:SELECT 版本

select 是一种用于实现多路复用(I/O 多路复用)的系统调用,它允许程序同时监视多个文件描述符(包括套接字),以确定哪些文件描述符已经准备好进行读、写或异常操作。在服务器中编程,select 可以用来同时处理多个客户端连接,提高服务器的并发处理能力。

select 多路复用 I/O 会使用到结构体类型 fd_set 。fd_set 是一个用于表示文件描述符集合的数据结构,通常用于 select 系统调用中。它是一个位掩码数组,每个位对应一个文件描述符,

typedef struct {
	 unsigned long fds_bits[__FD_SETSIZE / (8 * sizeof(unsigned long))]; 
} fd_set;
  1. __FD_SETSIZE
  • __FD_SETSIZE 是一个宏定义,表示 fd_set 能够处理的最大文件描述符数量。在大多数系统中,默认值是 1024。例如,如果 __FD_SETSIZE 是 1024,那么 fd_set 可以处理从 0 到 1023 的文件描述符。
  1. fds_bits
  • fds_bits 是一个数组,每个元素是一个 unsigned long 类型的值,用于存储位掩码。
  • 每个 unsigned long 类型的值通常有 32 或 64 位(取决于平台),因此可以表示 32 或 64 个文件描述符。
  • 数组的大小是 FD_SETSIZE / (8 * sizeof(unsigned long)),确保整个数组可以表示 FD_SETSIZE 个文件描述符。

这是 fd_set 套接字集合,而且是映射集,第 n 个二进制位表示对应的第 n 个文件描述符,取值 0 或 1 表示该文件 I/O 是否就绪。

select 多路复用 I/O 除了 fd_set 结构体外,还有 FD_ZEROFD_SETFD_CLRFD_ISSET 宏操作方法,具体的程序流程图如下。

Created with Raphaël 2.3.0 开始 init_server 占用端口建立服务器套接字 sockfd,使 sockfd 处于监听状态 宏 FD_ZERO 初始化套接字/描述符集合 fd_set 宏 FD_SET 把监听套接字 sockfd 加入到 fd_set 之中 把当前的最大的套接字设置为 sockfd 调用 select 函数,调动操作系统内核检查 fd_set 的各个文件描述符的 I/O 情况,并记录下来。 宏 FD_ISSET 检查 sockfd 是否监听到有来访 IP accept 接受客户端的连接,并分配一个套接字 clientfd 负责对接 宏 FD_SET 把新设定的套接字 clientfd 加入到 fd_set 之中 视情况更新当前最大的套接字整型 宏 FD_ISSET “遍历式” 地逐个检查 fd_set 中除 sockfd 以外所有的套接字的 I/O 情况 recv 函数接收信息 连接是否断开(recv 判断) 宏 FD_CLR 移除套接字集合 fd_set 中的对应的连接断开的套接字 更新当前的套接字集合 fd_set send 执行网络输出任务 yes no yes no

虽然这个 SELECT 版本的服务器对计算机资源的占用没有前一个版本的多,但由于该 I/O 机制是靠遍历全部 I/O 文件来执行的,时间复杂度是 O(n),效率相对较低。另外,套接字的模式是默认的阻塞模式,不利于服务器的性能提升。

案例二的 C 代码

和一 I/O 一线程的模式一样的是,服务器的监听套接字 sockfd 和其他的网络 I/O 套接字都是 “阻塞模式”,acceptrecvsend 函数都是在接收不到信号的时候会阻塞进程,因而需要条件判断的宏 FD_ISSET,以绕过阻塞。实际上是以逻辑判断绕过阻塞。

select 函数允许程序同时监视多个文件描述符(包括套接字),以确定哪些文件描述符已经准备好进行读、写或异常操作。select 函数的原型如下,

#include <sys/select.h>
#include <sys/time.h>

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
  • nfds:
    • 指定要监视的最大文件描述符加1。例如,如果最大的文件描述符是 10,那么 nfds 应该设置为 11。
    • 这个参数确保 select 只检查从 0 到 nfds-1 的文件描述符。
  • readfds:
    • 指向 fd_set 类型的指针,表示需要监视的文件描述符集合,这些文件描述符准备好进行读操作时,select 会返回。
    • 如果不需要监视读操作,可以设置为 NULL。
  • writefds:
    • 指向 fd_set 类型的指针,表示需要监视的文件描述符集合,这些文件描述符准备好进行写操作时,select 会返回。
    • 如果不需要监视写操作,可以设置为 NULL。
  • exceptfds:
    • 指向 fd_set 类型的指针,表示需要监视的文件描述符集合,这些文件描述符准备好进行异常操作时,select 会返回。
    • 如果不需要监视异常操作,可以设置为 NULL。
  • timeout:
    • 指向 struct timeval 类型的指针,表示 select 调用的超时时间。
    • 如果设置为 NULL,select 将阻塞直到至少有一个文件描述符准备好。
    • 如果设置为 {0, 0},select 将立即返回,不会阻塞。
  • 返回值
    • 返回值 > 0:表示有文件描述符准备好,返回值是准备好的文件描述符的数量。
    • 返回值 == 0:表示在超时时间内没有文件描述符准备好。
    • 返回值 < 0:表示发生错误,通常是因为无效的文件描述符或超时参数。

本案例中的 select 函数是非阻塞的,尽管所有套接字都是阻塞的。而且 rset 是会被传出参数的,操作系统内核会去主动修改套接字集合的。只要套接字所代表的 I/O 文件内还有数据,那对应集合中的映射值依旧为 1,表示此文件还有 I/O 事件没完成。

另外,select 机制水平触发 (level_triggle)的,本次读取还没进行完,就下次继续读,直至该套接字在 fd_set 中的映射显示 0。就像水位警示器一样,只要潮汐危机一刻未解除,就一直发出信号警报。

int main() {


	int sockfd = init_server(PORT);

	struct sockaddr_in  clientaddr;			// 申请地址的内存空间
	socklen_t len = sizeof(clientaddr);
	
	fd_set rfds, rset;		// 	这是套接字集合,rfds 是总体文件描述符集合,rset 将会是 rfds 的复制品

	FD_ZERO(&rfds);			// 	置零
	FD_SET(sockfd, &rfds);	//  先把用于监听的 sockfd 加入 rfds 之中

	int maxfd = sockfd;		// 	一开始没有接收 I/O 的时候,最大的 fd 就是 sockfd

	while (1) {
		rset = rfds;		// 	把 rfds 复制过来,保护数据 rfds

		struct timeval tv; 	// 	timeval 是一个 <sys/time.h> 结构体,用于表示时间值,通常用于指定时间间隔或时间戳。
		tv.tv_sec = 0;
		tv.tv_usec = 5000; 	// 	5 毫秒= 5000 微秒

		int nready = select(maxfd+1, &rset, NULL, NULL, &tv);		// 返回就绪集的数量
		//	select 函数处理的总集合是 0 ~ maxfd
		// 	所关心的是集合 rset 内的读事件就绪情况
		// 	select 函数的另外三个参数分别是,所关注的可写事件集合、错误事件集合、延时时间 5 毫秒

		if (FD_ISSET(sockfd, &rset)) { // accept
			//	宏操作 FD_ISSET 检查就绪集 rset 中是否有监控套接字 sockfd

			int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);		// 	从监控名单中抽取一位来访 IP:port 的 I/O 注册成一个新的客户 fd
			if (clientfd < 0) {
				printf("accept failed\n");
			} else { 
				printf("accept finshed: %d\n", clientfd);
			}
	
			FD_SET(clientfd, &rfds); 					// 	将套接字映射集合 rfds 的第 clientfd 位置为 1;这等价于说在套接字集合 rfds 上注册 clientfd 
			
			if (clientfd > maxfd) maxfd = clientfd;		//  新的文件描述符的加入,意味着原来的文件描述符的范围变大了
		}

		// recv
		// 这里就是我们复制 rfds 的原因,前面 rfds 已经因为注册新 clientfd 的原因,得到了更新。但任务还没完成,还要接着处理,故而使用 rset。
		int i = 0;
		for (i = sockfd+1; i <= maxfd;i ++) { 		// i fd

			if (FD_ISSET(i, &rset)) {		// 	检测在集合 rset 的第 i 个文件描述符位是否为 1,表示这个 I/O 文件就是我们要处理的读写任务
				char buffer[1024] = {0};	//	缓冲区重置为 0
				
				int count = recv(i, buffer, 1024, 0);	// 	读取内容,
				if (count == 0) { // disconnect			// 	连接失败的情况
					printf("client disconnect: %d\n", i);
					close(i);				//	关闭套接字 i 所对应的 I/O 文件,并且使得该文件所绑定的远程客户端 IP:port 可以重新被 sockfd 所 listen 监听到
					FD_CLR(i, &rfds);		// 	移除文件描述符,准确说是把 rfds 的第 i 个套接字置为 0		
					
					continue;
				}
				//	该条件判断其实体现了 select 多路复用的模式是 Level-triggle 水平触发
				//	原因是每次检测都会检查所有的文件描述符是否可读,如果可读那就继续读,这就是所谓的水平触发
				//  如果发现读取完了,那就把这个连接关闭,下次该远程客户端可以被 listen 监听到

				printf("RECV: %s\n", buffer);	

				count = send(i, buffer, count, 0);	// 	服务器是先读取后发送
				printf("SEND: %d\n", count);

			}

		}
		
	}
	printf("exit\n");

	return 0;
}

案例二的代码运行效果

代码编译

qiming@qiming:~/share/CTASK/TCP_test$ gcc -o networkio networkio.c 

执行程序,一开始没有来访 IP,程序并不是处于挂起状态,而是处于 while 无限循环之中。

qiming@qiming:~/share/CTASK/TCP_test$ ./networkio

连接服务器
在这里插入图片描述
命令行出现的现象是

qiming@qiming:~/share/CTASK/TCP_test$ ./networkio
accept finshed: 4
RECV: I'm qiming,the apprentice of 0voice school.
SEND: 45
RECV: I'm qiming,the apprentice of 0voice school.
SEND: 45
RECV: I'm qiming,the apprentice of 0voice school.
SEND: 45

我这里也发送了三次信息。它当然也是可以实现多路复用的,
在这里插入图片描述
命令行的现象(在新的连接上,我发了两条消息,故而总共5条消息)

qiming@qiming:~/share/CTASK/TCP_test$ ./networkio
accept finshed: 4
RECV: I'm qiming,the apprentice of 0voice school.
SEND: 45
RECV: I'm qiming,the apprentice of 0voice school.
SEND: 45
RECV: I'm qiming,the apprentice of 0voice school.
SEND: 45
accept finshed: 5
RECV: I'm qiming,the apprentice of 0voice school.
SEND: 45
RECV: I'm qiming,the apprentice of 0voice school.
SEND: 45

虽然这个 SELECT 版本的服务器对计算机资源的占用没有前一个版本的多,但由于该 I/O 机制是靠遍历全部 I/O 文件来执行的,时间复杂度是 O(n),效率相对较低。另外,套接字的模式是默认的阻塞模式,不利于服务器的性能提升。

案例三:POLL 版本

POLL机制是一种基于事件触发的 I/O 多路复用机制,它可以同时监视多个文件描述符,当其中任意一个文件描述符就绪时,就会通知程序进行相应的读写操作。该事件触发机制围绕着一个特殊的结构体类型 struct pollfd

struct pollfd {
	int   fd;         文件描述符 
	short events;      请求监控的事件 (输入) 
	short revents;    实际发生的事件 (输出) 
};

其中 events 是应用程序告诉内核"我关心该套接字的什么事件",revents 是内核告诉应用程序"该套接字发生了什么事件",内核通过此字段通知应用程序具体发生的事件类型,标识文件描述符的当前就绪状态。

  • POLL机制的实现原理
    • 用户将想要监听的 socket 文件绑定 struct pollfd 对象,并注册监听事件至 struct pollfd 对象 events 成员,监听多个 socket 文件使用 struct pollfd 数组。
    • 用户通过 struct pollfd 数组注册poll事件至 poll_list 链表,poll_list 链表单个元素可以存储固定数量的 struct pollfd 对象。
    • poll系统调用采用轮询方式获取 socket 事件信息,一次 poll 调用需完成整个poll_list 链表轮询工作,轮询 socket 的过程中会创建 socket 等待队列项,并加入socket 等待队列(用于 socket 唤醒进程)。如果检测到 socket 处于就绪状态,将socket 事件保存在 struct pollfd 对象的 revents 成员。
    • poll系统调用完成一次轮询后,如果检测到有 socket 处于就绪状态,则将poll_list 链表所有的 struct pollfd 通过 copy_to_user 拷贝至用户 struct pollfd 数组。如果未检测到有 socket 处于就绪状态,根据超时时间确定是否返回或者阻塞进程。

具体可见以下的流程图

Created with Raphaël 2.3.0 开始 init_server 占用端口建立服务器套接字 sockfd,使 sockfd 处于监听状态 创建长度固定的事件数组 struct pollfd,作为总集 把 sockfd 加入 pollfd,其 events 设置成 POLLIN 把当前的最大的套接字设置为 sockfd 调用 poll 函数,同时监视多个文件描述符的状态变化(如可读、可写、异常等) 检查 sockfd 是否监听到有来访 IP accept 接受客户端的连接,并分配一个套接字 clientfd 负责对接 把新设定的套接字 clientfd 注册到 struct pollfd 数组之中 视情况更新当前最大的套接字整型 “遍历式” 地逐个检查 struct pollfd 数组中除 sockfd 以外所有的套接字的 I/O 情况 检查当前所遍历的套接字是否发生了 POLLIN 事件 recv 函数接收信息 连接是否断开(recv 判断) 移除 struct pollfd 数组之中的对应的连接断开的套接字 更新当前的 struct pollfd 数组 send 执行网络输出任务 yes no yes no yes no

案例三的 C 代码

函数 init_server 的定义与前文一样。该服务器的代码如下。

int main() {


	int sockfd = init_server(PORT);

	struct sockaddr_in  clientaddr;			// 申请地址的内存空间
	socklen_t len = sizeof(clientaddr);

	struct pollfd fds[1024] = {0};		//  我们的查询最多只关注 1024 个事件
	fds[sockfd].fd = sockfd;			//  注册监听套接字 sockfd 入关心的套接字集合
	fds[sockfd].events = POLLIN;		//	我们关注的是 sockfd 的读事件信息
	
	int maxfd = sockfd;		//	一开始,套接字序列最大也就监听套接字 sockfd

	while (1) {

		int nready = poll(fds, maxfd+1, -1);
		//	poll 函数用于同时监视多个文件描述符的状态变化(如可读、可写、异常等)
		//	fds 表示事件总集,maxfd + 1 表示当前所监控的套接字的实际数量,-1 表示表示无限阻塞:直到至少一个文件描述符就绪或捕获到信号才返回。
		//	高效监控多个描述符:通过一次系统调用监控 fds 数组中所有描述符,比顺序检查每个描述符更高效(指代 select 机制)
		//	事件通知机制:当某个描述符就绪(如数据到达、可写、断开连接等),内核会唤醒进程并返回就绪描述符数量
		//	内核监控所有 fds 数组中指定的文件描述符,
		//	网络 I/O 是自动发生的,是客户端决定的
		//	poll 函数的内核操作:
		// 		1. 直接操作 fds 数组(无需全量拷贝),内核会根据我们所关心的内容进行检查
		// 		2. 仅遍历数组中的有效描述符
		// 		3. 将就绪事件写入 revents(操作系统写的)


		if (fds[sockfd].revents & POLLIN) {		//	sockfd 所发生的事件是否是一个 “读事件”

			int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);		//	接收信号
			if (clientfd < 0) {
				printf("accept failed\n");
			} else {
				printf("accept finshed: %d\n", clientfd);
			}
			
			//FD_SET(clientfd, &rfds); // 
			fds[clientfd].fd = clientfd;	// 	注册 clientfd
			fds[clientfd].events = POLLIN;	//	我们关心 clientfd 的读事件
			
			if (clientfd > maxfd) maxfd = clientfd;		//	扩大关注范围

		}
	
		int i = 0;
		for (i = sockfd+1; i <= maxfd;i ++) { // i fd

			if (fds[i].revents & POLLIN) {  // 	这是二进制运算的 “按位与” 运算,clientfd 所发生的事件是否是一个 “读事件”

				char buffer[1024] = {0};	//	内存重置
				
				int count = recv(i, buffer, 1024, 0);	//	读取内存
				if (count == 0) { // disconnect
					printf("client disconnect: %d\n", i);
					close(i);				//	关闭该网络 I/O

					fds[i].fd = -1;
					fds[i].events = 0;	// 	重置事件
					
					continue;
				}
				//	该条件判断其实体现了 POLL 多路复用的模式是 Level-triggle 水平触发
				//	原因是每次检测都会检查所有的文件描述符是否可读,如果可读那就继续读,这就是所谓的水平触发
				//  如果发现读取完了,那就把这个连接关闭,下次该远程客户端可以被 listen 监听到


				printf("RECV: %s\n", buffer);

				count = send(i, buffer, count, 0);
				printf("SEND: %d\n", count);

			}
		
		}

	}
	printf("exit\n");

	return 0;
}

注意到函数 poll 是连接可复用的关键,

#include <poll.h>

int poll(struct pollfd *fds, nfds_t nfds, int timeout);
  • struct pollfd *fds
    • 这是一个指向 struct pollfd 类型数组的指针。
  • nfds_t nfds
    • 指定 fds 数组中包含的 struct pollfd 结构的数量。
  • int timeout
    • 指定 poll 函数的超时时间,单位为毫秒。
    • 如果 timeout 为 -1,poll 将无限期阻塞,直到有文件描述符就绪。
    • 如果 timeout 为 0,poll 将立即返回,不会阻塞。
    • 如果 timeout 为正数,poll 将阻塞指定的毫秒数,如果在超时时间内没有文件描述符就绪,则返回。
  • 返回值
    • 成功:返回就绪的文件描述符的数量。
    • 失败:返回 -1,并设置 errno。
    • 超时:返回 0。

对套接字的事件检查是靠位运算执行的。

案例三的代码运行效果

代码编译

qiming@qiming:~/share/CTASK/TCP_test$ gcc -o networkio networkio.c 

程序执行,一开始没有外来连接时,程序挂起

qiming@qiming:~/share/CTASK/TCP_test$ ./networkio

读者可以根据前面的演示方法,进行尝试。

总结

服务器模式监听套接字模式使连接可复用的方法时间复杂度计算机资源占用
一 I/O 一请求阻塞创造任务线程,无限循环执行阻塞的 recv 和 send 函数O(1)极大
SELECT阻塞无限循环的执行 select 函数(每次执行都需要调用操作系统内核遍历所有套接字对应的 I/O 文件),使用条件判断绕开阻塞的 recv 和 send 函数O(N)相对较少
POLL阻塞无限循环的执行 poll 函数(每次执行都需要调用操作系统内核遍历所有套接字对应的 I/O 文件),使用条件判断绕开阻塞的 recv 和 send 函数O(N)相对较少

poll 机制相对于 select 机制具有以下优势:

  • 没有文件描述符数量限制,可以处理任意数量的文件描述符。
  • 更灵活的事件类型,支持多种事件类型。
  • 更好的可扩展性,适用于高并发场景。
  • 更简单的使用方式,初始化和操作更加直观。

那么我们如何再次改进服务器的性能呢?使其查询的时间复杂度更低,而且对计算机的资源占用更低?这就是 EPOLL,我们还将介绍其百万并发。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值