frankfan的胡思乱想

学海无涯,回头是岸

多路复用

多路复用select的细节 IOCP 高并发服务器常用架构

上一章节我们讨论了线程池、IO模型这两个经典话题。这对于构建高性能高并发服务器是非常重要的一环,今天在这一章节中我们主要来聊聊通用的高并发服务器的架构应该是怎样的,以及IO多路复用的具体用法以及异步IO的Windows实现(IOCP)

  • 因为不应该读写一个IO端口而影响到对其他IO端口的读写,所有我们采用了多线程,每个IO读写都在一条单独的线程中进行,彼此互不干涉。
  • 因此,随着socket链接(IO端口)越来越多,线程的个数开始增长,大量的线程开始带来高开销的线程上下文切换,因此我们开始引入线程池
  • 随着线程池的引入,不再频繁的创建线程,但线程的个数也因此固定,随着IO端口的增多(高并发)线程池无法从本质上提高工作效率,毕竟线程工作效率存在上限,关键问题变成了『读写IO阻塞』。这始终是核心问题所在,不管是否是多线程,只要是IO阻塞,那么每条线程在处理IO时都处于阻塞状态,这是高效率的杀手。
  • 所以我们开始思考,能不能不让读写IO阻塞住线程,在读写IO时,或者说在准备读写IO时,可以让线程去干别的事,等真正IO端口有数据可读时再去读。在这种思想下,我们引入了『IO多路复用』,也就是让系统内核告诉我们,哪个IO端口此刻有数据可读,用户线程此时只要直接去读即可,否则线程可以去做别的事情。

select

//server.cpp
int main(){
  
  //...create socket bind and listen client
	 socket_fd = //...
      
	 fd_set readmask;
   fd_set allreads;//可读IO socket描述符集合
   FD_ZERO(&allreads);//将集合清空
   FD_SET(0, &allreads);//将标准输入端口IO放入集合中
   FD_SET(socket_fd, &allreads);//将socket放入集合中
   //这样,当标准输入端以及socket_fd端存在可读事件时,select就能够感知
   while(1){
     
     readmask = allreads;//select会改变readmask集合中的状态,因此每次循环时都重新给readmask集合赋初值
     //在Linux下select函数的第一个参数需要将最大描述符的值+1,而Windows中select函数第一个参数可以填0,因为Windows把这部分工作已经代替我们做了。那这个最大值+1的操作是什么意思呢?看下文图解
     int rc = select(socket_fd + 1, &readmask, NULL, NULL, NULL);
 		 if (rc <= 0) {
          error(1, errno, "select failed");
     }
 	   
     //begin 因为在这个例子里描述符集合里只放了一个socket描述符以及一个标准输入描述符,一共是2个描述符,因此这里我们直接就分别进行了判断,看这两个描述符中是否有数据可读。
     //而现实情况是描述符集合里放了N个socket,当select返回时,则表示readmask集合里已经有了可读描述符,但是用户线程却不知道具体是哪个能读,唯一能做的就是遍历这个描述符集合,找到那些可读描述符
     /*
     //遍历描述符(用一个容器保存所有socket描述符,当select返回的时候则表明有socket描述符可读了,但具体是哪个只能通过FD_ISSET来判断,这也是select在应对高并发时力不从心的地方。这个遍历非常耗时)
     for(auto s_fd:client_fds){
     
     		if(FD_ISSET(s_fd->sfd, &readmask)){
     			//handle the client_socket
     		}
     }
     
     */
     if (FD_ISSET(socket_fd, &readmask)) {
     		
        //socket_fd端可读了
        //这是去读取socket_fd时是能立即获得数据而无需等待的。(当然,这个读取方法read本身是同步的,只有执行完read函数线程才会继续。所以这其实是同步IO
        //开始读取并处理数据...
     }
 
     if (FD_ISSET(STDIN_FILENO, &readmask)) {
        //进入这个判断则表示标准输入端有数据可读了
     }
     //end
     
	  }
   }
   return 0;
}

以上就是select函数的基本、也是核心用法。

就是『在一个循环中不停的调用select函数,然后等待IO事件发生,然后循环判断描述符是否可读,最后读取那些可读的描述符端口』,select最让人诟病的地方在于它无法直接返回那些有可读事件的描述符,需要交给用户自己去判断寻找。但是select的优势在于简单轻巧,使用方便,所以现在还依然存在市场。

可见,以上我们只使用了1个线程就能同时读取N个描述符端口,这得益于select函数帮我们『收集』到了哪些IO端口有数据可读,而不用线程在那里苦苦等待数据的到来。

到目前为止构架高并发服务器我们知道了2种基本架构

  • per thread per client
  • IO multiplexing and thread pool

根据实践经验,当不超过100个client时使用per thread per client的模式性能更好,而一旦超过这个数量,就不能适用一个客户端一条线程了。

fd_set可以认为是一个描述符集合

image.png

select函数中第一个参数用来指定集合的大小,显然当存在N个客户端socket时,这个集合的大小是最大socket文件描述符的值加1.

当最大socket值为3时,fd_set集合大小为4(3+1),如上图所示。(0、1、2被标准输入、输出、错误占据)。在Linux中select这个值需要人为指定,而Windows中则内部已经使用,用户直接传0无视就好。

到目前我们已经掌握了『线程』『IO多路复用(不过目前为止我们只学了一种实现方式那就是select)』,在此基础上,我们就能将两者进行各种排列组合探索出一种科学合理的可能方式。

image.png

这是一种模式,使用一个线程select做多路复用,在这个select中既处理链接accept又处理客户端socket的IO事件,当事件来临之时将其分发到后面的线程中去,这种方式实现思路简单,能应对小规模并发,但如果并发量大,后续线程工作繁忙,那么fd_set集合中的符合读取条件的描述符的数据得不到及时的处理,性能低下。

image.png

上图所示模式先通过一个线程中的selectaccept客户端socket,然后根据调度设计将这个socket放到一条线程的fd_set中,线程的数量是一定的,每个线程中都有一个select,就是所谓的『per thread per loop』,当线程中的fd_set有IO事件发生时,将该socket取出来 放入线程池中,该线程继续等待fd_set中事件发生。

以上就是一个高性能网络库的基础架构。不过通常是将select换成其他的如epoll(Linux),Windows下可以直接使用异步IO(IOCP)。

image.png

上图所示就是per thread per loop架构中每条线程的流程。

在循环一开始就用多路复用来检测哪些端口有IO事件发生,当这个检测的频率越快则证明消费这些IO数据的频率越快,这样就是所谓高并发支持的体系,因此IO多路复用后面的『IO事件处理』『其他业务逻辑』这两块内容应该尽量少的阻塞本循环线程,所以在IO事件处理逻辑中通常会新开业务处理线程(我们称其为工作线程)

不管是使用多线程还是IO多路复用,本质上在内核与用户态进行数据IO时是同步的,因此用户线程不得不处理这部分逻辑,而真正的大杀器则是『异步IO』,也就是用户线程并不需要去内核中读取数据过来,用户线程需要做的只是等待内核态告诉用户态数据已经全部拷贝好了,你直接去用吧。这样用户线程根本不需要花销时间来与内核态进行数据交互。

异步IO大杀器IOCP

网上搜索IOCP时可能最为常见也最核心的资料来源都是Jeffrey Richter大佬的那本『Windows核心编程』,其中Jeffrey大佬用很长的篇幅讲述了这个Windows中『最复杂』的API,其中用了一个文件拷贝的例子。

在实际工作中,IOCP这种重大杀器基本都不会用在基本的文件IO中,主要是本身代码结构复杂,然后收益并不那么明显,IOCP真正的场合是用在高性能服务器开发上,这样的场景中不仅仅牵涉到IOCP的使用,更多的是socket接口的使用与多线程的配合等,而网络上的绝大部分资料都是使用网络编程场景来介绍IOCP,这当然是无可厚非并且『正确』的。但由于网络编程本身的复杂性,所以对新手了解IOCP造成了不必要的干扰,因此我们这个案例中将会聚焦IOCP这个API本身的使用以及相关概念的讲解(但请注意,这个案例并不是IOCP真正的使用场景)


typedef struct
{
	HANDLE hFile;

} iocp_key;//用来做完成端口对应的key的

typedef struct 
{
	OVERLAPPED overlap;
	char buf[1024];
} iocp_overlapped;  //这里其实可以用继承OVERLAPPED的方式来定义iocp_overlapped结构

/*

class iocp_overlapped:public OVERLAPPED{
	char buf[1024];
};
*/


//c语言库提供的线程创建函数,线程所执行的函数签名就这样~
unsigned int __stdcall workThread(void *param)
{
	HANDLE iocp = (HANDLE)param;
	
	DWORD bytesRead = 0;
	iocp_key * key = 0;
	iocp_overlapped * s = 0;
	DWORD ret = 0;
 
	while (1)
	{
        
     //等待完成端口的响应
		ret = GetQueuedCompletionStatus(iocp, &bytesRead, (LPDWORD)&key, (LPOVERLAPPED*)&s, INFINITE);//当这个函数返回时,相关IO已经完成,而无需用户线程再度使用read相关方法去读取内核中的数据,即使此时内核中数据已经准备好了。
 
        //退出线程
		if (key == 0 && s == 0){//在main函数中,退出线程的操作我们调用了PostQueuedCompletionStatus,传入了2个0.线程根据这个标志来判断是不是需要退出了
			break;
		}

		//如果线程没退出执行到了这里,我们直接输出读取到的数据
		printf("byteread:%d, key:%p,ret :%d\r\n", bytesRead, key->hFile, ret);
		printf("%s\r\n",s->buf);
		
	}
	return 0;
}

int main()
{
	
	SYSTEM_INFO sysInfo;
	GetSystemInfo(&sysInfo);
 
    //准备线程数量
	const int threadCount = sysInfo.dwNumberOfProcessors * 2;
 
    //创建一个完成端口
	HANDLE iocp = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, threadCount);
	HANDLE * thread_handles = new HANDLE[threadCount];
 
    //创建线程
	for (int i = 0; i < threadCount; ++i){
		//c语言库提供的线程创建函数(而没用Windows提供的CreateThread
		thread_handles[i] = (HANDLE)_beginthreadex(0, 0, workThread, (void*)iocp, 0, 0);
	}
 
	HANDLE hFile = CreateFile(
		TEXT("D:/Files/demofile/51asm.html"),                              
		GENERIC_READ,                                  
		FILE_SHARE_READ,                              
		NULL,                                         
		OPEN_EXISTING,                                
		FILE_FLAG_OVERLAPPED ,  //异步读写文件 需要用FILE_FLAG_OVERLAPPED这个标志
		NULL                                          
		);
	DWORD numread = 0 , ret = 0;
	
    
	iocp_overlapped * s = new iocp_overlapped; 
	s->overlap.hEvent = NULL;
	s->overlap.Offset = 0;
	s->overlap.OffsetHigh = 0;
    
    //初始化数据 , 这些数据将在完成后被传递到线程中
	iocp_key * key = new iocp_key;
	key->hFile = hFile;
 
    //将文件handle 与 完成端口 关联在一起 , 注意 key 的类型转换
	CreateIoCompletionPort(hFile, iocp,(DWORD)key, 0);
    
    //读取文件 , 等读完后, 将有一个线程处理读完后的步骤
	ret = ReadFile(hFile, s->buf, 1024, &numread,&s->overlap);//我们只管来读这个文件 而其实这个是异步的,当在IOCP内部数据全部拷贝完成后,在线程内部调用的GetQueuedCompletionStatus函数能够获取到相应的信息
	
  //不让主线程退出
	getchar();
 
    //告诉所有线程去死吧
	for (int i = 0; i < threadCount; ++i){
		PostQueuedCompletionStatus(iocp, 0, (DWORD)0, 0);//给IOCP发了一个IO完成状态,在线程内部有判断这个内部的状态逻辑,当检测到是这个状态时会让线程提出循环,从而退出
	}
    
	WaitForMultipleObjects(threadCount, thread_handles, TRUE, -1);
 
	return 0;

以上,就是一个最精简但是又最核心的一个IOCP使用模型。掌握了这个模型就基本理解了IOCP相关API的使用方法,再去读复杂的基于IOCP的网络编程时就能理解IOCP的在IO中的用法了,就能开始明白什么叫『真正的异步IO』

关于IOCP的内部结构(那1个列表和4个队列)对IOCP的使用理解帮助意义不大,本章就不再赘述了。

(多说一些)

当某个线程调用GetQueuedCompletionStatus时,IOCP就会将这个线程纳入到『等待线程队列』中,IOCP内部会对这些用户线程进行调度,当IOCP内部的IO完成时,会调度相关的线程来处理这个IO完成数据,而用户无需操心(也操心不了)具体哪个线程被调度,只需要按照既定逻辑去处理完成后的IO事件即可。

而这个所谓的『调度』实际是得益于IOCP内部维护的三个线程队列。『等待线程队列』『释放线程队列』『暂停线程队列』,线程被IOCP在三3个不同的队列中调度,用户能做的就是等GetQueuedCompletionStatus返回,然后安心处理完成的IO数据。

而这,就是Windows中异步IO的全部秘密。

posted on 2021-12-28 09:32  shadow_fan  阅读(148)  评论(0编辑  收藏  举报

导航