且构网

分享程序员开发的那些事...
且构网 - 分享程序员编程开发的那些事

浅析Delphi实现IOCP后的优化

更新时间:2022-09-22 09:48:45

在我的BLOG中有几篇文章是关于如何用DLEPHI来实现IOCP,详见我的BLOGDELPHI中完成端口(IOCP)的简单分析》。在这几篇文章中介绍了如何编写一个简单的IOCP的方法。
最近我重新对这些文章中的一些BUG和效率低下的部分做了修正(其实相当于重新编写),通过几个不同的途径对IOCP进行了实现。下面我就来说一下我对以前代码的优化方法。
 
1:结构定义部分。
首先我们必须定义一个IO数据结构,在我的BLOG中我当时是这样定义的。
1):单IO数据结构
  LPVOID = Pointer;
  LPPER_IO_OPERATION_DATA = ^ PER_IO_OPERATION_DATA ;
  PER_IO_OPERATION_DATA = packed record
    Overlapped: OVERLAPPED;
    DataBuf: TWSABUF;
    Buffer: array [0..1024] of CHAR;
    BytesSEND: DWORD;
    BytesRECV: DWORD;
  end;
和一个
2):“单句柄数据结构”
  LPPER_HANDLE_DATA = ^ PER_HANDLE_DATA;
  PER_HANDLE_DATA = packed record
    Socket: TSocket;
  end;
其实为什么我们不能将他们进行合并定义成一个结构呢?
  //IO结构
  PIOData = ^TIOData;
  TIOData = record
    Overlapped: OVERLAPPED;
    DataBuf: TWSABUF;
    Socket:TSocket;                         //套接字
    OperationType:TOperation;                //操作类型
    BufferLen:Integer;                       //数据长度
    Buffer:array[0..DATA_BUFSIZE-1] of char;  //数据信息,包括数据头信息
  end;
这种结构当我们调用
GetQueuedCompletionStatus函数的时候,用该函数的第4个参数来返回这个结构,这样一来我们就不用定义2个结构来处理
不知道大家是否还记得在我的BLOG中关于粘包的文章(我们暂且不说它是否应该叫这个名字)。关于粘包的造成原理我这里就不讲述了,如有需要可以参看我的BLOG。这里只是说明一下,粘包的处理是我们将通过IOCP得到的数据,和这个套接字上次处理并剩余的数据合并在一起,看新合并后的数据包中是否包含一个完整的数据结构,如果包含则进行相关处理,并将处理后的剩余数据进行再次判断,反复如此。一般我们会将这个合并的数据放在一个TList链表中,有的时候为了加快它的查找速度,我们会将它放在一个HASH表中,以套接字做为KEY。自然放在HASH表中的速度要比放在单纯的链表中快一些。可是我们有没有想过直接放在上面的这个IO结构中呢?也就是说将粘包处理的数组放在IO结构中,这样当GetQueuedCompletionStatus返回的时候就会直接将数据进行粘包处理,又可以免去一次的数据查找过程。这样一来,上面的数据结构就变成了:
  //IO结构
  PIOData = ^TIOData;
  TIOData = record
    Overlapped: OVERLAPPED;
    DataBuf: TWSABUF;
    Socket:TSocket;                         //套接字
    OperationType:TOperation;                //操作类型
    BufferLen:Integer;                       //数据长度
    Buffer:array[0..DATA_BUFSIZE-1] of char;  //数据信息,包括数据头信息
    SpareBuffer:array[0..2*DATA_BUFSIZE - 1 ] of char;  //处理粘包数组
    SpareBufferlen:Integer;                             //粘包数组中剩余的数据长度
  end;
 
这样做从理论上来说IOCP的速度会提高不少。但是由于我们指定了粘包处理的数组大小,这样就会出现——当我们发送过来的数据结构的长度大于粘包数数组长度的时,粘包处理就会出现问题。这个时候我觉得处理方法有两个:
1:加大粘包数组长度,这个数组的长度设置成你的所有数据结构中最大者长度的2倍。
2:使用链表来进行粘包处理,我们可以将粘包设置成一个链表,这样就避免了粘包数组长度的限制,我们可以发送一个很大的数据结构。但是这样的设置又会带来新的问题,即每次需要申请新的内存。不过这也算是一种方法,适合于数据包大小变化很大的情况。具体结构为:
PSpareBuffer = ^TSpareBuffer;
  TSpareBuffer = record
    Postion:Integer;
    SpareBuffer:array[0..DATA_BUFSIZE-1] of char;
    Next:PSpareBuffer;
  end;
 
  //IO结构
  PIOData = ^TIOData;
  TIOData = record
    Overlapped: OVERLAPPED;
    DataBuf: TWSABUF;
    Socket:TSocket;                         //套接字
    OperationType:TOperation;                //操作类型
    BufferLen:Integer;                       //数据长度
    Buffer:array[0..DATA_BUFSIZE-1] of char;  //数据信息,包括数据头信息
    FirstBuffer:PSpareBuffer;                       //粘包处理链表的第一个指针 
    LastBuffer:PSpareBuffer;                       //粘包处理链表的最后一个指针
  end;
具体的实现方法,我这里就不讲述了,大家有时间可以自行实现。
还有可以提高效率的地方吗?我们来看看以前对于粘包的处理,以前我在对粘包的处理部分使用的是将一个代表数据包长度的Integer类型,转换成一个4位的char并加入到数据包的头部,然后根据这个包头长度来处理,所以代码中就出现了不少这样的代码:PacketHeader:=StrToInt(StrPas(Temp));这个可以优化吗?当然可以,将我们发送的数据格式修改成这样:
TNetPacketed = record
  DataLen:Integer;
end;
PNetPacketed = ^TNetPacketed;
 
procedure TIOCPServer.DataProcess(PerIoData:PRecvIOData);
var
  Offset:Word;
  pPacketed:PNetPacketed;
  Data:PChar;
begin
  Offset:=0;
  while PerIoData.SpareBufferLen  - Offset >= SizeOf(TNetPacketed) do
  begin
    pPacketed:=PNetPacketed(@PerIoData.SpareBuffer[Offset]);
    if (PerIoData.SpareBufferLen - Offset) >= (pPacketed.DataLen) then
    begin
      GetMem(Data,pPacketed.DataLen - SizeOf(TNetPacketed));
      StrMove(Data,@PerIoData.SpareBuffer[Offset+SizeOf(TNetPacketed)],pPacketed.DataLen - SizeOf(TNetPacketed));
      if Assigned(OnRecive) then
      begin
        OnRecive(Data,pPacketed.DataLen - SizeOf(TNetPacketed),PerIoData.Socket);
      end;
      FreeMem(Data);
      Inc(Offset,pPacketed.DataLen);
    end
    else
    begin
      Break;
    end;
  end;
  if (Offset>0)then
  begin
    Dec(PerIoData.SpareBufferLen,Offset);
    Move(PerIoData.SpareBuffer[Offset],PerIoData.SpareBuffer,PerIoData.SpareBufferLen);
  end;
end;
本来在这里想写一些比较详细的文字来说明粘包的处理过程,可是后来觉得使用代码应该更能说明问题。所以就将我现用的代码贴了出来,我想大家看到代码就应该明白我的意思了呵呵。
 
2:使用内存池。
使用内存池来提高IOCP的效率,这几乎是大家的共识。可是如何使用内存池呢?有人喜欢使用环形内存池,有人喜欢用链表内存池,也有人直接使用FASTMM来做内存池的。我觉得这些方法都可以达到一定的目的,我使用内存池的类是这样的:
 
//发送内存池管理类
  TMemPoolControl = class
  private
    { Private declarations }
    FMemFirst:PIOData;
    FMemCS:TRTLCriticalSection;
    procedure CreateMem;
    procedure AddMem(p_IOData:PIOData);
  public
    { Public declarations }
    FMemCount:Integer;
    constructor Create;
    destructor Destroy; override;
    //申请一个发送空间
    procedure AllocateBuffer(var p_IOData: PIOData);
    procedure ReleaseBuffer(p_IOData: PIOData);
 
  end;
 
 
{ TMemPoolControl }
 
procedure TMemPoolControl.AddMem(p_IOData: PIOData);
var
  p_MoveIOData,p_OldIOData:PIOData;
begin
  if FMemCount > MAXPOOLNUMS then
  begin
    //内存池数据太多,直接释放
    HeapFree(GetProcessHeap, 0, p_IOData);
    Exit;
  end;
  //初始化此内存块
  FillChar(p_IOData.Buffer,SizeOf(p_IOData.Buffer),#0);
  p_IOData.BufferLen:=0;
  p_IOData.Next:=nil;
  p_OldIOData:=nil;
  p_MoveIOData:=FMemFirst;
  if not Assigned(FMemFirst) then
  begin
    FMemFirst:=p_IOData;
  end
  else
  begin
    //循环查找最后一个内存指针
    while Assigned(p_MoveIOData) do
    begin
      p_OldIOData:=p_MoveIOData;
      p_MoveIOData:=p_MoveIOData.Next;
    end;
    p_OldIOData.Next:=p_IOData;
  end;
  Inc(FMemCount);
end;
 
procedure TMemPoolControl.AllocateBuffer(var p_IOData: PIOData);
begin
  EnterCriticalSection(FMemCS);
  try
    if Assigned(FMemFirst) then
    begin
      p_IOData:=FMemFirst;
      FMemFirst:=FMemFirst.Next;
      p_IOData.Next:=nil;
      Dec(FMemCount);
    end
    else
    begin
      CreateMem;
      p_IOData:=FMemFirst;
      FMemFirst:=FMemFirst.Next;
      p_IOData.Next:=nil;
      Dec(FMemCount);
    end;
  finally
    LeaveCriticalSection(FMemCS);
  end;
end;
 
constructor TMemPoolControl.Create;
begin
  FMemFirst:=nil;
  FMemCount:=0;
  InitializeCriticalSection(FMemCS);
end;
 
procedure TMemPoolControl.CreateMem;
var
  I:Integer;
  Buf:PIOData;
begin
  for I:=1 to (MAXPOOLNUMS - FMemCount) do
  begin
    Buf:=PIOData(HeapAlloc(GetProcessHeap,HEAP_ZERO_MEMORY,sizeof(TIOData)));
    Buf.Next:=nil;
    if Assigned(Buf) then
    begin
      AddMem(Buf);
    end;
  end;
end;
 
destructor TMemPoolControl.Destroy;
var
  p_IOData:PIOData;
begin
  EnterCriticalSection(FMemCS);
  try
    //清空接收缓冲池
    while Assigned(FMemFirst) do
    begin
      p_IOData:=FMemFirst;
      FMemFirst:=FMemFirst.Next;
      p_IOData.Next:=nil;
      HeapFree(GetProcessHeap, 0, p_IOData);
    end;
    FMemCount:=0;
    FMemFirst:=nil;
  finally
    LeaveCriticalSection(FMemCS);
    DeleteCriticalSection(FMemCS);
  end;
  inherited;
end;
 
procedure TMemPoolControl.ReleaseBuffer(p_IOData: PIOData);
begin
  EnterCriticalSection(FMemCS);
  try
    if Assigned(p_IOData) then
    begin
      p_IOData.BufferLen:=0;
      FillChar(p_IOData.Buffer,SizeOf(p_IOData.Buffer),#0);
      p_IOData.Next:=nil;
      AddMem(p_IOData);
    end;
  finally
    LeaveCriticalSection(FMemCS);
  end;
end;
 
这个代码中有一个比较慢的地方,我只定义了这个内存池的头指针,而没有定义尾指针。所以在加入一个新内存的时候就要从头查找一遍,降低了效率。大家可以在这里定义一个尾指针用于加快插入速度。
 
3:连接池。
连接池的时候主要是使用ACCEPTEX函数来代替WSAAccept函数。这个函数最大的好处是可以实现创建出多个套接字。但是在我实际使用中却发现,它有几个不好的地方
1):控制麻烦:我相信使用过ACCEPTEX的朋友应该会同意我的观点。较之WSAAccept函数来说,ACCEPTEX函数使用起来繁琐很多。首先要将此函数引入,然后预先创建多个套接字并将这些套节字都投递accept请求,并将这些套接字放在一个链表中。投递请求后,设置2个事件放在事件数组中,这时创建工作者线程,并将工作者线程的句柄保存在事件数组中,然后使用WSAWaitForMultipleEvents函数WSAWaitForMultipleEvents( FNetServer.EventSerial + 1, @FNetServer.Eventarray[0], FALSE, 1000, False );来等待相应的事件出发,对于超时事件我们需要对链表中的套接字进行检测是否超时,对于…….说着我就头大。实现的代码,和我写的其它版本的IOCP对比了一下,复杂程度和可控制程度麻烦了许多,代码越多出错几率就会越大,所以我不建议大家使用ACCEPTEX
2):连接判断:对于使用ACCEPTEX函数最大的问题,我觉得是它无法做到对于连接请求是否允许连接进行的判断。我们知道在WSAAccept函数中有个参数 LPCONDITIONPROC lpfnCondition,这个参数是一个回调函数,此函数用于判断此次连接是否被允许。如果这个函数返回CF_ACCEPT表示允许连接,如果返回CF_REJECT则表示不可以连接。而在ACCEPTEX函数中我们却看不到这种功能的存在(兴许有,但是我没有找到)。
综上所述,我不建议大家使用ACCEPTEX。当然如果有人使用的话如果有什么问题,可以在我的BLOG中留言我会尽量帮助大家。
 
4:多次投递。
在《windows网络与通信程序设计》一书中,关于IOCP的注意事项讲述的“包重新排序问题”中有提到。结论是“这个问题可以通过仅使用一个工作线程,仅提交一个I/O调用,然后等待它完成来避免。但是这样就丧失了IOCP的所有优点。”在我写的IOCP的各版本中,有使用多次投递的方式来实现的,也有使用单次投递方式来实现的。不过我的测试发现他们之间的效果相差不大。这也可能是我的实现或者测试方法的问题,希望大家有机会可以用这种方法也做一下测试,看看两者之间的差距。多次投递的理论在于我们一次性投递多次的WSARECV。正如我们一次性给系统5个空篮子一样,如果有数据到来的时候,系统会根据我们投递WSARECV的顺序给不同篮子以数据(这里注意的是,系统给每个篮子数据的时候,不是随机给的,而是依据你投递的顺序给的,例如你第一个给的是A篮子,第二个给的是B篮子,那么系统一定会将第一个数据放在A篮子中)。但是由于IOCP使用的是多线程来处理的,那么在我们得到的时候有可能是先得到B篮子,这个时候就需要进行重新排序的过程。具体的方法可以类似书中讲述的方法。
 
以上4点是我编写IOCP的时候所感受到的,希望写出来和大家一起探讨一下。
本文转自狗窝博客51CTO博客,原文链接http://blog.51cto.com/fxh7622/106035如需转载请自行联系原作者

fxh7622