I/O多路复用

 

文章目录

前言

一、IO 多路复用的三种实现方式

1. select机制

2. poll机制

3. epoll机制


前言

http://t.csdnimg.cn/Vn9aw这篇文章中介绍使用TCP通讯协议实现的客户端以及服务端实现进程间的网络通信。这里TCP服务端是通过创建进程的方式来处理每一个客户端的连接。当并发量不是很大时,这种处理方式还可以使用。一旦并发量很大,频繁创建的进程会带来巨大的资源消耗以及上下文切换消耗。而IO多路复用技术的核心是减少服务端线程的创建,通过使用较少线程处理所有请求的方式提高整体效率,可以很好的解决这个问题。有关IO多路复用技术的详细介绍见https://www.cnblogs.com/MyXjil/p/17478795.html这篇文章。


一、IO 多路复用的三种实现方式

在Linux系统中,常见的IO多路复用实现方式主要有三种:select、poll和epoll。下面分别介绍这三种方式的特点和用法。

1. select机制

原理与特点

  • select是一个系统调用函数,用于监视多个文件描述符的状态变化。
  • 它将多个文件描述符集合传递给内核,由内核监视这些文件描述符的状态(可读、可写、异常)。
  • 当有文件描述符就绪或超时发生时,select函数返回,程序根据返回的文件描述符集合进行相应的读写操作。

限制与缺点

  • 文件描述符集合的大小有限制,通常是1024个,这限制了select能够同时监视的文件描述符数量。
  • select在每次调用时都需要将文件描述符集合从用户态拷贝到内核态,并在返回时将结果从内核态拷贝回用户态,这增加了额外的开销。
  • select使用轮询方式检查文件描述符集合,效率较低,特别是在文件描述符数量较多的情况下。

函数原型

fd_set 是文件描述符的集合,使用以下函数操作:
void FD_CLR(int fd, fd_set *set);
功能:从集合set中删除fd文件描述符

int  FD_ISSET(int fd, fd_set *set);
功能:判断集合set中是否存在fd文件描述符

void FD_SET(int fd, fd_set *set);
功能:向集合set中添加fd文件描述符

void FD_ZERO(fd_set *set);
功能:清空集合set

int select(int nfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds, struct timeval *timeout);
功能:同时监控多个文件描述的读、写、异常操作
    nfds:被监控的文件描述符中的最大值+1
    readfds:监控读操作的文件描述符集合
    writefds:监控写操作的文件描述符集合
    exceptfds:监控异常操作的文件描述符集合
    timeout:设置超时时间
        NULL    一直阻塞,直到某个文件描述符发生了变化
        0秒0微秒 非阻塞
        大于0秒 等待超时时间,超时返回0
        struct timeval {
               long    tv_sec;  //  秒
               long    tv_usec; //  微秒
           };
    返回值:监控到发生相关操作的文件描述符的个数,超时返回0,错误返回-1

实现 :

代码如下(示例): 

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/select.h>
#include <sys/time.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

/*TCP服务端调用select函数实现多路复用*/

int main(int argc , const char* argv[])
{
	int svr_fd = socket(AF_INET, SOCK_STREAM, 0);
	if(svr_fd < 0)
	{
		perror("socket");
		exit(1);
	}

	struct sockaddr_in addr = {};
	addr.sin_family = AF_INET;
	addr.sin_port = htons(8888);
	addr.sin_addr.s_addr = inet_addr("127.0.0.1"); // 绑定回环地址

	socklen_t addrlen = sizeof(addr);
	if(bind(svr_fd, (struct sockaddr *)&addr, addrlen))
	{
		perror("bind");
		exit(1);
	}

	if(listen(svr_fd, 10))
	{
		perror("listen");
		exit(1);
	}
	
	// 定义一个文件描述符集合
	fd_set reads;
	FD_ZERO(&reads);

	// 把需要等待的socket描述符添加到集合中
	FD_SET(svr_fd, &reads);

	// 定义超时时间
	struct timeval timeout = {5, 0};

	// 记录下最大的socket描述符
	int max_fd = svr_fd; // 当前
	
	char buf[4096] = {};
	size_t buf_size = sizeof(buf);

	while(1)
	{
		// 若有多个新的连接,集合会发生变化。会把之前没发生变化的文件描述符覆盖
		// 备份之前文件描述符集合
		fd_set reads_copy = reads;

		// 调用select监控多个文件描述
		int ret = select(max_fd+1, &reads_copy, NULL, NULL, &timeout);
		if(ret < 0)
		{
			perror("select");
			exit(1);
		}
		else
		{
			// 测试网络等待的socket描述符,检查服务器是否有新的连接请求。
			if(FD_ISSET(svr_fd, &reads_copy))
			{
				// 调用accept连接客户端
				int cli_fd = accept(svr_fd, (struct sockaddr *)&addr, &addrlen);
				if(cli_fd < 0)
				{
					perror("accept");	
				}
				else
				{
					// 把客户端的socket描述符添加到监控集合中
					FD_SET(cli_fd, &reads);
					if(cli_fd > max_fd)
					{
						max_fd = cli_fd; // 记录被监控文件描述符的最大值	
					}
				}
			}
			else
			{
				// 没有新的连接请求,测试其他socket描述符是否发生读操作
				for(int fd = 3; fd <= max_fd; fd++) // 遍历文件描述符0、1、2(标准输入、输出、错误)之外的所有文件描述符
				{
					if(FD_ISSET(fd, &reads_copy) && fd != svr_fd) // 找到连接的客户端文件描述符
					{
						int ret = recv(fd, buf, buf_size, 0);
						if(ret <= 0)
						{
							FD_CLR(fd, &reads); // 从集合中删除fd文件描述符
							printf("客户端%d退出\n", fd);
							continue;
						}
						printf("recv:%s bits:%d\n", buf, ret);
						strcat(buf, ":return");
						ret = send(fd, buf, strlen(buf)+1, 0);
						if(ret <= 0 || strcmp("quit", buf) == 0)
						{
							FD_CLR(fd, &reads);	
							printf("客户端%d退出\n", fd);
							continue;
						}
					}
				}
			}
		}
	}
	return 0; 
}

代码说明:

1. 初始化套接字

  • 使用socket函数创建一个TCP套接字(AF_INET表示使用IPv4地址,SOCK_STREAM表示使用TCP协议)。
  • 如果套接字创建失败,则打印错误信息并退出程序。

2. 绑定套接字

  • 设置sockaddr_in结构体,指定服务器将监听的IP地址(这里使用回环地址127.0.0.1,即仅监听本地连接)和端口号(8888)。
  • 使用bind函数将套接字与指定的地址和端口绑定。
  • 如果绑定失败,则打印错误信息并退出程序。

3. 监听连接

  • 使用listen函数使套接字进入监听状态,准备接受客户端的连接请求。这里设置的监听队列长度为10。
  • 如果监听失败,则打印错误信息并退出程序。

4. 使用select处理多个连接

  • 初始化一个文件描述符集合reads,用于存放需要检查的套接字描述符。
  • 将服务器套接字描述符svr_fd添加到reads集合中。
  • 设置超时时间timeout,这里设置为5秒。
  • 进入一个无限循环,使用select函数等待一个或多个套接字变为可读状态。

5. 处理可读套接字

  • select返回后,首先检查服务器套接字svr_fd是否可读(即是否有新的连接请求)。
    • 如果有新的连接请求,使用accept函数接受连接,并获取新的客户端套接字描述符cli_fd
    • cli_fd添加到reads集合中,以便后续可以检查其可读状态。
    • 更新max_fd为当前最大的文件描述符,以便select能正确检查所有套接字。
  • 如果没有新的连接请求,遍历reads集合中除了服务器套接字外的所有套接字。
    • 使用recv函数从客户端套接字读取数据。
    • 如果读取到数据,则打印数据,并构造响应消息发送回客户端。
    • 如果读取到0字节或发生错误,则认为客户端已关闭连接,从reads集合中移除该套接字描述符,并打印客户端退出的信息。
    • 如果接收到的消息是"quit",则同样关闭连接并从reads集合中移除该套接字描述符。

2. poll机制

原理与特点

  • poll是select的改进版,它使用结构体数组而不是位图来表示要监视的文件描述符。
  • 这种方式消除了文件描述符数量的限制,因为数组的大小可以根据需要动态调整。
  • poll同样需要将文件描述符集合从用户态拷贝到内核态,但由于使用了结构体数组,其表示方式更加灵活和高效。

限制与缺点

  • 虽然poll没有文件描述符数量的限制,但它仍然需要复制大量的数据在用户态和内核态之间,这在文件描述符数量较多时会导致性能下降。
  • poll同样使用轮询方式检查文件描述符集合,效率较低。

函数原型

int poll(struct pollfd *fds,nfds_t nfds,int timeout);
fds:struct pollfd结构变量数组
    struct pollfd {
    int   fd;      //被监控的文件描述符
    short events;  //想要监控的事件
    short revents; //实际监控到的事件
        POLLIN  普通优先级的读事件
        POLLPRI 高优先级的读事件
        POLLOUT 普通优先级的写事件
        POLLRDHUP 对方socket关闭
        POLLERR 错误事件
        POLLHUP 对方挂起
        POLLNVAL 非法描述符
    };
nfds:数组的长度
timeout:超时时间 按毫秒赋值 1000毫秒=1秒
返回值:监控到发生相关操作的描述符的个数,超时返回0,错误返回-1

实现: 

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <poll.h>

/*TCP服务端调用poll函数实现多路复用*/

// 把客户端的socket描述符添加到pollfd数组中,并设置事件 
int add_fds(struct pollfd* fds, int nfds, int fd, short events)
{
	for(int i = 0; i < nfds; i++)
	{
		if(0 == fds[i].fd)
		{
			fds[i].fd = fd;
			fds[i].events = events;
			return 0;
		}
	}
	return -1;
}

int main(int argc , const char* argv[])
{
	int svr_fd = socket(AF_INET, SOCK_STREAM, 0);
	if(svr_fd < 0)
	{
		perror("socket");
		exit(1);
	}

	struct sockaddr_in addr = {};
	addr.sin_family = AF_INET;
	addr.sin_port = htons(8888);
	addr.sin_addr.s_addr = inet_addr("127.0.0.1"); // 绑定回环地址

	socklen_t addrlen = sizeof(addr);
	if(bind(svr_fd, (struct sockaddr *)&addr, addrlen))
	{
		perror("bind");
		exit(1);
	}

	if(listen(svr_fd, 10))
	{
		perror("listen");
		exit(1);
	}
	
	// 创建pollfd数组初始化
	struct pollfd *fds = calloc(sizeof(struct pollfd), 10);
	
	// 设置[0]位置要监听的描述符和事件
	fds[0].fd = svr_fd;
	fds[0].events = POLLIN;  // 普通优先级的读事件
	
	// 定义超时时间
	int timeout = 10000;
	
	char buf[4096] = {};
	size_t buf_size = sizeof(buf);
	
	while(1)
	{
		// 调用poll函数
		int ret = poll(fds, 10, timeout);
		if(ret <= 0)
		{
			perror("poll");
			exit(1);
		}
		
		// 判断[0]位置是否有读事件发生,有客户端在等待连接
		if(fds[0].revents & POLLIN) // &判断 |赋值 
		{
			// 创建连接
			int cli_fd = accept(svr_fd, (struct sockaddr*)&addr, &addrlen);
			if(cli_fd > 0)
			{
				// 把客户端的socket描述符添加到pollfd数组中,并设置事件
				if(add_fds(fds, 10, cli_fd, POLLIN))
				{
					printf("客户端数量已满!\n");
					exit(1);
				}
			}
		}
		else
		{
			// 遍历pollfd数组判断其他位置是否有读事件产生
			for(int i = 1; i < 10; i++)
			{
				if(fds[i].events & POLLIN)
				{	
					int ret = recv(fds[i].fd, buf, buf_size, 0);
					if(ret <= 0 || 0 == strncmp(buf, "quit", 4))
					{
						printf("客户端%d退出\n", fds[i].fd);
						fds[i].fd = 0;
						fds[i].events = 0;
						continue;
					}
					else
					{	
						printf("recv:%s bits:%d\n", buf, ret);
			
						strcat(buf, ":return");
						ret = send(fds[i].fd, buf, strlen(buf)+1, 0);
						if(ret <= 0)
						{
							printf("客户端%d退出\n", fds[i].fd);
							fds[i].fd = 0;
							fds[i].events = 0;
						}
					}
				}
			}
		}
	}
	return 0; 
}

代码说明:

1.初始化服务器套接字

2.准备poll使用的pollfd数组:

  • 使用calloc分配一个pollfd结构数组(fds),大小为10,用于跟踪最多10个文件描述符(包括服务器监听套接字和客户端连接套接字)。
  • 将服务器监听套接字(svr_fd)设置为fds数组的第一个元素,并设置其事件为POLLIN(表示有数据可读)。

3.进入事件循环:

  • 使用poll函数等待一个或多个文件描述符上的事件发生。poll会阻塞直到有事件发生或达到超时时间(timeout毫秒)。
  • 如果poll返回错误(ret <= 0),则打印错误信息并退出。

4.处理事件:

  • 如果服务器监听套接字(fds[0])上的POLLIN事件发生,表示有新的客户端连接请求。
    • 使用accept函数接受客户端连接,并获取新的客户端文件描述符(cli_fd)。
    • 尝试将新的客户端文件描述符添加到fds数组中。如果数组已满(即找不到fd为0的pollfd结构),则打印错误信息并退出。
  • 否则,遍历fds数组(从索引1开始,因为索引0是服务器监听套接字),检查是否有客户端文件描述符上的POLLIN事件发生。
    • 如果有,使用recv函数从客户端接收数据。
    • 如果接收到的数据长度小于等于0,或者接收到特定字符串("quit"),则认为客户端已断开连接,将对应的pollfd结构的fdevents设置为0,以便下次可以重用该位置。
    • 否则,打印接收到的数据,并添加":return"字符串后发送回客户端。如果发送失败,则认为客户端已断开连接,并重置对应的pollfd结构。

3. epoll机制

原理与特点

  • epoll是select和poll的升级版,它提供了更高效的方式来处理大量并发连接。
  • epoll使用红黑树来管理待检测的文件描述符集合,这提高了查找和修改文件描述符的效率。
  • epoll还使用了回调机制,当文件描述符就绪时,内核会直接将就绪的文件描述符集合返回给用户空间,无需用户空间进行额外的检测。

epoll的条件触发和边缘触发:

  • 条件触发:当文件缓冲区中有需要读取的数据时就会触发事件,类似于键盘                  
  • 边缘触发:当数据发送时触发一次事件,类似于鼠标

        把监控事件增加设置为EPOLLET

        优点:大大地降低事件触发的次数,在某些只需要处理一次事件即可的情境下能够提高效率 

优点

  • 没有文件描述符数量的限制,仅受系统中进程能打开的最大文件数目限制。
  • 使用红黑树和回调机制,提高了处理大量并发连接的效率。
  • 避免了在每次调用时都进行大量的数据复制。

函数原型

int epoll_create(int size);
功能:创建一个epoll的内核对象,该对象可以管理、保存被监控的描述符
size:epoll对象管理描述符的数量
返回值:epoll对象的描述符

int epoll_ctl(int epfd,int op,int fd, struct epoll_event *event);
功能:控制epoll对象,添加、删除描述符
epfd:epoll对象描述符
op:
    EPOLL_CTL_ADD   添加监控的描述符
    EPOLL_CTL_DEL   删除监控的描述符
    EPOLL_CTL_MOD   修改要监控的描述符的事件
fd:
    要操作的描述符
event:要监听的事件
    struct epoll_event {
        uint32_t events;   //要监控事件,参数poll
        epoll_data_t data; /* User data variable */
    };
    typedef union epoll_data {
        void        *ptr;
        int          fd;    //  产生事件的描述符
        uint32_t     u32;
        uint64_t     u64;
    } epoll_data_t;
返回值:成功0 失败-1

int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);
功能:监控文件描述符,并返回发生事件的描述符
epfd:epoll对象描述符
events:输出型参数,用于获取发生事件的描述符
maxevents:可以返回事件数目的最大值
timeout:超时时间
返回值:监控到发生相关操作的描述符的个数,超时返回0,错误返回-1

实现: 

代码如下(示例):

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/epoll.h>

/*TCP服务端调用epoll函数实现多路复用*/

int main(int argc , const char* argv[])
{
	int svr_fd = socket(AF_INET, SOCK_STREAM, 0);
	if(svr_fd < 0)
	{
		perror("socket");
		exit(1);
	}

	struct sockaddr_in addr = {};
	addr.sin_family = AF_INET;
	addr.sin_port = htons(8888);
	addr.sin_addr.s_addr = inet_addr("127.0.0.1"); // 绑定回环地址

	socklen_t addrlen = sizeof(addr);
	if(bind(svr_fd, (struct sockaddr *)&addr, addrlen))
	{
		perror("bind");
		exit(1);
	}

	if(listen(svr_fd, 10))
	{
		perror("listen");
		exit(1);
	}
	
	// 创建epoll对象
	int epfd = epoll_create(10);
	if(epfd < 0)
	{
		perror("epoll_create");
		exit(1);
	}
	
	// 添加描述符
	struct epoll_event event;
	event.events = EPOLLIN; 
	event.data.fd = svr_fd;
	
	if(epoll_ctl(epfd, EPOLL_CTL_ADD, svr_fd, &event))
	{
		perror("epoll_ctl");
		exit(1);
	}
	
	// 定义一个存储监控结果的数组
	struct epoll_event events[10] = {};
	
	char buf[4] = {};
	size_t buf_size = sizeof(buf);

	while(1)
	{
		// 监听
		int event_cnt = epoll_wait(epfd, events, 10, 10000);
		if(event_cnt < 0)
		{
			perror("epoll_wait");
			exit(1);
		}
		
		// 遍历监听的结果
		for(int i = 0; i < event_cnt; i++)
		{
			if(svr_fd == events[i].data.fd)
			{
				// 创建连接
				int cli_fd = accept(svr_fd, (struct sockaddr *)&addr, &addrlen);
				if(cli_fd > 0)
				{
					//event.events = EPOLLIN; // 条件触发
					event.events = EPOLLIN | EPOLLET; // 边缘触发
					event.data.fd = cli_fd;
					if(epoll_ctl(epfd, EPOLL_CTL_ADD, cli_fd, &event))
					{
						perror("epoll_ctl");
						exit(1);
					}
				}
			}
			else
			{
				// 处理边缘触发数据丢失情况(循环接收)
				int ret = 0;
				while(ret = recv(events[i].data.fd, buf, buf_size, MSG_DONTWAIT) != -1) // 不阻塞接收
				{
					if(ret <= 0 || 0 == strncmp(buf, "quit", 4))
					{
						printf("客户端%d退出\n", events[i].data.fd);
						epoll_ctl(epfd, EPOLL_CTL_DEL, events[i].data.fd, NULL);
						continue;
					}
					printf("recv:%s bits:%d\n", buf, ret);
				}
				
				strcat(buf, ":return");
				ret = send(events[i].data.fd, buf, strlen(buf)+1, 0);
				if(ret <= 0)
				{
					printf("客户端%d退出\n", events[i].data.fd);
					epoll_ctl(epfd, EPOLL_CTL_DEL, events[i].data.fd, NULL);
				}
			}
		}
	}
	return 0; 
}

代码说明:

1.初始化服务器套接字

2.创建epoll实例

  • 使用epoll_create函数创建一个epoll实例(epfd),该实例用于管理多个文件描述符上的事件。

3.注册服务器监听套接字到epoll

  • 创建一个epoll_event结构体(event),设置其事件类型为EPOLLIN(表示有数据可读),并将服务器监听套接字(svr_fd)作为事件的数据。
  • 使用epoll_ctl函数将服务器监听套接字添加到epoll实例中,以便监听其上的事件。

4.事件循环

  • 进入一个无限循环,使用epoll_wait函数等待epoll实例上发生的事件。该函数会阻塞直到有事件发生或达到超时时间(10000毫秒)。
  • epoll_wait返回后,遍历所有发生的事件。

5.处理事件

  • 如果事件对应的文件描述符是服务器监听套接字(svr_fd),则表示有新的客户端连接请求。
    • 使用accept函数接受客户端连接,并获取新的客户端文件描述符(cli_fd)。
    • 设置event结构体的事件类型为EPOLLIN | EPOLLETEPOLLIN表示有数据可读,EPOLLET表示边缘触发模式),并将客户端文件描述符作为事件的数据。
    • 使用epoll_ctl函数将新的客户端文件描述符添加到epoll实例中,以便监听其上的事件。
  • 如果事件对应的文件描述符是客户端文件描述符,则表示该客户端上有数据可读或发生了其他注册的事件。
    • 使用recv函数尝试从客户端接收数据,这里使用了MSG_DONTWAIT标志来设置非阻塞模式。
    • 如果接收到的数据长度小于等于0,或者接收到特定字符串("quit"),则认为客户端已断开连接,从epoll实例中删除该客户端文件描述符,并打印退出信息。
    • 否则,打印接收到的数据,并添加":return"字符串后发送回客户端。
    • 如果发送失败,则认为客户端已断开连接,从epoll实例中删除该客户端文件描述符,并打印退出信息。

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值