欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

IOCP服务器设计(via Modern C++)

程序员文章站 2022-03-23 19:44:04
iocp服务器设计(via modern c++)。c++11标准提出来有些年头了,十一放假没事研究了一下iocp,想着能不能用c++11实现一个高性能的服务器。当然,目前有许多十分成熟的c++网络...

iocp服务器设计(via modern c++)。c++11标准提出来有些年头了,十一放假没事研究了一下iocp,想着能不能用c++11实现一个高性能的服务器。当然,目前有许多十分成熟的c++网络库,比如ace,asio等等。但是如果想深入了解其本质,在windows平台下就必须了解socket结合iocp的使用原理。

本文尽可能把笔者在使用c++11实现iocp服务器的过程中遇到的困难和问题展现给大家,让大家学习起来少走些弯路。由于代码比较底层,所以有些细节希望大家在看本文和代码的时候能够揣摩和理解。本文假定读者总体把握了piggyxp原文的相关内容并具有相当的window的相关知识(熟悉winsock2库基本函数的使用,windows多线程的基本概念等)、c++11/03编程基础(stl,仿函数等)。

在每一节标题后都有箭头指向目录,文档某些位置可能会有返回箭头(返回到可能你在的地方),希望能帮助大家更好的理解本文。

温馨提示:由于笔者水平有限,虽经过仔细调试,但本文代码仍然可能存在笔者未知的bug或者性能缺陷。请大家发现问题后能够及时联系我,让我们共同进步。

在生活中,异步的概念是很常见的。比如你洗衣服时突然女朋友(程序员有女朋友?)来了,你从洗衣间出去招待,而洗衣机则按照你的指令继续在工作。当你招呼完女朋友回到洗衣间的时候,衣服已经洗好了。也就是在女朋友来的时间点,你与洗衣机分离,它按照你的指令在完成工作,而你却可以处理其他更需要处理的事情。当你处理完回来后,洗衣机可能早已经完成了它的工作,你只需要将衣服取出晾起来就可以了。而同步就是你家没有洗衣机,当女朋友来的时候要么中断洗衣服去招待女朋友,要么让女朋友等待自己把衣服洗完,一件事情只能在另一件事情之后发生。这样,大家就能明显看出来有台洗衣机的好处了。

不过如何知道衣服洗完了呢?windows牌洗衣机给我们提供了这么四种方式:

表2 windows 提供的4种异步方式

方式解释相关技术

led灯洗完一件衣服就亮灯,但只有一个灯,其他人可以帮忙处理触发设备内核对象

高级led灯洗完一件衣服就亮灯,可以有多个灯,其他人可以帮忙处理触发事件内核对象

发送短信洗完一件衣服就发送一条短信,有一个短信列表,但只有你能够处理可提醒io(apc)

群发短信洗完一件衣服就发送一条短信,有一个短信列表,其他人可以帮忙处理io完成端口(iocp)

这样,大家就很明白iocp的好处了:不需要去时刻看着灯亮不亮;短信到了可以去处理也可以不去处理;不仅你能处理,还有家人也能帮你处理。

触发设备内核对象、触发事件内核对象和可提醒io就不展开讨论了,有兴趣的朋友可以查阅本节列出的参考文献,下面进入正题。

iocp状态机

这一小节可能比较难,希望大家能够耐心看下去,因为要真正掌握iocp就必须弄清楚它内在的原理。先给出iocp的状态机,如图1所示:

IOCP服务器设计(via Modern C++)

图1 iocp状态机

下面给出图中各的相关说明:

表3 iocp相关组件说明

组件简要解释

等待队列当线程池中的某线程在等待io操作时(调用getqueuedcompletionstatus函数),iocp将线程加入等待队列。

iocp在io操作完成后将返回结果加入完成队列,由等待队列中的最后一个加入的线程处理。

已释放列表当等待的线程处理完io操作后或是从暂停状态被唤醒都会加入此列表。

当线程再次调用getqueuedcompletionstatus函数将使自己再次加入等待队列;将自身挂起将加入已暂停列表。

已暂停列表当已释放列表中的线程挂起时将加入已暂停列表;当挂起线程被激活时线程加入已释放列表。

完成队列iocp完成指定io操作后将执行结果插入完成队列。这个队列时先进先出的。

iocp设备列表即要进行异步io操作的设备列表(可以是文件,也可以是套接字),所有的io操作都围绕这些设备进行。

这样,整个iocp服务器创建的流程就很明了了:?

创建一个新的完成端口,处理所有的io请求。 创建一个线程池,此时线程处于已释放列表。 创建一个socket并将其绑定在创建的完成端口上,作为io操作的实体。利用这个套接字进行listen操作,并向第1步创建的完成端口中投递accept消息,将第2步创建线程置于等待队列中等待客户端连接。 当客户端连接后,iocp将在io完成队列插入accept,等待队列中的线程将得到accept,并创建新的socket作为与客户端通信的套接字,并将其绑定在第1步创建好的完成端口上。 此后,无论是recv,send都照此步骤进行即可。

这里有几个细节需要注意:

1. 最合适的线程数应当是多于处理器核心数的

多线程优化理论告诫我们,为了避免ring0与ring3之间的上下文切换,我们应当将线程数设置为处理器核数。但是微软在设计iocp的时候想到了这样一个问题:考虑到线程挂起,如果按照理论值设置线程数,将有可能出现实际工作线程数小于cpu所能接受的最大工作线程数,这样就无法有效发挥多线程的优势。因此,最理想的线程数量应当多于处理器核心数的,经验值为两倍核心数。

2. 等待队列是后入先出的

之所以这样设计也是出于性能调优的考虑。当某线程处理完某批io数据后重新加入等待队列,由于lifo机制,当完成队列中又存在有新的io数据时,该线程将会优先处理数据。这样可能会导致某些线程一直处于等待状态,这样windows就可以将其换出内存节约空间。

3. 投递

所谓投递其实就是利用acceptex,wsarecv和wsasend等函数在io完成端口中进行异步操作。形象来说就是你向洗衣机输入参数的过程,后续工作由洗衣机(winsock2)完成。

windows api相关知识↑

本节参考文献

microsoft. i/o completion ports[eb/ol]. https://msdn.microsoft.com/en-us/library/aa365198(vs.85).x

microsoft. windows sockets 2[eb/ol]. https://msdn.microsoft.com/en-us/library/windows/desktop/ms740673(v=vs.85).aspx

russinovich m e, solomon d a, ionescu a. windows internals[m]. pearson education, 2012: 56-58.

iocp apis

关于常规的io完成端口api主要有以下三个:

创建和关联io完成端口函数createiocompletionport,该函数在创建完成端口和关联设备(文件设备,套接字等)时使用。

handle winapi createiocompletionport(

_in_ handle filehandle,

_in_opt_ handle existingcompletionport,

_in_ ulong_ptr completionkey,

_in_ dword numberofconcurrentthreads

);

获取完成队列状态函数getqueuedcompletionstatus,该函数在线程池线程函数中使用。?

bool winapi getqueuedcompletionstatus(

_in_ handle completionport,

_out_ lpdword lpnumberofbytestransferred,

_out_ pulong_ptr lpcompletionkey,

_out_ lpoverlapped * lpoverlapped,

_in_ dword dwmilliseconds

);

在完成队列中插入消息函数postqueuedcompletionstatus,该函数在给线程传递退出参数时使用。?

bool winapi postqueuedcompletionstatus(

_in_ handle completionport,

_in_ dword dwnumberofbytestransferred,

_in_ ulong_ptr dwcompletionkey,

_in_opt_ lpoverlapped lpoverlapped

);

以上函数的详细用法在参考文献及piggyxp的文章中可以找到,故不再赘述。

在编程过程中主要考虑以下几个问题:

1. createiocompletionport函数的设计问题

按照设计模式最基础的原则即单一职责原则,这个函数设计是存在缺陷的。事实上很多windows api都或多或少存在此问题,笔者印象比较深刻的是netbios的系列函数。理想的设计是自己再抽象两个函数,即创建完成端口一个函数,绑定完成端口一个函数。可以这样设计:

创建一个新的完成端口函数createnewiocompletionport,该函数在初始化时使用。

/**

* create completion port

*/

inline auto createnewiocompletionport( dword numberofconcurrentthreads = 0 ) {

return createiocompletionport( invalid_handle_value, nullptr, 0, numberofconcurrentthreads );

}

设备与完成端口绑定函数associatedevicewithcompletionport,该函数在完成端口建立后与io设备绑定时使用。?

/**

* associate device with completion port

*/

inline auto associatedevicewithcompletionport( handle hcompport, handle hdevice, dword dwcompkey ) {

return createiocompletionport( hdevice, hcompport, dwcompkey, 0 ) == hcompport;

}

2. 线程池线程退出问题

由于在程序中使用了线程池,对于每一个线程而言如何不留痕迹地结束是一个很有技巧性的问题。一种优雅的方法是使用postqueuedcompletionstatus函数给完成端口传递退出完成键(completionkey)。由于线程只有可能在等待队列、已释放列表和已暂停列表中,且设计线程函数时均会循环调用getqueuedcompletionstatus函数,因此最终所有线程都会转移到等待队列中去。

有的读者会考虑到等待队列的lifo特性,其实只要我们设计线程函数时首先判断传入的完成键是否为退出的特定信号,检测到自行退出即可。我们在主线程退出时在完成端口中传入创建线程数量个推出信号,由于是完成队列是顺序存取,只要线程函数设计合理,可以保证每一个线程函数都可以收到退出消息。不会发生piggyxp考虑的收不到信息的情况。

更深入的讨论高级程序员参考

笔者深入分析了getqueuedcompletionstatus函数(由kernel32.dll转发,在kernelbase.dll中实现),发现其内部准备好各项参数后调用了ntremoveiocompletion函数(由ntdll.dll转发,在内核ntoskrnl.exe中实现)。这样就很明白了,其实就是在完成队列中取出一个数据。

继续对ntremoveiocompletion函数进行分析,发现在内部调用了ioremoveiocompletion,继续深究下去发现其主要功能调用了keremovequeueex函数,而在该函数内部进行了无锁同步:

if ( _interlockedbittestandset( ... ) ) {

do {

do

keyieldprocessorex( ... );

while ( ... );

} while ( _interlockedbittestandset( ... ) );

}

这样就能保证apc交付时,只有一个线程可以访问到完成队列。因此,只要在设计过程中一次只取出一个完成的数据,就不会出现问题。当然,如果想更高效的处理数据(比如调用getqueuedcompletionstatusex)又想通过postqueuedcompletionstatus方式退出的话,就可能需要特殊处理。比如像piggyxp一样设计一个信号量,或者接收到退出信号后在退出之前向完成队列中再post一个退出信号等等。

如果想要更加深入的了解其中的运作机理,大家可以去看看wrk或者是react os的。当然,这些代码时代都比较久远了,可能细节上和现在的windows实现不太一样,但是也能说明问题。

p.s.

在windows vista以上操作,将完成端口的句柄直接关闭将取消所有关联的io操作,关联io端口的所有线程调用getqueuedcompletionstatus会放弃等待并立即返回false,这时调用getlasterror获取错误码时,会返回error_invalid_handle。检测到这一情况就可以退出了。

小插曲

在分析windows 10内核的时候在explorer中可以看到ntoskrnl,而在ida中看不到。最后只得将其复制到其他地方才进行了分析,感叹一句微软套路深。

3. 完成键(completionkey)和重叠结构(overlapped)的设置问题?

这里可能是理解完成端口的一个难点,至少笔者在学习的时候在这里停顿了一段时间。

首先说说完成键。这个参数是为了给线程池中的线程通信而设计的,也就是说当调用前文所述associatedevicewithcompletionport时传入的完成键将会传给调用getqueuedcompletionstatus的线程。这样,主线程就可以通过这两个函数与线程池中的线程进行通信。同样注意到完成键是一个dword类型,也可以给它传入一个结构体的地址。

而重叠结构是在io处理时传递给相应io函数的数据载体。这个结构很有用,但本文不再展开说明,有兴趣的朋友可以查看参考文献相应部分。c/c++程序员应该都知道这样一个事实:结构体的第一个成员的地址和结构体的地址是相同的。所以,我们可以定义一个结构体(或者是一个c++类),将重叠结构作为第一个成员,在io处理时,将我们定义的结构传入。这样,io函数处理它自身需要的重叠结构信息,而我们可以在其中夹带私货。为什么要这么做呢?因为在我们在线程函数中可能需要一些其他的数据,这样就可以通过这种办法传进去。

于是我们就明白了:完成键与线程有关而重叠结构与io有关。我们需要完成键给线程传递参数,需要重叠结构(以及夹带的私货)来完成io操作。

至于这些怎样与socket结合,请浏览下一节内容。

更深入的讨论高级程序员参考

在piggyxp的博文中提到了一个“神奇的宏”:containing_record。这个宏广泛应用于驱动编程中,用于获取在知道结构体某成员地址的情况下推知整个结构体地址的场景中。具体定义如下:

/**

* calculate the address of the base of the structure given its type, and an

* address of a field within the structure.

*/

#define containing_record(address, type, field) ((type *)( \

(pchar)(address) - \

(ulong_ptr)(&((type *)0)->field)))

这个是带有浓郁c风格、充满trick的一个宏。能进行深入讨论的朋友一看就明白,就不班门弄斧了。值得注意的是,使用这个宏的时候对成员是否是结构体的第一个成员没有限制。