您的位置:首页 > 其它

使用异步 I/O 大大提高应用程序的性能

2007-11-15 09:21 218 查看
对于开发一个不考虑跨平台,只在 Windows Server 环境下运行的高性能服务器来说,IOCP无疑是一个最优的解决方案。最近一个项目要用到 IOCP ,特地找了些资料。网上的资料很多,但很多都是以基础性的介绍为主,代码也是些经典书籍上的标准代码。这些代码对理解IOCP无疑是很重要的,但对于高性能服务器开发来说,细节的实现则似乎更加重要。根据自己最近做的一个项目,有几点体会,特记录下来,以备后查。

1、是在写服务端而不是在写客户端。
服务端与客户端绝对是两码事。在客户端我们提倡 Create/New 和 Free/Dispose,随用随申请,不用即释放。但在服务端要尽量避免这样做。在客户端可以随时使用 string 类型,但在服务端也必须尽量避免使用 string 。string使用起来异常方便,但我们看看编译后的代码恐怕就会只冒冷汗:原来编译器为string的方便做了那么多额外的工作。客户端要为客户解决内存,但服务端能“浪费”则“浪费”。

2、内存管理。
不得不再次佩服一下某大牛说的话:“玩服务器就是玩内存”。
内存管理不当就会造成内存泄漏和内存碎片。对于客户端而言,内存碎片几乎不算是问题。内存泄漏那么一点点也可以接受。但对于 24 * 7 的服务器而言,这却绝对致命,其重要性甚至超过了 IOCP 本身。
关于内存泄漏,只要记得保证申请和释放动作的对称性即可,外加一系列的测试工具,基本就可以把这个问题解决。
其次就是内存碎片。内存碎片问题的重要性绝不亚于内存泄漏。造成碎片的原因也是防不胜防。简单的如每次的 New 和 Dispose ,Create 和 Free ,隐晦一点的如 string 类型的操作。
解决办法:
首先对于Create和Free,尽量少用。换句话说,尽量少用封装。适当的封装是可以的,只要封装的层次不是太深。Delphi 提供了 VCL 源码,我们可以看看即使是直接继承 TObject 那也会多做多少工作!对于频繁调用的函数,不要采用虚拟函数。这些晚绑定的函数,想调用就得查找 VMT,很费时间。对于类的普通函数,由于进行了早绑定,这个和其他非类的常规一样,不会降低效率。其次,相应的,尽量使用结构和函数来代替类。对于结构,New 和 Dispose 也要尽量少用。要集中的使用来避免内存碎片。我们应该一次性把所预料的内存都申请完,服务器就得有服务器的样,放着那么多内存干什么。早晚都得申请,为什么在服务端启动的时候不一次性申请完,在服务端关闭的时候一次性释放掉?既避免了内存碎片又避免了以后的再申请操作,一举两得,何乐而不为?要知道内存分配和释放是非常昂贵的操作。不论是从时间上还是从稳定性上而言。再具体些,怎么保存这些申请到的内存?怎么保证在必要的时候可以很方便的再申请或及时的释放一些内存?我采用的是链表。在每次为一个数据结构申请内存的时候,先查看这个链表是否为空,如果不为空,就从这个链表中取出一个内存块,不需要真正调用函数申请。如果为空,再动态分配。使用完成后,把这个数据结构不释放,而是再把它插入到链表中去,以便下一次使用。再次,不用 string 用什么?用数组!用字符数组!就像C中的字符数组一样。就是这么简单~

3、使用使用 AcceptEx 代替 accept 。
AcceptEx 函数是微软的 Winsosk 扩展函数,这个函数和 accept 或 WSAAccept 是阻塞的,一直要到有客户端连接上来后 accept 才返回,而且,accept 本质上是在接受一个连接的同时再创建一个套接字。而创建一个套接字,对于 Windows 的网络模型而言,代价是非常大的。而 AcceptEx 则避免了这两个问题。首先它是异步的,直接就返回了。其次可以也是必须事先要和某一套接字绑定在一起。这样在接受一个连接时就不必再创建套接字了,而这个套接字我们可以事先使用 WSASocket 函数申请好,就像上面的预申请内存一样。总而言之一句话,“准备工作”一定要做好,到时需要拿来就是了。
这里面还有一个问题,刚开始创建和投递多少 AcceptEx 调用?万一不够用怎么办?这个问题我们可以把 FD_ACCEPT 事件和一个 Event 对象关联起来,然后用 WaitForSingleObject() 等待这个 Event ,若预投递的套接字不够用的话就会触发 FD_ACCEPT 事件, Event 受信,WaitForSingleObject() 返回,我们就重新再发出一些 AcceptEx 调用。

4、要利用好 GetQueuedCompletionStatus() 函数中的 lpCompletionKey 参数。
这个东西传递的是“单句柄数据”,换句话说,是和每个连接/套接字本身而不是在某个连接的 I/O 操作绑定的。在服务端设计当中,不可避免的,我们都会有一些只和该连接本身关联的一些数据(比如这个连接的套接字、客户端的IP,连接的会话密钥等等),如果采用传统的操作手法,将会不可避免采用一些查询机制在每次收发数据时来获取这些信息(比如数据的解密密钥),但现在我们只需要再创建完成端口时把包含这些信息结构体的指针传入就行了,下次直接使用GetQueuedCompletionStatus()取得结构体指针就行了,无需再次查询。方便和高效之极。

5、关于 Delphi 下 WinSock 函数库的封装
这是 Delphi 相对于 C/C++ 特有的问题。这些库 M$ 都是以 C 头文件(.h文件)形式给出的。因此若想在 Delphi 上调用就需要把其中的 C 表达形式转换为 Delphi 表达形式。问题就出在转换这里。抛开在转换期间可能会转换错误以外,由于没有一个强制性的转换标准,所以就会造成好几个转换版本,既便他们都是正确的。这就造成调用时所采用的代码不同。就拿最常用的 GetQueuedCompletionStatus() 函数来说,M$定义如下:
BOOL GetQueuedCompletionStatus(
HANDLE CompletionPort,
LPDWORD lpNumberOfBytes,
PULONG_PTR lpCompletionKey,
LPOVERLAPPED* lpOverlapped,
DWORD dwMilliseconds
);

其中第二、三、四个参数都是需要传入指针形式的。某个 Delphi 转换版本如下:
function GetQueuedCompletionStatus(CompletionPort: THandle;
var lpNumberOfBytesTransferred, lpCompletionKey: DWORD;
var lpOverlapped: POverlapped; dwMilliseconds: DWORD): BOOL; stdcall;

当然这个转换是没问题的,但关键是对于第二、三、四个参数它采用 var 而使得参数进行了引用传递。在调用时只需要填入参数,而不必再使用取运算符 @ 来填入参数地址。另外一个转换版本如下:
function GetQueuedCompletionStatus(CompletionPort: THandle;
lpNumberOfBytesTransferred, lpCompletionKey: PDWORD;
lpOverlapped: PPOverlapped; dwMilliseconds: DWORD): BOOL; stdcall;

这个版本没有采用 var 引用传递,而是采用的指针的值传递。调用时必须先使用运算符 @ 来取得参数地址。
那么这种差异就可能导致我们更换一个 WinSock 声明文件就不能正常编译的问题。更可怕的是编译通过,但是误把指针当引用而引起的运行时错误,更是防不胜防。所以我觉得在一个项目当中有必要统一一下。那么两种声明哪个更好些?虽然第一种在调用时代码可能书写较为美观,但我还是推荐第二种,不采用 var 引用传递的那种。原因有两点:一是这样最接近原 .h 头文件的表达形式,二是调用时也会明显看到是需要传入一个类型还是需要该指向该类型的一个指针。

其他的一些小问题:
1、既然决定采用 IOCP 了,那就不要考虑跨平台了。尽量采用 M$ 提供的扩展版本的 Winsock 函数。这通常会给程序带来一些性能方面的优化。
2、同样的代码,在不同版本的 Windows Server 上表现也是不一样的。通常版本越高级,负载能力越强。
3、虽然 IOCP 是不分 TCP 和 UDP 的,但 IOCP 通常不用在 UDP 服务端上。原因也很简单,UDP 服务端总共就需要一个套接字,但 TCP 每个连接都需要一个套接字。“绑定”在 UDP 上 IOCP 根本没有 IOCP 的感觉。:)

http://www.ibm.com/developerworks/cn/linux/l-async/ 阅读更多
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: