在c#使用Windows IOCP(完成端口)编程研究【Copy】(转)
一:概述
(1)IOCP是什么呢?
它就是IO Completion Port的缩写,它就是MS的内核调用机制。 因为在硬件里,与驱动程序打交道都是通过协议栈进行的,并且是通过发送包请求实现。 当在网络服务器使用它实现时,就会最接近内核部份,提高了性能,也提高速度。目前就要看看怎么样用IOCP创建高性能的服务器,怎么样响应大量用户的 TCP或者UDP的数据。 当创建IOCP端口后,就要初始化连接监听,这跟一般的SOCKET是没有什么区别的,当然要把它关联到IOCP,否则就不会从IOCP那里得响应。 接着就会创建满足需要的接收请求,这样就会收到连接进来。 如果有连接进来,就会收在GetQueuedCompletionStatus函数里收到前面发出的请求包,接着就进行数据监听,或者数发送的请求.就可 以进行这个连接的数据收发了。 我一直想搞清楚几个状态之间的变换,第一个就是从监听状态到连接进来,再到数据发送,然后到连接关闭。在IOCP里是怎么样来标志一个连接关闭呢? 通过查找MSDN帮助文档,看了不少资料,终于找到了。 要标志一个连接关闭,要查看两个东西,一个GetQueuedCompletionStatus函数就是接收到的数据lpNumberOfBytes为 0,另一外就是GetLastError函数返回ERROR_SUCCESS。 上面两个条件满足后,就知道SOCKET关闭了。
(2) IOCP工作原理
设备windows操作系统上允许通信的任何东西,比如文件、目录、串行口、并行口、邮件槽、命名管 道、无名管道、套接字、控制台、逻辑磁盘、物理磁盘等。绝大多数与设备打交道的函数都是CreateFile/ReadFile/WriteFile等。 所以我们不能看到**File函数就只想到文件设备。与设备通信有两种方式,同步方式和异步方式。同步方式下,当调用ReadFile函数时,函数会等待 系统执行完所要求的工作,然后才返回;异步方式下,ReadFile这类函数会直接返回,系统自己去完成对设备的操作,然后以某种方式通知完成操作。重叠 I/O顾名思义,当你调用了某个函数(比如ReadFile)就立刻返回做其他动作的时候,同时系统也在对I/0设备进行你要求的操作,在这段时间 内你的程序和系统的内部动作是重叠的,因此有更好的性能。所以,重叠I/O是用于异步方式下使用I/O设备的。重叠I/O需要使用的一个非常重要的数据结构OVERLAPPED。完成端口是一种WINDOWS内核对象。完成端口用于异步方式的重叠I/0情况下,当然重叠I/O不一定非使用完成端口不可,还有设备内核对象、事件对象、告警I/O等。但是完成端口内部提供了线程池的管理,可以避免反复创建线程的开销,同时可以根据CPU的个数灵活的决定线程个 数,而且可以让减少线程调度的次数从而提高性能。
(3)IOCP能达到的效果
首先它是使用线程池的方法。
在创建IOCP时,就要设置有多少并发线程。在调用CreateIoCompletionPort函数创建IOCP时,就要设置多少线程并发执行。如果设置NumberOfConcurrentThreads参数为0,就是让并发的线程数跟CPU个数一样。这样使用线程池,就可以不用在接收到连接时再创建任何新的线程,提供更高的响应速度。
其次,IOCP是内核的调用机制。它的优先级比较高,如果在调试程序时不小心,还是很容易死机的。我就在写错接收数据缓冲区的长度为0时,就死机了。
二、IOCP内部运行机制
(1)创建完成端
完成端口是一个内核对象,使用时他总是要和至少一个有效的设备句柄进行关联,完成端口是一个复杂的内核对象,创建它的函数是:
HANDLE CreateIoCompletionPort(
IN HANDLE FileHandle,
IN HANDLE ExistingCompletionPort,
IN ULONG_PTR CompletionKey,
IN DWORD NumberOfConcurrentThreads
);
(2)通常创建工作分两步:
第一步,创建一个新的完成端口内核对象,可以使用下面的函数:
HANDLE CreateNewCompletionPort(DWORD dwNumberOfThreads)
{
return CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, NULL, dwNumberOfThreads);
};
第二步,将刚创建的完成端口和一个有效的设备句柄关联起来,可以使用下面的函数:
bool AssicoateDeviceWithCompletionPort (HANDLE hCompPort, HANDLE hDevice,DWORD dwCompKey)
{
HANDLE h=CreateIoCompletionPort(hDevice,hCompPort,dwCompKey,0);
return h==hCompPort;
};
说明
1) CreateIoCompletionPort函数也可以一次性的既创建完成端口对象,又关联到一个有效的设备句柄。
2) CompletionKey是一个可以自己定义的参数,我们可以把一个结构的地址赋给它,然后在合适的时候取出来使用,最好要保证结构里面的内存不是分配在栈上,除非你有十分的把握内存会保留到你要使用的那一刻。
3) NumberOfConcurrentThreads通常用来指定要允许同时运行的的线程的最大个数。通常我们指定为0,这样系统会根据CPU的个数来自动确定。
创建和关联的动作完成后,系统会将完成端口关联的设备句柄、完成键作为一条纪录加入到这个完成端口的设备列表中。如果你有多个完成端口,就会有多个对应的设备列表。如果设备句柄被关闭,则表中自动删除该纪录。
(3)完成端口线程的工作原理
完成端口可以帮助我们管理线程池,但是线程池中的线程需要我们使用_beginthreadex来创建,凭什么通知完成端口管理我们的新线程呢?答案在函数GetQueuedCompletionStatus。该函数原型:
BOOL GetQueuedCompletionStatus
(IN HANDLE CompletionPort,
OUT LPDWORD lpNumberOfBytesTransferred,
OUT PULONG_PTR lpCompletionKey,
OUT LPOVERLAPPED *lpOverlapped,
IN DWORD dwMilliseconds);
这个函数试图从指定的完成端口的I/O完成队列中抽取纪录。只有当重叠I/O动作完成的时候,完成队列中才有纪录。凡是调用这个函数的线程将被放入到完成端口的等待线程队列中,因此完成端口就可以在自己的线程池中帮助我们维护这个线程。
完成端口的I/O 完成队列中存放了当重叠I/0完成的结果---- 一条纪录,该纪录拥有四个字段,前三项就对应GetQueuedCompletionStatus函数的2、3、4参数,最后一个字段是错误信息 dwError。我们也可以通过调用PostQueudCompletionStatus模拟完成了一个重叠I/0操作。当I/O完成队列中出现了纪录, 完成端口将会检查等待线程队列,该队列中的线程都是通过调用GetQueuedCompletionStatus函数使自己加入队列的。等待线程队列很简单,只是保存了这些线程的ID。完成端口会按照后进先出的原则????将一个线程队列的ID放入到释放线程列表中,同时该线程将从等待 GetQueuedCompletionStatus函数返回的睡眠状态中变为可调度状态等待CPU的调度。 基本上情况就是如此,所以我们的线程要想成为 完成端口管理的线程,就必须要调用GetQueuedCompletionStatus函数。出于性能的优化,实际上完成端口还维护了一个暂停线程列表, 具体细节可以参考《Windows高级编程指南》,我们现在知道的知识,已经足够了。
(4)线程间数据传递
线程间传递数据最常用的办法是在_beginthreadex函数中将参数传递给线程函数,或者使用全局变量。但是完成端口还有自己的传递数据的方法,答案就在于CompletionKey和OVERLAPPED参数。
CompletionKey被保存在完成端口的设备表中,是和设备句柄一一对应的,我们可以将与设备句柄相关的数据保存到CompletionKey中,或者将CompletionKey表示为结构指针, 这样就可以传递更加丰富的内容。这些内容只能在一开始关联完成端口和设备句柄的时候做,因此不能在以后动态改变。
OVERLAPPED参数是在每次调用ReadFile这样的支持重叠I/0的函数时传递给完成端口的。 我们可以看到,如果我们不是对文件设备做操作,该结构的成员变量就对我们几乎毫无作用。 我们需要附加信息,可以创建自己的结构,然后将OVERLAPPED结构变量作为我们结构变量的第一个成员,然后传递第一个成员变量的地址给 ReadFile函数。因为类型匹配,当然可以通过编译。当GetQueuedCompletionStatus函数返回时,我们可以获取到第一个成员变 量的地址,然后一个简单的强制转换,我们就可以把它当作完整的自定义结构的指针使用,这样就可以传递很多附加的数据了。太好了!只有一点要注意,如果跨线程传递,请注意将数据分配到堆上,并且接收端应该将数据用完后释放。 我们通常需要将ReadFile这样的异步函数的所需要的缓冲区放到我们自定义的结构中,这样当GetQueuedCompletionStatus被返 回时,我们的自定义结构的缓冲区变量中就存放了I/0操作的数据。CompletionKey和OVERLAPPED参数,都可以通过 GetQueuedCompletionStatus函数获得。
(5)线程的安全退出
很多线程为了不止一次的执行异步数据处理,需要使用如下语句
while (true)
{GetQueuedCompletionStatus(...);
}
那么如何退出呢,答案就在于上面曾提到的PostQueudCompletionStatus函数,我们可以用它发送一个自定义的包含了OVERLAPPED成员变量的结构地址,里面包含一个状态变量,当状态变量为退出标志时,线程就执行清除动作然后退出。
三、C#中实现IOCP编程
这里主要使用IOCP的三个API:
CreateIoCompletionPort,
PostQueuedCompletionStatus,
GetQueuedCompletionStatus,
第一个是用来创建一个完成端口对象,
第二个是向一个端口发送数据,
第三个是接受数据,基本上用着三个函数,就可以写一个使用IOCP的简单示例。
其中完成端口一个内核对象,所以创建的时候会耗费性能,CPU得切换到内核模式,而且一旦创建了内核对象,我们都要记着要不用的时候显式的释放它的句柄,释放非托管资源的最佳实践肯定是使用Dispose模式,这个博客园有人讲过N次了。而 一般要获取一个内核对象的引用, 最好用SafeHandle来引用它,这个类可以帮你管理引用计数,而且用它引用内核对象,代码更健壮,如果用指针引用内核对象,在创建成功内核对象并复 制给指针这个时间段,如果抛了ThreadAbortException,这个内核对象就泄漏了,而用SafeHandle去应用内核对象就不会在赋值的 时候发生ThreadAbortException。另外SafeHandle类继承自CriticalFinalizerObject类,并实现了IDispose接口,CLR对CriticalFinalizerObject及其子类有特殊照顾,比如说在编译的时候优先编译,在调用非 CriticalFinalizerObject类的Finalize方法后再调用CriticalFinalizerObject类的Finalize 类的Finalize方法等。在win32里,一般一个句柄是-1或者0的时候表示这个句柄是无效的,所以.net有一个SafeHandle的派生类 SafeHandleZeroOrMinusOneIsInvalid ,但是这个类是一个抽象类,你要引用自己使用的内核对象或者非托管对象,要从这个类派生一个类并重写Relseas方法。另外在.net框架里它有两个实 现几乎一模一样的子类,一个是SafeFileHandle 一个是SafeWaitHandle,前者表示文件句柄,后者表示等待句柄,我们这里为了方便 就直接用SafeFileHandle来引用完成端口对象了。
CreateIoCompletionPort函数的原型如下
[DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
public static extern SafeFileHandle CreateIoCompletionPort
(
IntPtr FileHandle,
IntPtr ExistingCompletionPort,
IntPtr CompletionKey,
uint NumberOfConcurrentThreads
);
FileHandle参数表示要绑定在完成端口上的句柄,比如说一个已经accept的socket句柄。
ExistingCompletionPort 参数表示一个已有的完成端口句柄,第一次创建完成端口的时候显然随便传个值就行,所以这个参数直接定义成IntPtr类型了。当你创建了工作线程来为I/O请求服务的时候,才会把句柄和完成端口关联在一起,而之前第一次创建完 成端口的时候这个参数传一个zero指针就O了,而FileHandle参数传一个-1的指针就行了。
CompletionKey是完成键的意思,它可以是任意想传递给工作线程的数据,学名叫做单句柄数据,就是说跟随FileHandle参数走的一些状态数据,一般在socket的iocp程序里是把 socket传进去,以便在工作线程里拿到这个socket句柄,在收到异步操作完成的通知及处理后继续进行下一个异步操作的投递,如发送和接受数据等。
NumberOfConcurrentThreads参数表示在一个完成端口上同时允许执行的最大线程数量。如果传0,就是说你有几个CPU,就是允许最大有几个线程,这也是最理想情况,因为一个CPU一个线程可以防止线程上下文切换。关 于这个值要和创建工作线程的数量有关系,大家要理解清楚,不一定 CPU有多少个,你的工作线程就创建多少个。因为你的工作线程有时候会阻塞或者等待,而如果你正好创建了CPU个数个工作线程,有一个等待的话,因为你分 配了同时最多有CPU个数多个最大IOCP线程,这时候就不能效率最大化了。所以一般工作线程创建的要比CPU个数多一些,除非你保证你的工作线程不会阻 塞。
PostQueuedCompletionStatus函数原型如下
[DllImport("Kernel32", CharSet = CharSet.Auto)]
private static extern bool PostQueuedCompletionStatus
(
SafeFileHandle CompletionPort,
uint dwNumberOfBytesTransferred,
IntPtr dwCompletionKey,
IntPtr lpOverlapped
);
该方法用于给完成端口投递自定义信息,一般情况下如果把某个句柄和完成端口绑定后,当有数据
收发操作完成时会自动同时工作线程,工作线程里的GetQueuedCompletionStatus就不会阻塞,而继续往下走,来进行接收到IO操作完
成通知的流程。而有时候我们需要手工向工作者线程投递一些消息,比如说我们主线程知道所有的socket句柄都关闭了,工作线
程可以退出了,我们就可以给工作线程发一个自定义数据,工作线程收到后判断是否是退出指令,然后退出。
CompletionPort参数表示向哪个完成端 口对象投递信息,在这个完成端口上等待消息的工作线程就会收到消息了。
dwNumberOfBytesTransferred表示你投递的数据有多大,我 们一般投递的是一个对象的指针,在32位系统里,int指针就是4个字节了,直接写4就O了,要不就用sizeof你传的数据,如 sizeof(IntPtr)。dwCompletionKey同CreateIoCompletionPort的解释,是单句柄数据,本示例用不到,不 细说,直接用IntPtr.Zero填充了事。
lpOverlapped参数,本意是一个win32的overlapped结构的指针,本示例中不用,所以不详细讲。它叫单IO数据,是相对单据并拘束CompletionKey来讲的,前者是一个句柄的每次IO操作的上下文,比如单词IO操作的数据、操作类型等,后者是整个句柄的上下文。但这里我们表示你要投递的数据,可以是任何类型的数据(谁让它是个指针呢,所以传啥都行),值得注意的一点就是,这个数据传递到工作线程的时候,中间这个数据走的是非托管代码。所以不能直接传一个引用进去,这里要使用到GCHandle类。
先大致介绍一下GCHandle类吧。它有个静态方法Alloc来给把一个对象在GC句柄表里注册,GC句柄表示CLR为没个应用程序域提供的一个表,它允许你来监视和管理对象的生命周期,你可以往里加一个对象的引用,也可以从里面移除一个对象,往里加对象的时候,还可以指定一个标记来表示我们希望如何监视和控制这个对象。而加入一个条目的操作就是 GCHandle的Alloc对象,它有两个参数,第一个参数是对象,第二参数是GCHandleType类型的枚举,第二个参数表示我们如何来监视和控制这个对象的生命周期。 当这个参数是GCHandleType.Normal时,表示我们告诉垃圾收集器,及时托管代码里没有该对象的根,也不要回收该对 象,但垃圾收集器可以移动它,一般我们向非托管代码传递一个对象,而又从非托管代码传递回来的时候用这个类型非常好,它不会让垃圾收集器在非托管代码返回 托管代码的时候回收掉该对象,还不怎么影响GC的性能,因为GC还可以移动它。dwCompletionKey就是我们在托管-非托管-托管之间传递的一 个很典型的场景。所以这里用它,另外还有GCHandleType.Pinned,它和GCHandleType.Normal不同的一点就是GC除了在 没有根的时候不能回收这个对象外,还不能移动它,应用场景是给非托管代码传递一个byte[]的buffer,让托管代码去填充,如果用 GCHandleType.Normal有可能在非托管代码返回托管代码的时候写错内存位置,因为有可能GC移动了这个对象的内存地址。关于根、GC原 理,大家可以参考相关资料。另外在你的数据从非托管代码传递会托管代码后,要调用GCHandle的实例方法free来在GC句柄表里移除该对象,这时候 你的托管代码还有个该对象的引用,也就是根,GC也不会给你回收的,当你用完了后,GC就给你回收了。GCHandle的Target属性用来访问 GCHandle指向的对象。其它两个GCHandleType的成员是关于弱引用的,和本文关系不大,就不介绍了。
GetQueuedCompletionStatus原型如下
[DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
public static extern bool GetQueuedCompletionStatus
(
SafeFileHandle CompletionPort,
out uint lpNumberOfBytesTransferred,
out IntPtr lpCompletionKey,
out IntPtr lpOverlapped,
uint dwMilliseconds
);
前几个参数和PostQueuedCompletionStatus差不多,
CompletionPort 表示在哪个完成端口上等待PostQueuedCompletionStatus发来的消息,或者IO操作完成的通 知,lpNumberOfBytesTransferred表示收到数据的大小,这个大小不是说CompletionKey的大小,而是在单次I/O操作完成后(WSASend或者WSAReceve),实际传输的字节数,我在这里理解的不是很透彻,我觉得如果是接受 PostQueuedCompletionStatus的消息的话,应该是收到lpOverlapped的大小,因为它才是单IO数据嘛。 lpCompletionKey用来接收单据并数据,我们没传递啥,后来也没用,在socket程序里,一般接socket句柄。
lpOverlapped用来接收单IO数据,或者我们的自定义消息。
dwMilliseconds表示等待一个自定义消息或者IO完成通知消息在完成端口上出现的时间,传递INIFINITE(0xffffffff)表示无限等待下去。
好了,API大概介绍这么多,下面介绍代码
1、主线程创建一个完成端口对象,不和任何句柄绑定,前几个参数都写0,NumberOfConcurrentThreads参数我们写1,因为我们的示例就一个工作线程。
2、创建一个工作线程,把第一步创建的完成端口传进去
3、创建两个单IO数据,分别发投递给第一步创建的完成端口
4、在工作线程里执行一个死循环,循环在传递进来的完成端口上等待消息,没有消息的时候GetQueuedCompletionStatus处于休息状态,有消息来的时候把指针转换成对象,然后输出
5、如果收到退出指令,就退出循环,从而结束工作者线程。
下面是完整代码,需要打开不安全代码的编译选项。
using System;
using System.Runtime.InteropServices;
using System.Threading;
using Microsoft.Win32.SafeHandles;
[StructLayout(LayoutKind.Sequential)]
class PER_IO_DATA
{ public string Data;
}
public class IOCPApiTest
{ [DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
public static extern SafeFileHandle CreateIoCompletionPort(IntPtr FileHandle, IntPtr ExistingCompletionPort, IntPtr CompletionKey, uint NumberOfConcurrentThreads);
[DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
public static extern bool GetQueuedCompletionStatus(SafeFileHandle CompletionPort, out uint lpNumberOfBytesTransferred, out IntPtr lpCompletionKey, out IntPtr lpOverlapped, uint dwMilliseconds);
[DllImport("Kernel32", CharSet = CharSet.Auto)]
private static extern bool PostQueuedCompletionStatus(SafeFileHandle CompletionPort, uint dwNumberOfBytesTransferred, IntPtr dwCompletionKey, IntPtr lpOverlapped)
public static unsafe void TestIOCPApi()
{
var CompletionPort = CreateIoCompletionPort(new IntPtr(-1), IntPtr.Zero, IntPtr.Zero, 1);
if(CompletionPort.IsInvalid)
{
Console.WriteLine("CreateIoCompletionPort 错:{0}",Marshal.GetLastWin32Error());
}
var thread = new Thread(ThreadProc);
thread.Start(CompletionPort);
var PerIOData = new PER_IO_DATA() ;
var gch = GCHandle.Alloc(PerIOData);
PerIOData.Data = "hi,我是蛙蛙王子,你是谁?";
Console.WriteLine("{0}-主线程发送数据",Thread.CurrentThread.GetHashCode());
PostQueuedCompletionStatus(CompletionPort, (uint)sizeof(IntPtr), IntPtr.Zero, (IntPtr)gch);
var PerIOData2 = new PER_IO_DATA();
var gch2 = GCHandle.Alloc(PerIOData2);
PerIOData2.Data = "关闭工作线程吧";
Console.WriteLine("{0}-主线程发送数据", Thread.CurrentThread.GetHashCode());
PostQueuedCompletionStatus(CompletionPort, 4, IntPtr.Zero, (IntPtr)gch2);
Console.WriteLine("主线程执行完毕");
Console.ReadKey();
}
static void ThreadProc(object CompletionPortID)
{
var CompletionPort = (SafeFileHandle)CompletionPortID;
while (true)
{
uint BytesTransferred;
IntPtr PerHandleData;
IntPtr lpOverlapped;
Console.WriteLine("{0}-工作线程准备接受数据",Thread.CurrentThread.GetHashCode());
GetQueuedCompletionStatus(CompletionPort, out BytesTransferred,
out PerHandleData, out lpOverlapped, 0xffffffff);
if(BytesTransferred <= 0)
continue;
GCHandle gch = GCHandle.FromIntPtr(lpOverlapped);
var per_HANDLE_DATA = (PER_IO_DATA)gch.Target;
Console.WriteLine("{0}-工作线程收到数据:{1}", Thread.CurrentThread.GetHashCode(), per_HANDLE_DATA.Data);
gch.Free();
if (per_HANDLE_DATA.Data != "关闭工作线程吧") continue;
Console.WriteLine("收到退出指令,正在退出");
CompletionPort.Dispose();
break; } }
public static int Main(String[] args)
{ TestIOCPApi(); return 0; }
}