版权声明:本文为CSDN博主「PiggyXP」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/piggyxp/article/details/6922277

五 使用完成端口的基本流程

说了这么多的废话,大家都等不及了吧,我们终于到了具体编码的时候了。

使用完成端口,说难也难,但是说简单,其实也简单 ---- 又说了一句废话=。=

大体上来讲,使用完成端口只用遵循如下几个步骤:

1 调用 CreateIoCompletionPort() 函数创建一个完成端口,而且在一般情况下,我们需要且只需要建立这一个完成端口,把它的句柄保存好,我们今后会经常用到它……

2 根据系统中有多少个处理器,就建立多少个工作者(为了醒目起见,下面直接说Worker)线程,这几个线程是专门用来和客户端进行通信的,目前暂时没什么工作;

3 下面就是接收连入的Socket连接了,这里有两种实现方式:一是和别的编程模型一样,还需要启动一个独立的线程,专门用来accept客户端的连接请求;二是用性能更高更好的异步AcceptEx()请求,因为各位对accept用法应该非常熟悉了,而且网上资料也会很多,所以为了更全面起见,本文采用的是性能更好的AcceptEx,至于两者代码编写上的区别,我接下来会详细的讲。

4 每当有客户端连入的时候,我们就还是得调用CreateIoCompletionPort()函数,这里却不是新建立完成端口了,而是把新连入的Socket(也就是前面所谓的设备句柄),与目前的完成端口绑定在一起。

至此,我们其实就已经完成了完成端口的相关部署工作了,嗯,是的,完事了,后面的代码里我们就可以充分享受完成端口带给我们的巨大优势,坐享其成了,是不是很简单呢?

5 例如,客户端连入之后,我们可以在这个Socket上提交一个网络请求,例如WSARecv(),然后系统就会帮咱们乖乖的去执行接收数据的操作,我们大可以放心的去干别的事情了;

6 而此时,我们预先准备的那几个Worker线程就不能闲着了, 我们在前面建立的几个Worker就要忙活起来了,都需要分别调用GetQueuedCompletionStatus() 函数在扫描完成端口的队列里是否有网络通信的请求存在(例如读取数据,发送数据等),一旦有的话,就将这个请求从完成端口的队列中取回来,继续执行本线程中后面的处理代码,处理完毕之后,我们再继续投递下一个网络通信的请求就OK了,如此循环。

关于完成端口的使用步骤,用文字来表述就是这么多了,很简单吧?如果你还是不理解,我再配合一个流程图来表示一下:

当然,我这里假设你已经对网络编程的基本套路有了解了,所以略去了很多基本的细节,并且为了配合朋友们更好的理解我的代码,在流程图我标出了一些函数的名字,并且画得非常详细。

另外需要注意的是由于对于客户端的连入有两种方式,一种是普通阻塞的accept,另外一种是性能更好的AcceptEx,为了能够方面朋友们从别的网络编程的方式中过渡,我这里画了两种方式的流程图,方便朋友们对比学习,图a是使用accept的方式,当然配套的源代码我默认就不提供了,如果需要的话,我倒是也可以发上来;图b是使用AcceptEx的,并配有配套的源码。

采用accept方式的流程示意图如下:



采用AcceptEx方式的流程示意图如下:



两个图中最大的相同点是什么?是的,最大的相同点就是主线程无所事事,闲得蛋疼……

为什么呢?因为我们使用了异步的通信机制,这些琐碎重复的事情完全没有必要交给主线程自己来做了,只用在初始化的时候和Worker线程交待好就可以了,用一句话来形容就是,主线程永远也体会不到Worker线程有多忙,而Worker线程也永远体会不到主线程在初始化建立起这个通信框架的时候操了多少的心……

图a中是由 _AcceptThread()负责接入连接,并把连入的Socket和完成端口绑定,另外的多个_WorkerThread()就负责监控完成端口上的情况,一旦有情况了,就取出来处理,如果CPU有多核的话,就可以多个线程轮着来处理完成端口上的信息,很明显效率就提高了。

图b中最明显的区别,也就是AcceptEx和传统的accept之间最大的区别,就是取消了阻塞方式的accept调用,也就是说,AcceptEx也是通过完成端口来异步完成的,所以就取消了专门用于accept连接的线程,用了完成端口来进行异步的AcceptEx调用;然后在检索完成端口队列的Worker函数中,根据用户投递的完成操作的类型,再来找出其中的投递的Accept请求,加以对应的处理。

读者一定会问,这样做的好处在哪里?为什么还要异步的投递AcceptEx连接的操作呢?

首先,我可以很明确的告诉各位,如果短时间内客户端的并发连接请求不是特别多的话,用accept和AcceptEx在性能上来讲是没什么区别的。

按照我们目前主流的PC来讲,如果客户端只进行连接请求,而什么都不做的话,我们的Server只能接收大约3万-4万个左右的并发连接,然后客户端其余的连入请求就只能收到WSAENOBUFS (10055)了,因为系统来不及为新连入的客户端准备资源了。

需要准备什么资源?当然是准备Socket了……虽然我们创建Socket只用一行SOCKET s= socket(…) 这么一行的代码就OK了,但是系统内部建立一个Socket是相当耗费资源的,因为Winsock2是分层的机构体系,创建一个Socket需要到多个Provider之间进行处理,最终形成一个可用的套接字。总之,系统创建一个Socket的开销是相当高的,所以用accept的话,系统可能来不及为更多的并发客户端现场准备Socket了。

而AcceptEx比Accept又强大在哪里呢?是有三点:

1 这个好处是最关键的,是因为AcceptEx是在客户端连入之前,就把客户端的Socket建立好了,也就是说,AcceptEx是先建立的Socket,然后才发出的AcceptEx调用,也就是说,在进行客户端的通信之前,无论是否有客户端连入,Socket都是提前建立好了;而不需要像accept是在客户端连入了之后,再现场去花费时间建立Socket。如果各位不清楚是如何实现的,请看后面的实现部分。

2 相比accept只能阻塞方式建立一个连入的入口,对于大量的并发客户端来讲,入口实在是有点挤;而AcceptEx可以同时在完成端口上投递多个请求,这样有客户端连入的时候,就非常优雅而且从容不迫的边喝茶边处理连入请求了。

3 AcceptEx还有一个非常体贴的优点,就是在投递AcceptEx的时候,我们还可以顺便在AcceptEx的同时,收取客户端发来的第一组数据,这个是同时进行的,也就是说,在我们收到AcceptEx完成的通知的时候,我们就已经把这第一组数据接完毕了;但是这也意味着,如果客户端只是连入但是不发送数据的话,我们就不会收到这个AcceptEx完成的通知……这个我们在后面的实现部分,也可以详细看到。

最后,各位要有一个心里准备,相比accept,异步的AcceptEx使用起来要麻烦得多……

六 完成端口的实现详解

又说了一节的废话,终于到了该动手实现的时候了……

这里我把完成端口的详细实现步骤以及会涉及到的函数,按照出现的先后步骤,都和大家详细的说明解释一下,当然,文档中为了让大家便于阅读,这里去掉了其中的错误处理的内容,当然,这些内容在示例代码中是会有的。

【第一步】创建一个完成端口

首先,我们先把完成端口建好再说。

我们正常情况下,我们需要且只需要建立这一个完成端口,代码很简单:

HANDLE m_hIOCompletionPort = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 0 );

呵呵,看到CreateIoCompletionPort()的参数不要奇怪,参数就是一个INVALID,一个NULL,两个0…,说白了就是一个-1,三个0……简直就和什么都没传一样,但是Windows系统内部却是好一顿忙活,把完成端口相关的资源和数据结构都已经定义好了(在后面的原理部分我们会看到,完成端口相关的数据结构大部分都是一些用来协调各种网络I/O的队列),然后系统会给我们返回一个有意义的HANDLE,只要返回值不是NULL,就说明建立完成端口成功了,就这么简单,不是吗?

有的时候我真的很赞叹Windows API的封装,把很多其实是很复杂的事整得这么简单……

至于里面各个参数的具体含义,我会放到后面的步骤中去讲,反正这里只要知道创建我们唯一的这个完成端口,就只是需要这么几个参数。

但是对于最后一个参数 0,我这里要简单的说两句,这个0可不是一个普通的0,它代表的是NumberOfConcurrentThreads,也就是说,允许应用程序同时执行的线程数量。当然,我们这里为了避免上下文切换,最理想的状态就是每个处理器上只运行一个线程了,所以我们设置为0,就是说有多少个处理器,就允许同时多少个线程运行。

因为比如一台机器只有两个CPU(或者两个核心),如果让系统同时运行的线程多于本机的CPU数量的话,那其实是没有什么意义的事情,因为这样CPU就不得不在多个线程之间执行上下文切换,这会浪费宝贵的CPU周期,反而降低的效率,我们要牢记这个原则。

【第二步】根据系统中CPU核心的数量建立对应的Worker线程

我们前面已经提到,这个Worker线程很重要,是用来具体处理网络请求、具体和客户端通信的线程,而且对于线程数量的设置很有意思,要等于系统中CPU的数量,那么我们就要首先获取系统中CPU的数量,这个是基本功,我就不多说了,代码如下:

 SYSTEM_INFO si;GetSystemInfo(&si);int m_nProcessors = si.dwNumberOfProcessors;

这样我们根据系统中CPU的核心数量来建立对应的线程就好了,下图是在我的 i7 2600k CPU上初始化的情况,因为我的CPU是8核,一共启动了16个Worker线程,如下图所示:



啊,等等!各位没发现什么问题么?为什么我8核的CPU却启动了16个线程?这个不是和我们第二步中说的原则自相矛盾了么?

哈哈,有个小秘密忘了告诉各位了,江湖上都流传着这么一个公式,就是:

我们最好是建立CPU核心数量*2那么多的线程,这样更可以充分利用CPU资源,因为完成端口的调度是非常智能的,比如我们的Worker线程有的时候可能会有Sleep()或者WaitForSingleObject()之类的情况,这样同一个CPU核心上的另一个线程就可以代替这个Sleep的线程执行了;因为完成端口的目标是要使得CPU满负荷的工作。

这里也有人说是建立 **CPU“核心数量 * 2 +2”**个线程,我想这个应该没有什么太大的区别,我就是按照我自己的习惯来了。

然后按照这个数量,来启动这么多个Worker线程就好可以了,接下来我们开始下一个步骤。

什么?Worker线程不会建?

…囧…

Worker线程和普通线程是一样一样一样的啊~~~,代码大致上如下:

  // 根据CPU数量,建立*2的线程m_nThreads = 2 * m_nProcessors;HANDLE* m_phWorkerThreads = new HANDLE[m_nThreads];for (int i = 0; i < m_nThreads; i++){m_phWorkerThreads[i] = ::CreateThread(0, 0, _WorkerThread, …);}

其中,_WorkerThread是Worker线程的线程函数,线程函数的具体内容我们后面再讲。

【第三步】创建一个用于监听的Socket,绑定到完成端口上,然后开始在指定的端口上监听连接请求

最重要的完成端口建立完毕了,我们就可以利用这个完成端口来进行网络通信了。

首先,我们需要初始化Socket,这里和通常情况下使用Socket初始化的步骤都是一样的,大约就是如下的这么几个过程(详情参照我代码中的LoadSocketLib()和InitializeListenSocket(),这里只是挑出关键部分):

  // 初始化Socket库WSADATA wsaData;WSAStartup(MAKEWORD(2,2), &wsaData);//初始化Socketstruct sockaddr_in ServerAddress;// 这里需要特别注意,如果要使用重叠I/O的话,这里必须要使用WSASocket来初始化Socket// 注意里面有个WSA_FLAG_OVERLAPPED参数SOCKET m_sockListen = WSASocket(AF_INET, SOCK_STREAM, 0, NULL, 0, WSA_FLAG_OVERLAPPED);// 填充地址结构信息ZeroMemory((char *)&ServerAddress, sizeof(ServerAddress));ServerAddress.sin_family = AF_INET;// 这里可以选择绑定任何一个可用的地址,或者是自己指定的一个IP地址    //ServerAddress.sin_addr.s_addr = htonl(INADDR_ANY);                      ServerAddress.sin_addr.s_addr = inet_addr(“你的IP”);         ServerAddress.sin_port = htons(11111);                          // 绑定端口if (SOCKET_ERROR == bind(m_sockListen, (struct sockaddr *) &ServerAddress, sizeof(ServerAddress))) // 开始监听listen(m_sockListen,SOMAXCONN))

需要注意的地方有两点:

1 想要使用重叠I/O的话,初始化Socket的时候一定要使用WSASocket并带上WSA_FLAG_OVERLAPPED参数才可以(只有在服务器端需要这么做,在客户端是不需要的);

2注意到listen函数后面用的那个常量SOMAXCONN了吗?这个是在微软在WinSock2.h中定义的,并且还附赠了一条注释,Maximum queue length specifiable by listen.,所以说,不用白不用咯_

接下来有一个非常重要的动作:既然我们要使用完成端口来帮我们进行监听工作,那么我们一定要把这个监听Socket和完成端口绑定才可以的吧:

如何绑定呢?同样很简单,用CreateIoCompletionPort()函数。

等等!大家没觉得这个函数很眼熟么?是的,这个和前面那个创建完成端口用的居然是同一个API!但是这里这个API可不是用来建立完成端口的,而是用于将Socket和以前创建的那个完成端口绑定的,大家可要看准了,不要被迷惑了,因为他们的参数是明显不一样的,前面那个的参数是一个-1,三个0,太好记了…

说实话,我感觉微软应该把这两个函数分开,弄个 CreateNewCompletionPort() 多好呢?

这里在详细讲解一下CreateIoCompletionPort()的几个参数:

HANDLE WINAPI CreateIoCompletionPort(__in HANDLE FileHandle,             // 这里当然是连入的这个套接字句柄了__in_opt HANDLE ExistingCompletionPort, // 这个就是前面创建的那个完成端口__in ULONG_PTR CompletionKey,        // 这个参数就是类似于线程参数一样,在绑定的时候把自己定义的结构体指针传递,这样到了Worker线程中,也可以使用这个结构体的数据了,相当于参数的传递__in DWORD NumberOfConcurrentThreads // 这里同样置0
);

这些参数也没什么好讲的吧,用处一目了然了。而对于其中的那个CompletionKey,我们后面会详细提到。

到此才算是Socket全部初始化完毕了。

初始化Socket完毕之后,就可以在这个Socket上投递AcceptEx请求了。

【第四步】在这个监听Socket上投递AcceptEx请求

这里的处理比较复杂。

这个AcceptEx比较特别,而且这个是微软专门在Windows操作系统里面提供的扩展函数,也就是说这个不是Winsock2标准里面提供的,是微软为了方便咱们使用重叠I/O机制,额外提供的一些函数,所以在使用之前也还是需要进行些准备工作。

微软的实现是通过mswsock.dll中提供的,所以我们可以通过静态链接mswsock.lib来使用AcceptEx。但是这是一个不推荐的方式,我们应该用WSAIoctl 配合SIO_GET_EXTENSION_FUNCTION_POINTER参数来获取函数的指针,然后再调用AcceptEx。

这是为什么呢?因为我们在未取得函数指针的情况下就调用AcceptEx的开销是很大的,因为AcceptEx 实际上是存在于Winsock2结构体系之外的(因为是微软另外提供的),所以如果我们直接调用AcceptEx的话,首先我们的代码就只能在微软的平台上用了,没有办法在其他平台上调用到该平台提供的AcceptEx的版本(如果有的话), 而且更糟糕的是,我们每次调用AcceptEx时,Service Provider都得要通过WSAIoctl()获取一次该函数指针,效率太低了,所以还不如我们自己直接在代码中直接去这么获取一下指针好了。

获取AcceptEx函数指针的代码大致如下:


LPFN_ACCEPTEX     m_lpfnAcceptEx;         // AcceptEx函数指针
GUID GuidAcceptEx = WSAID_ACCEPTEX;        // GUID,这个是识别AcceptEx函数必须的
DWORD dwBytes = 0;  WSAIoctl(m_pListenContext->m_Socket, SIO_GET_EXTENSION_FUNCTION_POINTER, &GuidAcceptEx, sizeof(GuidAcceptEx), &m_lpfnAcceptEx, sizeof(m_lpfnAcceptEx), &dwBytes, NULL, NULL);

具体实现就没什么可说的了,因为都是固定的套路,那个GUID是微软给定义好的,直接拿过来用就行了,WSAIoctl()就是通过这个找到AcceptEx的地址的,另外需要注意的是,通过WSAIoctl获取AcceptEx函数指针时,只需要随便传递给WSAIoctl()一个有效的SOCKET即可,该Socket的类型不会影响获取的AcceptEx函数指针。

然后,我们就可以通过其中的指针m_lpfnAcceptEx调用AcceptEx函数了。

AcceptEx函数的定义如下:

BOOL AcceptEx(   SOCKET sListenSocket, SOCKET sAcceptSocket, PVOID lpOutputBuffer, DWORD dwReceiveDataLength, DWORD dwLocalAddressLength, DWORD dwRemoteAddressLength, LPDWORD lpdwBytesReceived, LPOVERLAPPED lpOverlapped
);

乍一看起来参数很多,但是实际用起来也很简单:

参数1–sListenSocket, 这个就是那个唯一的用来监听的Socket了,没什么说的;

参数2–sAcceptSocket, 用于接受连接的socket,这个就是那个需要我们事先建好的,等有客户端连接进来直接把这个Socket拿给它用的那个,是AcceptEx高性能的关键所在。

参数3–lpOutputBuffer,接收缓冲区, 这也是AcceptEx比较有特色的地方,既然AcceptEx不是普通的accpet函数,那么这个缓冲区也不是普通的缓冲区,这个缓冲区包含了三个信息:一是客户端发来的第一组数据,二是server的地址,三是client地址,都是精华啊…但是读取起来就会很麻烦,不过后面有一个更好的解决方案。

参数4–dwReceiveDataLength,前面那个参数lpOutputBuffer中用于存放数据的空间大小。如果此参数=0,则Accept时将不会待数据到来,而直接返回,如果此参数不为0,那么一定得等接收到数据了才会返回…… 所以通常当需要Accept接收数据时,就需要将该参数设成为:sizeof(lpOutputBuffer) - 2*(sizeof sockaddr_in +16),也就是说总长度减去两个地址空间的长度就是了,看起来复杂,其实想明白了也没啥……

参数5–dwLocalAddressLength,存放本地址地址信息的空间大小;

参数6–dwRemoteAddressLength,存放本远端地址信息的空间大小;

参数7–lpdwBytesReceived,out参数,对我们来说没用,不用管;

参数8–lpOverlapped,本次重叠I/O所要用到的重叠结构。

这里面的参数倒是没什么,看起来复杂,但是咱们依旧可以一个一个传进去,然后在对应的IO操作完成之后,这些参数Windows内核自然就会帮咱们填满了。

但是非常悲催的是,我们这个是异步操作,我们是在线程启动的地方投递的这个操作, 等我们再次见到这些个变量的时候,就已经是在Worker线程内部了,因为Windows会直接把操作完成的结果传递到Worker线程里,这样咱们在启动的时候投递了那么多的IO请求,这从Worker线程传回来的这些结果,到底是对应着哪个IO请求的呢?。。。。

聪明的你肯定想到了,是的,Windows内核也帮我们想到了:用一个标志来绑定每一个IO操作,这样到了Worker线程内部的时候,收到网络操作完成的通知之后,再通过这个标志来找出这组返回的数据到底对应的是哪个Io操作的。

这里的标志就是如下这样的结构体:

typedef struct _PER_IO_CONTEXT{OVERLAPPED m_Overlapped; // 每一个重叠I/O网络操作都要有一个              SOCKET m_sockAccept; // 这个I/O操作所使用的Socket,每个连接的都是一样的WSABUF m_wsaBuf; // 存储数据的缓冲区,用来给重叠操作传递参数的,关于WSABUF后面还会讲char m_szBuffer[MAX_BUFFER_LEN]; // 对应WSABUF里的缓冲区OPERATION_TYPE m_OpType; // 标志这个重叠I/O操作是做什么的,例如Accept/Recv等
} PER_IO_CONTEXT, *PPER_IO_CONTEXT;

这个结构体的成员当然是我们随便定义的,里面的成员你可以随意修改(除了OVERLAPPED那个之外……)。

但是AcceptEx不是普通的accept,buffer不是普通的buffer,那么这个结构体当然也不能是普通的结构体了……

在完成端口的世界里,这个结构体有个专属的名字单IO数据PER_IO_CONTEXT,是什么意思呢?也就是说每一个重叠I/O都要对应的这么一组参数,至于这个结构体怎么定义无所谓,而且这个结构体也不是必须要定义的,但是没它……还真是不行,我们可以把它理解为线程参数,就好比你使用线程的时候,线程参数也不是必须的,但是不传还真是不行……

除此以外,我们也还会想到,既然每一个I/O操作都有对应的PER_IO_CONTEXT结构体,而在每一个Socket上,我们会投递多个I/O请求的,例如我们就可以在监听Socket上投递多个AcceptEx请求,所以同样的,我们也还需要一个单句柄数据PER_SOCKET_CONTEXT来管理这个句柄上所有的I/O请求,这里的“句柄”当然就是指的Socket了,我在代码中是这样定义的:

typedef struct _PER_SOCKET_CONTEXT
{  SOCKET m_Socket; // 每一个客户端连接的SocketSOCKADDR_IN m_ClientAddr; // 这个客户端的地址CArray<_PER_IO_CONTEXT*> m_arrayIoContext; // 数组,所有客户端IO操作的参数,// 也就是说对于每一个客户端Socket// 是可以在上面同时投递多个IO请求的
} PER_SOCKET_CONTEXT, *PPER_SOCKET_CONTEXT;

这也是比较好理解的,也就是说我们需要在一个Socket句柄上,管理在这个Socket上投递的每一个IO请求的_PER_IO_CONTEXT。

当然,同样的,各位对于这些也可以按照自己的想法来随便定义,只要能起到管理每一个IO请求上需要传递的网络参数的目的就好了,关键就是需要跟踪这些参数的状态,在必要的时候释放这些资源,不要造成内存泄漏,因为作为Server总是需要长时间运行的,所以如果有内存泄露的情况那是非常可怕的,一定要杜绝一丝一毫的内存泄漏

至于具体这两个结构体参数是如何在Worker线程里大发神威的,我们后面再看。

以上就是我们全部的准备工作了,具体的实现各位可以配合我的流程图再看一下示例代码,相信应该会理解得比较快。

完成端口初始化的工作比起其他的模型来讲是要更复杂一些,所以说对于主线程来讲,它总觉得自己付出了很多,总觉得Worker线程是坐享其成,但是Worker自己的苦只有自己明白,Worker线程的工作一点也不比主线程少,相反还要更复杂一些,并且具体的通信工作全部都是Worker线程来完成的,Worker线程反而还觉得主线程是在旁边看热闹,只知道发号施令而已,但是大家终究还是谁也离不开谁,这也就和公司里老板和员工的微妙关系是一样的吧……

【第五步】我们再来看看Worker线程都做了些什么

_Worker线程的工作都是涉及到具体的通信事务问题,主要完成了如下的几个工作,让我们一步一步的来看。

1 使用 GetQueuedCompletionStatus() 监控完成端口

首先这个工作所要做的工作大家也能猜到,无非就是几个Worker线程哥几个一起排好队队来监视完成端口的队列中是否有完成的网络操作就好了,代码大体如下:

void *lpContext = NULL;
OVERLAPPED *pOverlapped = NULL;
DWORD dwBytesTransfered = 0;BOOL bReturn = GetQueuedCompletionStatus(pIOCPModel->m_hIOCompletionPort,&dwBytesTransfered,(LPDWORD)&lpContext,&pOverlapped,INFINITE);

各位留意到其中的GetQueuedCompletionStatus()函数了吗?这个就是Worker线程里第一件也是最重要的一件事了,这个函数的作用就是我在前面提到的,会让Worker线程进入不占用CPU的睡眠状态,直到完成端口上出现了需要处理的网络操作或者超出了等待的时间限制为止

一旦完成端口上出现了已完成的I/O请求,那么等待的线程会被立刻唤醒,然后继续执行后续的代码。

至于这个神奇的函数,原型是这样的:

BOOL WINAPI GetQueuedCompletionStatus(__in HANDLE CompletionPort, // 这个就是我们建立的那个唯一的完成端口__outLPDWORD lpNumberOfBytes, //这个是操作完成后返回的字节数__outPULONG_PTR lpCompletionKey, // 这个是我们建立完成端口的时候绑定的那个自定义结构体参数__out LPOVERLAPPED *lpOverlapped, // 这个是我们在连入Socket的时候一起建立的那个重叠结构__in DWORD dwMilliseconds // 等待完成端口的超时时间,如果线程不需要做其他的事情,那就INFINITE就行了
);

所以,如果这个函数突然返回了,那就说明有需要处理的网络操作了 — 当然,在没有出现错误的情况下。

然后switch()一下,根据需要处理的操作类型,那我们来进行相应的处理。

但是如何知道操作是什么类型的呢?这就需要用到从外部传递进来的loContext参数,也就是我们封装的那个参数结构体,这个参数结构体里面会带有我们一开始投递这个操作的时候设置的操作类型,然后我们根据这个操作再来进行对应的处理。

但是还有问题,这个参数究竟是从哪里传进来的呢?传进来的时候内容都有些什么?

这个问题问得好!

首先,我们要知道两个关键点:

1 这个参数,是在你绑定Socket到一个完成端口的时候,用的CreateIoCompletionPort()函数,传入的那个CompletionKey参数,要是忘了的话,就翻到文档的“第三步”看看相关的内容;我们在这里传入的是定义的PER_SOCKET_CONTEXT,也就是说“单句柄数据”,因为我们绑定的是一个Socket,这里自然也就需要传入Socket相关的上下文,你是怎么传过去的,这里收到的就会是什么样子,也就是说这个lpCompletionKey就是我们的PER_SOCKET_CONTEXT,直接把里面的数据拿出来用就可以了。

2 **另外还有一个很神奇的地方,里面的那个lpOverlapped参数,里面就带有我们的PER_IO_CONTEXT。**这个参数是从哪里来的呢?我们去看看前面投递AcceptEx请求的时候,是不是传了一个重叠参数进去?这里就是它了,并且,我们可以使用一个很神奇的宏,把和它存储在一起的其他的变量,全部都读取出来,例如:

PER_IO_CONTEXT* pIoContext = CONTAINING_RECORD(lpOverlapped, PER_IO_CONTEXT, m_Overlapped);

这个宏的含义,就是去传入的lpOverlapped变量里,找到和结构体中PER_IO_CONTEXT中m_Overlapped成员相关的数据。

你仔细想想,其实真的很神奇……

但是要做到这种神奇的效果,应该确保我们在结构体PER_IO_CONTEXT定义的时候,把Overlapped变量,定义为结构体中的第一个成员。

只要各位能弄清楚这个GetQueuedCompletionStatus()中各种奇怪的参数,那我们就离成功不远了。

既然我们可以获得PER_IO_CONTEXT结构体,那么我们就自然可以根据其中的m_OpType参数,得知这次收到的这个完成通知,是关于哪个Socket上的哪个I/O操作的,这样就分别进行对应处理就好了。

在我的示例代码里,在有AcceptEx请求完成的时候,我是执行的_DoAccept()函数,在有WSARecv请求完成的时候,执行的是_DoRecv()函数,下面我就分别讲解一下这两个函数的执行流程。

【第六步】当收到Accept通知时 _DoAccept()

在用户收到AcceptEx的完成通知时,需要后续代码并不多,但却是逻辑最为混乱,最容易出错的地方,这也是很多用户为什么宁愿用效率低下的accept()也不愿意去用AcceptEx的原因吧。

和普通的Socket通讯方式一样,在有客户端连入的时候,我们需要做三件事情:

1 为这个新连入的连接分配一个Socket;

2 在这个Socket上投递第一个异步的发送/接收请求;

3 继续监听。

其实都是一些很简单的事情但是由于“单句柄数据”和“单IO数据”的加入,事情就变得比较乱。因为是这样的,让我们一起缕一缕啊,最好是配合代码一起看,否则太抽象了……

1 首先,_Worker线程通过GetQueuedCompletionStatus()里会收到一个lpCompletionKey,这个也就是PER_SOCKET_CONTEXT,里面保存了与这个I/O相关的Socket和Overlapped还有客户端发来的第一组数据等等,对吧?但是这里得注意,这个SOCKET的上下文数据,是关于监听Socket的,而不是新连入的这个客户端Socket的,千万别弄混了……

2 所以,AcceptEx不是给咱们新连入的这个Socket早就建好了一个Socket吗?所以这里,我们需要再用这个新Socket重新为新客户端建立一个PER_SOCKET_CONTEXT,以及下面一系列的新PER_IO_CONTEXT,千万不要去动传入的这个Listen Socket上的PER_SOCKET_CONTEXT,也不要用传入的这个Overlapped信息,因为这个是属于AcceptEx I/O操作的,也不是属于你投递的那个Recv I/O操作的……,要不你下次继续监听的时候就悲剧了……

3 等到新的Socket准备完毕了,我们就赶紧还是用传入的这个Listen Socket上的PER_SOCKET_CONTEXT和PER_IO_CONTEXT去继续投递下一个AcceptEx,循环起来,留在这里太危险了,早晚得被人给改了……

4 而我们新的Socket的上下文数据和I/O操作数据都准备好了之后,我们要做两件事情:一件事情是把这个新的Socket和我们唯一的那个完成端口绑定,这个就不用细说了,和前面绑定监听Socket是一样的;然后就是在这个Socket上投递第一个I/O操作请求,在我的示例代码里投递的是WSARecv()。因为后续的WSARecv,就不是在这里投递的了,这里只负责第一个请求。

但是,至于WSARecv请求如何来投递的,我们放到下一节中去讲,这一节,我们还有一个很重要的事情,我得给大家提一下,就是在客户端连入的时候,我们如何来获取客户端的连入地址信息。

这里我们还需要引入另外一个很高端的函数,GetAcceptExSockAddrs(),它和AcceptEx()一样,都是微软提供的扩展函数,所以同样需要通过下面的方式来导入才可以使用……

WSAIoctl(m_pListenContext->m_Socket, SIO_GET_EXTENSION_FUNCTION_POINTER, &GuidGetAcceptExSockAddrs,sizeof(GuidGetAcceptExSockAddrs), &m_lpfnGetAcceptExSockAddrs, sizeof(m_lpfnGetAcceptExSockAddrs),   &dwBytes, NULL, NULL);

和导出AcceptEx一样一样的,同样是需要用其GUID来获取对应的函数指针 m_lpfnGetAcceptExSockAddrs 。

说了这么多,这个函数究竟是干嘛用的呢?它是名副其实的“AcceptEx之友”,为什么这么说呢?因为我前面提起过AcceptEx有个很神奇的功能,就是附带一个神奇的缓冲区,这个缓冲区厉害了,包括了客户端发来的第一组数据、本地的地址信息、客户端的地址信息,三合一啊,你说神奇不神奇?

这个函数从它字面上的意思也基本可以看得出来,就是用来解码这个缓冲区的,是的,它不提供别的任何功能,就是专门用来解析AcceptEx缓冲区内容的。例如如下代码:

PER_IO_CONTEXT* pIoContext = 本次通信用的I/O ContextSOCKADDR_IN* ClientAddr = NULL;
SOCKADDR_IN* LocalAddr = NULL;
int remoteLen = sizeof(SOCKADDR_IN), localLen = sizeof(SOCKADDR_IN);  m_lpfnGetAcceptExSockAddrs(pIoContext->m_wsaBuf.buf, pIoContext->m_wsaBuf.len - ((sizeof(SOCKADDR_IN)+16)*2),  sizeof(SOCKADDR_IN)+16, sizeof(SOCKADDR_IN)+16, (LPSOCKADDR*)&LocalAddr, &localLen, (LPSOCKADDR*)&ClientAddr, &remoteLen);

解码完毕之后,于是,我们就可以从如下的结构体指针中获得很多有趣的地址信息了:

inet_ntoa(ClientAddr->sin_addr) 是客户端IP地址
ntohs(ClientAddr->sin_port) 是客户端连入的端口
inet_ntoa(LocalAddr ->sin_addr) 是本地IP地址
ntohs(LocalAddr ->sin_port) 是本地通讯的端口
pIoContext->m_wsaBuf.buf 是存储客户端发来第一组数据的缓冲区

自从用了“AcceptEx之友”,一切都清净了….

【第七步】当收到Recv通知时, _DoRecv()

在讲解如何处理Recv请求之前,我们还是先讲一下如何投递WSARecv请求的。

WSARecv大体的代码如下,其实就一行,在代码中我们可以很清楚的看到我们用到了很多新建的PerIoContext的参数,这里再强调一下,注意一定要是自己另外新建的啊,一定不能是Worker线程里传入的那个PerIoContext,因为那个是监听Socket的,别给人弄坏了……:

int nBytesRecv = WSARecv(pIoContext->m_Socket, pIoContext ->p_wbuf, 1, &dwBytes, 0, pIoContext->p_ol, NULL);

这里,我再把WSARev函数的原型再给各位讲一下

int WSARecv(SOCKET s, // 当然是投递这个操作的套接字LPWSABUF lpBuffers, // 接收缓冲区, 这里需要一个由WSABUF结构构成的数组DWORD dwBufferCount, // 数组中WSABUF结构的数量,设置为1即可LPDWORD lpNumberOfBytesRecvd, // 如果接收操作立即完成,这里会返回函数调用所接收到的字节数LPDWORD lpFlags, // 说来话长了,我们这里设置为0 即可LPWSAOVERLAPPED lpOverlapped, // 这个Socket对应的重叠结构NULL // 这个参数只有完成例程模式才会用到,完成端口中我们设置为NULL即可
);

其实里面的参数,如果你们熟悉或者看过我以前的重叠I/O的文章,应该都比较熟悉,只需要注意其中的两个参数:

LPWSABUF lpBuffers;

这里是需要我们自己new 一个 WSABUF 的结构体传进去的;

如果你们非要追问 WSABUF 结构体是个什么东东?我就给各位多说两句,就是在ws2def.h中有定义的,定义如下:

typedef struct _WSABUF {ULONG len; /* the length of the buffer */__field_bcount(len) CHAR FAR *buf; /* the pointer to the buffer */
} WSABUF, FAR * LPWSABUF;

而且好心的微软还附赠了注释,真不容易….

看到了吗?如果对于里面的一些奇怪符号你们看不懂的话,也不用管他,只用看到一个ULONG和一个CHAR*就可以了,这不就是一个是缓冲区长度,一个是缓冲区指针么?至于那个什么 FAR……让他见鬼去吧,现在已经是32位和64位时代了……

这里需要注意的,我们的应用程序接到数据到达的通知的时候,其实数据已经被咱们的主机接收下来了,我们直接通过这个WSABUF指针去系统缓冲区拿数据就好了,而不像那些没用重叠I/O的模型,接收到有数据到达的通知的时候还得自己去另外recv,太低端了……这也是为什么重叠I/O比其他的I/O性能要好的原因之一。

LPWSAOVERLAPPED lpOverlapped

这个参数就是我们所谓的重叠结构了,就是这样定义,然后在有Socket连接进来的时候,生成并初始化一下,然后在投递第一个完成请求的时候,作为参数传递进去就可以,

OVERLAPPED* m_pol = new OVERLAPPED;
ZeroMemory(m_pol, sizeof(OVERLAPPED));

在第一个重叠请求完毕之后,我们的这个OVERLAPPED 结构体里,就会被分配有效的系统参数了,并且我们是需要每一个Socket上的每一个I/O操作类型,都要有一个唯一的Overlapped结构去标识。

这样,投递一个WSARecv就讲完了,至于_DoRecv()需要做些什么呢?其实就是做两件事:

1 把WSARecv里这个缓冲区里收到的数据显示出来;

2 发出下一个WSARecv();

Over……

至此,我们终于深深的喘口气了,完成端口的大部分工作我们也完成了,也非常感谢各位耐心的看我这么枯燥的文字一直看到这里,真是一个不容易的事情!!

【第八步】如何关闭完成端口

休息完毕,我们继续……

各位看官不要高兴得太早,虽然我们已经让我们的完成端口顺利运作起来了,但是在退出的时候如何释放资源咱们也是要知道的,否则岂不是功亏一篑……

从前面的章节中,我们已经了解到,Worker线程一旦进入了GetQueuedCompletionStatus()的阶段,就会进入睡眠状态,INFINITE的等待完成端口中,如果完成端口上一直都没有已经完成的I/O请求,那么这些线程将无法被唤醒,这也意味着线程没法正常退出

熟悉或者不熟悉多线程编程的朋友,都应该知道,如果在线程睡眠的时候,简单粗暴的就把线程关闭掉的话,那是会一个很可怕的事情,因为很多线程体内很多资源都来不及释放掉,无论是这些资源最后是否会被操作系统回收,我们作为一个C++程序员来讲,都不应该允许这样的事情出现。

所以我们必须得有一个很优雅的,让线程自己退出的办法。

这时会用到我们这次见到的与完成端口有关的最后一个API,叫 PostQueuedCompletionStatus(),从名字上也能看得出来,这个是和 GetQueuedCompletionStatus() 函数相对的,这个函数的用途就是可以让我们手动的添加一个完成端口I/O操作,这样处于睡眠等待的状态的线程就会有一个被唤醒,如果为我们每一个Worker线程都调用一次PostQueuedCompletionStatus()的话,那么所有的线程也就会因此而被唤醒了。

PostQueuedCompletionStatus()函数的原型是这样定义的:

BOOL WINAPI PostQueuedCompletionStatus(__in HANDLE CompletionPort,__in DWORD dwNumberOfBytesTransferred,__in ULONG_PTR dwCompletionKey,__in_opt LPOVERLAPPED lpOverlapped
);

我们可以看到,这个函数的参数几乎和GetQueuedCompletionStatus()的一模一样,都是需要把我们建立的完成端口传进去,然后后面的三个参数是 传输字节数、结构体参数、重叠结构的指针.

注意,这里也有一个很神奇的事情,正常情况下,GetQueuedCompletionStatus()获取回来的参数本来是应该是系统帮我们填充的,或者是在绑定完成端口时就有的,但是我们这里却可以直接使用PostQueuedCompletionStatus()直接将后面三个参数传递给GetQueuedCompletionStatus(),这样就非常方便了。

例如,我们为了能够实现通知线程退出的效果,可以自己定义一些约定,比如把这后面三个参数设置一个特殊的值,然后Worker线程接收到完成通知之后,通过判断这3个参数中是否出现了特殊的值,来决定是否是应该退出线程了。

例如我们在调用的时候,就可以这样:

for (int i = 0; i < m_nThreads; i++)
{PostQueuedCompletionStatus(m_hIOCompletionPort, 0, (DWORD) NULL, NULL);
}

为每一个线程都发送一个完成端口数据包,有几个线程就发送几遍,把其中的dwCompletionKey参数设置为NULL,这样每一个Worker线程在接收到这个完成通知的时候,再自己判断一下这个参数是否被设置成了NULL,因为正常情况下,这个参数总是会有一个非NULL的指针传入进来的,如果Worker发现这个参数被设置成了NULL,那么Worker线程就会知道,这是应用程序再向Worker线程发送的退出指令,这样Worker线程在内部就可以自己很“优雅”的退出了……

学会了吗?

但是这里有一个很明显的问题,聪明的朋友一定想到了,而且只有想到了这个问题的人,才算是真正看明白了这个方法。

我们只是发送了m_nThreads次,我们如何能确保每一个Worker线程正好就收到一个,然后所有的线程都正好退出呢?是的,我们没有办法保证,所以很有可能一个Worker线程处理完一个完成请求之后,发生了某些事情,结果又再次去循环接收下一个完成请求了,这样就会造成有的Worker线程没有办法接收到我们发出的退出通知。

所以,我们在退出的时候,一定要确保Worker线程只调用一次GetQueuedCompletionStatus(),这就需要我们自己想办法了,各位请参考我在Worker线程中实现的代码,我搭配了一个退出的Event,在退出的时候SetEvent一下,来确保Worker线程每次就只会调用一轮 GetQueuedCompletionStatus() ,这样就应该比较安全了。

另外,在Vista/Win7系统中,我们还有一个更简单的方式,我们可以直接CloseHandle关掉完成端口的句柄,这样所有在GetQueuedCompletionStatus()的线程都会被唤醒,并且返回FALSE,这时调用GetLastError()获取错误码时,会返回ERROR_INVALID_HANDLE,这样每一个Worker线程就可以通过这种方式轻松简单的知道自己该退出了。当然,如果我们不能保证我们的应用程序只在Vista/Win7中,那还是老老实实的PostQueuedCompletionStatus()吧。

最后,在系统释放资源的最后阶段,切记,因为完成端口同样也是一个Handle,所以也得用CloseHandle将这个句柄关闭,当然还要记得用closesocket关闭一系列的socket,还有别的各种指针什么的,这都是作为一个合格的C++程序员的基本功,在这里就不多说了,如果还是有不太清楚的朋友,请参考我的示例代码中的 StopListen() 和DeInitialize() 函数。

七 完成端口使用中的注意事项

终于到了文章的结尾了,不知道各位朋友是基本学会了完成端口的使用了呢,还是被完成端口以及我这么多口水的文章折磨得不行了……

最后再补充一些前面没有提到了,实际应用中的一些注意事项吧。

1 Socket的通信缓冲区设置成多大合适?

在x86的体系中,内存页面是以4KB为单位来锁定的,也就是说,就算是你投递WSARecv()的时候只用了1KB大小的缓冲区,系统还是得给你分4KB的内存。为了避免这种浪费,最好是把发送和接收数据的缓冲区直接设置成4KB的倍数。

2 关于完成端口通知的次序问题

这个不用想也能知道,调用GetQueuedCompletionStatus() 获取I/O完成端口请求的时候,肯定是用先入先出的方式来进行的。

但是,咱们大家可能都想不到的是,唤醒那些调用了GetQueuedCompletionStatus()的线程是以后入先出的方式来进行的。

比如有4个线程在等待,如果出现了一个已经完成的I/O项,那么是最后一个调用GetQueuedCompletionStatus()的线程会被唤醒。平常这个次序倒是不重要,但是在对数据包顺序有要求的时候,比如传送大块数据的时候,是需要注意下这个先后次序的。

– 微软之所以这么做,那当然是有道理的,这样如果反复只有一个I/O操作而不是多个操作完成的话,内核就只需要唤醒同一个线程就可以了,而不需要轮着唤醒多个线程,节约了资源,而且可以把其他长时间睡眠的线程换出内存,提到资源利用率。

3 如果各位想要传输文件…

如果各位需要使用完成端口来传送文件的话,这里有个非常需要注意的地方。因为发送文件的做法,按照正常人的思路来讲,都会是先打开一个文件,然后不断的循环调用ReadFile()读取一块之后,然后再调用WSASend ()去发发送。

但是我们知道,ReadFile()的时候,是需要操作系统通过磁盘的驱动程序,到实际的物理硬盘上去读取文件的,这就会使得操作系统从用户态转换到内核态去调用驱动程序,然后再把读取的结果返回至用户态;同样的道理,WSARecv()也会涉及到从用户态到内核态切换的问题 — 这样就使得我们不得不频繁的在用户态到内核态之间转换,效率低下……

而一个非常好的解决方案是使用微软提供的扩展函数TransmitFile()来传输文件,因为只需要传递给TransmitFile()一个文件的句柄和需要传输的字节数,程序就会整个切换至内核态,无论是读取数据还是发送文件,都是直接在内核态中执行的,直到文件传输完毕才会返回至用户态给主进程发送通知。这样效率就高多了。

4 关于重叠结构数据释放的问题

我们既然使用的是异步通讯的方式,就得要习惯一点,就是我们投递出去的完成请求,不知道什么时候我们才能收到操作完成的通知,而在这段等待通知的时间,我们就得要千万注意得保证我们投递请求的时候所使用的变量在此期间都得是有效的。

例如我们发送WSARecv请求时候所使用的Overlapped变量,因为在操作完成的时候,这个结构里面会保存很多很重要的数据,对于设备驱动程序来讲,指示保存着我们这个Overlapped变量的指针,而在操作完成之后,驱动程序会将Buffer的指针、已经传输的字节数、错误码等等信息都写入到我们传递给它的那个Overlapped指针中去。如果我们已经不小心把Overlapped释放了,或者是又交给别的操作使用了的话,谁知道驱动程序会把这些东西写到哪里去呢?岂不是很崩溃……

暂时我想到的问题就是这么多吧,如果各位真的是要正儿八经写一个承受很大访问压力的Server的话,你慢慢就会发现,只用我附带的这个示例代码是不够的,还得需要在很多细节之处进行改进,例如用更好的数据结构来管理上下文数据,并且需要非常完善的异常处理机制等等,总之,非常期待大家的批评和指正。

谢谢大家看到这里!!!

完成端口(IOCP)详解[2/2](转载)相关推荐

  1. GPIO输入输出各种模式(推挽、开漏、准双向端口)详解---重点双向开漏端口

    GPIO输入输出各种模式(推挽.开漏.准双向端口)详解 原创techexchangeischeap 发布于2017-05-20 10:31:31 阅读数 62961  收藏 展开 技术交流是便宜的博客 ...

  2. 网络安全nmap扫描端口命令详解linux网络探测

    简介: nmap是一个网络连接端扫描软件,用来扫描网上电脑开放的网络连接端.确定哪些服务运行在哪些连接端,并且推断计算机运行哪个操作系统(这是亦称 fingerprinting).它是网络管理员必用的 ...

  3. (转载)完成端口(CompletionPort)详解 - 手把手教你玩转网络编程系列之三

    手把手叫你玩转网络编程系列之三 完成端口(Completion Port)详解 ----- By PiggyXP(小猪) 前 言 本系列里完成端口的代码在两年前就已经写好了,但是由于许久没有写东西了, ...

  4. 完成端口(CompletionPort)详解 - 手把手教你玩转网络编程系列之三 1-转

    完成端口(Completion Port)详解 本系列里完成端口的代码在两年前就已经写好了,但是由于许久没有写东西了,不知该如何提笔,所以这篇文档总是在酝酿之中--酝酿了两年之后,终于决定开始动笔了, ...

  5. 内网端口映射详解(花生壳)

    转自:http://hi.baidu.com/xujinnei/item/de88c5b22af4f2eb4fc7fdf2 关于如何建立服务器的解答. 一.花生壳的作用 首先,我们先来了解一下花生壳的 ...

  6. ASP.Net 2.0窗体身份验证机制详解(FormsAuthentication) (转载)

    ASP.Net 2.0窗体身份验证机制详解(FormsAuthentication) 收藏 转自:http://www.aspxclub.com/l12/c_3689.html 本篇文章介绍了在ASP ...

  7. jquery ui tabs详解(中文) 【转载】

    1 属性 1.11 ajaxOptions,当选项卡加载内容时,添加一个ajax选项.只有ajax时,添加的ajax选项才起作用.默认值为null.上面的例子中,添加了beforeSend和succe ...

  8. log4j配置详解(非常详细转载)

    转载:http://www.360doc.com/content/17/0824/16/46744981_681796916.shtml Log4J的配置文件(Configuration File)就 ...

  9. 线性规划之单纯形法【超详解+图解】-转载

    线性规划之单纯形法[超详解+图解] 目录 1.作用 2.线性规划的一般形式 5.1几何意义 5.2如何判断最优 5.3如何选择新的基变量 5.4如何选择被替换的基变量 5.5终止条件 标准型: 转化为 ...

  10. 【 Python 中 int 用法详解】(转载)

    Python 中 int 用法详解 欢迎转载,转载请注明出处! 文章目录 Python 中 int 用法详解 0. 参考资料 1. int 的无参数调用 2. int 接收数字作为参数 3. int ...

最新文章

  1. Ultimate SLAM?利用事件相机解锁高速运动、高动态范围场景
  2. 【Zookeeper进阶】大白话解释Zookeeper的选举机制
  3. 计算机组成原理CRC相关运算,计算机组成原理复习
  4. 软件工程导论第六周作业:关于servlet,jquery,ExtJs,Spket
  5. 两条实用的 SQL 语句
  6. java精确度_java的数值精度问题 | 学步园
  7. Java for循环改数据_如何改变arrs数组?当然是需用for循环啦
  8. 微课|玩转Python轻松过二级(1.3节):编码规范与代码优化建议1
  9. c#多线程thread实例详解
  10. C#代码执行中等待10秒
  11. iphone怎么录屏 苹果屏幕录制怎么操作
  12. mysql优化(关联查询优化)
  13. python二次开发ug_CAD二次开发(UG/Proe/其他) - 随笔分类 - 白途思 - 博客园
  14. OpenCV的二值化处理函数threshold()详解
  15. laravel框架跨域请求
  16. 超级详细的IDEA设置Java类和方法的注释模板
  17. 新学习之 jQuery-boxy
  18. UFS系列十:UFS电源管理
  19. python 探索性分析_python中的探索性文本分析
  20. 盐城工业职业技术学院计算机没用过,2020年江苏软考盐城工业职业技术学院考点参考人数266人...

热门文章

  1. 子盒子width为父盒子width的百分比注意
  2. JAVA语言基础-反射、特性
  3. 同行代码评审过程中的实践经验
  4. java socket - 传递对象
  5. c#+arcAE对图层进行各种渲染操作
  6. 面向实时嵌入式系统的图形用户界面支持系统――MiniGUI 背景,发展及优势
  7. 3.从Paxos到Zookeeper分布式一致性原理与实践---Paxos 工程实践
  8. 13.vim 全局替换路径
  9. 21. 投票(poll)
  10. 54. Attribute isId 属性