异常连接导致的内存泄漏排查


背景

在生产环境中,部署在客户的程序在运行了将近两个月后发生了闪退。而且两个服务器的程序先后都出现了闪退现象。通过排查windows日志发现是OOM异常导致的闪退。本文记录了该异常事件完整的排查过程与解决方案。

在本篇文章中会涉及到以下技术知识点:使用windbg对dump文件进行内存分析、使用wireshark抓包分析、powershell脚本编写、完成端口及重叠I/O原理等。

详细流程

程序崩溃后,我们要求客户导出一个dump文件供我们分析,并提供程序相关的运行日志。同时查看了windows的相关日志确定了是由于OOM(Out Of Memory)异常导致的。

使用windbg分析dump文件

启动windbg打开dump文件

20190728143557.png

由于我们的程序是基于.net framework 3.5开发的,因此我们使用SOS的相关扩展命令进行分析。需要在windbg中导入mscorwks
.loadby sos mscorwks

想对windbg进行深入学习,可以查看《使用WinDbg》讲解的非常详细。

通过!dumpheap -stat对内存占用情况进行汇总统计。

!dumpheap -stat 
...
00007ff7ffbc0d50   536240     17159680 NetMQ.Core.Utils.Proactor+Item
00007ff7ffbca7f8   536242     17159744 NetMQ.Core.IOObject
00007ff7ffbcba70   536534     34338176 AsyncIO.Windows.AcceptExDelegate
00007ff7ffbcb7f0   536534     34338176 AsyncIO.Windows.ConnectExDelegate
00007ff7ffbcbdd8  1073068     60091808 AsyncIO.Windows.Overlapped
00007ff7ffbcb600   536534     90137712 AsyncIO.Windows.Socket
Total 3839215 objects

由于我们的程序底层网络通讯框架时基于NetMQ自研发的框架,从内存占用情况来看所有内存占用都是NetMQ底层依赖的AsyncIO的对象。因此接下来就对具体的对象进行分析。

再次通过!do 抽取几个对象查看。发现所有的对象实际已经调用过了Dispose方法释放内存。但是对象没有被GC回收。

0:000> !do 00000000238b7b48 
Name: AsyncIO.Windows.Overlapped
MethodTable: 00007ff7ffbcbdd8
EEClass: 00007ff7ffbbea30
Size: 56(0x38) bytes
 (D:\FingardFC_V2.18.2\AsyncIO.dll)
Fields:
              MT    Field   Offset                 Type VT     Attr            Value Name
00007ff85e5fa7f8  4000027       18        System.IntPtr  1 instance         22b0c060 m_address
00007ff85e5f3bc0  4000028       28 ...Services.GCHandle  1 instance 00000000238b7b70 m_handle
00007ff7ffbc3210  4000029       20         System.Int32  1 instance                0 <OperationType>k__BackingField
00007ff7ffbcb600  400002a        8 ...IO.Windows.Socket  0 instance 00000000238b7a68 <AsyncSocket>k__BackingField
00007ff85e5f6fc0  400002b       24       System.Boolean  1 instance                0 <InProgress>k__BackingField
00007ff85e5f6fc0  400002c       25       System.Boolean  1 instance                1 <Disposed>k__BackingField
00007ff85e5f76e0  400002d       10        System.Object  0 instance 00000000238b7df8 <State>k__BackingField
00007ff85e5ff060  4000022       58         System.Int32  1   static               40 Size
00007ff85e5ff060  4000023       5c         System.Int32  1   static                8 BytesTransferredOffset
00007ff85e5ff060  4000024       60         System.Int32  1   static               16 OffsetOffset
00007ff85e5ff060  4000025       64         System.Int32  1   static               24 EventOffset
00007ff85e5ff060  4000026       68         System.Int32  1   static               32 MangerOverlappedOffset
0:000> !do 00000000238acc50 
Name: AsyncIO.Windows.Overlapped
MethodTable: 00007ff7ffbcbdd8
EEClass: 00007ff7ffbbea30
Size: 56(0x38) bytes
 (D:\FingardFC_V2.18.2\AsyncIO.dll)
Fields:
              MT    Field   Offset                 Type VT     Attr            Value Name
00007ff85e5fa7f8  4000027       18        System.IntPtr  1 instance         22b0ad70 m_address
00007ff85e5f3bc0  4000028       28 ...Services.GCHandle  1 instance 00000000238acc78 m_handle
00007ff7ffbc3210  4000029       20         System.Int32  1 instance                1 <OperationType>k__BackingField
00007ff7ffbcb600  400002a        8 ...IO.Windows.Socket  0 instance 00000000238acba8 <AsyncSocket>k__BackingField
00007ff85e5f6fc0  400002b       24       System.Boolean  1 instance                1 <InProgress>k__BackingField
00007ff85e5f6fc0  400002c       25       System.Boolean  1 instance                1 <Disposed>k__BackingField
00007ff85e5f76e0  400002d       10        System.Object  0 instance 00000000238acf38 <State>k__BackingField
00007ff85e5ff060  4000022       58         System.Int32  1   static               40 Size
00007ff85e5ff060  4000023       5c         System.Int32  1   static                8 BytesTransferredOffset
00007ff85e5ff060  4000024       60         System.Int32  1   static               16 OffsetOffset
00007ff85e5ff060  4000025       64         System.Int32  1   static               24 EventOffset
00007ff85e5ff060  4000026       68         System.Int32  1   static               32 MangerOverlappedOffset

查看终结队列中的对象,可以发现对象都在终结队列中。

0:000> !finq -stat
Generation 0:
       Count      Total Size   Type
---------------------------------------------------------
           1             168   AsyncIO.Windows.Socket

1 object, 168 bytes

Generation 1:
       Count      Total Size   Type
---------------------------------------------------------
        1008          169344   AsyncIO.Windows.Socket
           2              48   System.Windows.Forms.VisualStyles.VisualStyleRenderer+ThemeHandle

1,010 objects, 169,392 bytes

Generation 2:
       Count      Total Size   Type
---------------------------------------------------------
           1             776   FC.Main.frmMain
           1             104   AsyncIO.Windows.CompletionPort
      535525        89968200   AsyncIO.Windows.Socket
...

查看垃圾回收器句柄的统计信息,存在大量的重叠资源对象未释放。

0:000> !gchandles
GC Handle Statistics:
Strong Handles: 520519
Pinned Handles: 84
Async Pinned Handles: 0
Ref Count Handles: 0
Weak Long Handles: 43
Weak Short Handles: 116
Other Handles: 0
Statistics:
              MT    Count    TotalSize Class Name
...
00007ff85e5e5be0      510      2435216 System.Object[]
00007ff7ffbcbdd8   511752     28658112 AsyncIO.Windows.Overlapped
Total 520762 objects

我使用的NetMQ版本是4.0.0.1,使用的AsyncIO版本是0.1.26.0

AsyncIO重叠资源释放代码如下

public void Dispose()
{
    if (!InProgress)
    {
        Free();
    }

    Disposed = true;            
}
private void Free()
{
    Marshal.FreeHGlobal(m_address);

    if (m_handle.IsAllocated)
    {
        m_handle.Free();
    }        
}

InProgress=false才会释放相关的非托管资源句柄。在对InProgress查找所有引用。发现只有一个地方对其赋值为ture

public void StartOperation(OperationType operationType)
{
    InProgress = true;
    Success = false;
    OperationType = operationType;
}

再对StartOperation查找引用,一共有4个地方调用。

20190728151917.png

可以发现该字段适用于表示重叠I/O是否正在处理。在如果重叠I/O正在处理,则不释放相关的资源,具体原因后面讲到重叠I/O时会进行说明。

使用wireshark抓包分析

与此同时,我们对程序日志也进行了分析。发现我们的程序接收到了大量的Http请求。

由于我们和客户接口是通过TCP协议传输,而非HTTP协议,因此理论上不应该会有HTTP请求发到我们程序端口上。又因为我们程序有接收超时机制,即使有我们无法解析的无效请求,超过了超时时间我们也会将对应的资源释放。而且从dump文件来看也没有我们未释放的资源对象。

为了搞清楚到底是什么请求发到我们程序上,因此要求客户在服务器抓包。我们对抓包文件进行分析。发现抓到了大量的异常连接,每5秒会有2个。
20190728153333.png

然后我通过计算未释放对象的数量基本与接收到这个包数量吻合。因此初步断定内存泄漏是由于该包引起的。这个包应该是一个服务监控程序发的,每五秒发一次,有2个地址在往我们程序发。

完成端口和重叠IO

确定了初步的原因,接下来就需要进行源码分析,排查问题点。由于AsyncIO使用的是基于完成端口的重叠I/O,因此有必要先对重叠I/O和完成端口进行简单介绍。

重叠I/O

一般来说我们开发程序需要进行I/O读写使用同步I/O与异步I/O两种方式。
同步I/O是大多数开发人员习惯的使用方式,从文件或网络中读取数据,线程会被挂起,等待数据读取完毕后继续执行。异步I/O则不会等待I/O调用完成,而是立即发返回,操作系统完成我们的I/O请求后会进行通知。

在Windows下的异步I/O我们也可以称之为重叠(overlapped)I/O。重叠的意思是执行I/O请求的时间与线程执行其他任务的时间是重叠的,即执行真正I/O请求的时候,我们的工作线程可以执行其他请求,而不会阻塞等待I/O请求执行完毕。

完成端口

实际在windows上一共支持四种接收完成通知的方式。分别为触发设备内核对象、触发时间内核对象、可提醒I/O以及I/O完成端口。其他三种有或多或少的缺点,而完成端口则是在Windows上性能最佳的接收I/O完成通知的方式。

想要详细了解四种接收完成通知方式的同学可以查阅《Windows via C/C++ 第五版》(也被称为Windows核心编程第五版)的第十章-同步设备I/O与异步设备I/O的10.5节。

I/O完成端口的设计理论依据是并发编程的线程数必须有一个上限,即最佳并发线程数为CPU的逻辑线程数。I/O完成端口充分的发挥了并发编程的优势的同时又避免了线程上下文切换带来的性能损失。

在大多数x86和x64的多处理器,线程上下文切换时间间隔大约为15ms。
CPU每过大约15ms将CPU寄存器当前的线程上下文存回到该线程的上下文,然后该线程不在运行。然后系统检查剩下的可调度线程内核对象,选择一个线程的内核对象,将其上下文载入导CPU寄存器中。
关于Windows线程相关内容可以查阅《Windows via C/C++ 第五版》的第七章

Reactor模型与Proactor模型

目前常提到的I/O多路复用主要包含两种线程模型,Reactor模型和Procator模型。

Reactor模型是同步非阻塞线程模型。在设备可读写时,系统会进行通知,然后我们从设备读写数据。
Proactor模型时异步线程模型。在读写完毕时,系统会进行通知,然后我们就可以处理读写完毕后的事件。

在windows的完成端口就是系统层面的异步I/O模型。而linux仅支持select、epoll、kqueue等同步非阻塞I/O模型。

关于Reactor和Proactor的具体处理逻辑可以看Reactor与Proactor的概念如何深刻理解reactor和proactor?两篇文章。

完成端口处理逻辑

为了更好的分析问题,还需要清楚重叠I/O和完成端口的完整处理流程。
I/O设备包含了如文件、目录、套接字、逻辑/物理磁盘驱动器等等。由于windows下异步I/O设计的通用性,所以I/O设备都能充分利用重叠I/O和完成端口提升性能。由于目前我们的场景是使用套接字(socket)进行I/O读写,因此后面直接使用套接字来表示设备,实际其他I/O的处理流程也是一样的。

创建完成端口。

在外面创建网络监听的时候,首先我们需要创建一个完成端口,后续设备的通知都需要通过该完成端口进行通知。
创建完成端口的时候可以指定允许并发执行线程的数量,在应用程序初始化时,就会创建线程池,并初始化线程,以便提高应用程序的性能。

注册套接字

相比同步I/O,使用完成端口需要我们先将设备注册到完成端口。
首先我们创建一个用于监听的套接字,然后将其绑定到完成端口上。该操作会将套接字添加到完成端口的设备列表中,这样当该套接字的I/O请求处理完成时,I/O线程就会将该套接字的完成事件加入到完成端口的I/O完成队列中。
注册完之后就可以绑定并开始监听端口了。

接收客户端请求

同步I/O是在设备可读写的时候会通知我们,然后在创建一个套接字用于处理客户端I/O读写。
异步I/O则需要先创建一个套接字,然后将其绑定到完成端口上,当我们接收到新的客户端请求时,实际的I/O操作已经完成。
由于创建套接字的开销非常大,因此异步I/O提前准备好一个套接字相比同步I/O接收到请求以后再创建,性能会更好。

处理I/O请求

读请求

同步I/O可以断的查看设备是否可读。当设备可读时,再从设备缓冲区读取数据到内存中。
异步I/O首先需要初始化一个内存空间用于接收数据,然后调用重叠读操作,当系统接收到数据时,I/O线程将数据直接写入到我们提供的内存地址中,完成后就会将I/O请求加入I/O完成队列,我们就可以接收到I/O读完成通知。当我们收到通知时,如果没有发生错误,实际数据已经从系统缓冲取加载到内存了。

写请求

同步I/O在发送数据的时候同步的将数据写入到缓冲区。这个过程我们的线程实际是阻塞的。
异步I/O在发送数据的时候,先发起重叠写操作,当数据写入到缓冲区后,就会将I/O请求加入到I/O完成队列。我们就可以收到I/O完成的通知。所以实际数据写入缓冲区时我们的工作线程仍然可以并发处理其他事情。

问题排查

在简单介绍了重叠I/O和完成端口后,回到问题排查中。由于前面我们已经发现所有内存泄漏点都是由于重叠资源未释放导致的,而实际我们已经调用过Dipose释放资源

首先来看下创建套接字、接收数据、发送数据和释放套接字的时候分别做了什么

创建套接字

public Socket(AddressFamily addressFamily, SocketType socketType, ProtocolType protocolType)
        : base(addressFamily, socketType, protocolType)
{
    m_disposed = false;

    m_inOverlapped = new Overlapped(this);
    m_outOverlapped = new Overlapped(this);

    m_sendWSABuffer = new WSABuffer();
    m_receiveWSABuffer = new WSABuffer();

    InitSocket();
    InitDynamicMethods();
}
public Overlapped(Windows.Socket asyncSocket)
{
    Disposed = false;
    InProgress = false;
    AsyncSocket = asyncSocket;
    m_address = Marshal.AllocHGlobal(Size);
    Marshal.WriteIntPtr(m_address, IntPtr.Zero);
    Marshal.WriteIntPtr(m_address,BytesTransferredOffset, IntPtr.Zero);
    Marshal.WriteInt64(m_address, OffsetOffset, 0);
    Marshal.WriteIntPtr(m_address, EventOffset, IntPtr.Zero);

    m_handle = GCHandle.Alloc(this, GCHandleType.Normal);

    Marshal.WriteIntPtr(m_address, MangerOverlappedOffset, GCHandle.ToIntPtr(m_handle));            
}
  1. 创建重叠资源。在创建重叠资源的时候,会通过GCHandle.Alloc分配句柄,防止托管对象被GC回收导致非托管资源被回收。只有调用Free才能被回收。
  2. 初始化输入输出对象WSABuffer。当发送或接收数据时会直接使用该对象地址,而不会发生内存复制。
  3. 初始化一个套接字对象
private void InitSocket()
{
    Handle = UnsafeMethods.WSASocket(AddressFamily, SocketType, ProtocolType,
        IntPtr.Zero, 0, SocketConstructorFlags.WSA_FLAG_OVERLAPPED);

    if (Handle == UnsafeMethods.INVALID_HANDLE_VALUE)
    {
        throw new SocketException();
    }
}

初始化接收扩展方法和连接的扩展方法

 internal static class UnsafeMethods
{
    public static readonly Guid WSAID_CONNECTEX = new Guid("25a207b9-ddf3-4660-8ee9-76e58c74063e");
    public static readonly Guid WSAID_ACCEPT_EX = new Guid("b5367df1-cbac-11cf-95ca-00805f48a192");
    ...
}
private void InitDynamicMethods()
{
    m_connectEx =
        (ConnectExDelegate)LoadDynamicMethod<ConnectExDelegate>(UnsafeMethods.WSAID_CONNECTEX);

    m_acceptEx =
        (AcceptExDelegate)LoadDynamicMethod<AcceptExDelegate>(UnsafeMethods.WSAID_ACCEPT_EX);
}

异步接收套接字

public void AcceptInternal(AsyncSocket socket)
{
    if (m_acceptSocketBufferAddress == IntPtr.Zero)
    {
        m_acceptSocketBufferSize = (m_boundAddress.Size + 16) * 2;

        m_acceptSocketBufferAddress = Marshal.AllocHGlobal(m_acceptSocketBufferSize);
    }

    int bytesReceived;

    m_acceptSocket = socket as Windows.Socket;

    m_inOverlapped.StartOperation(OperationType.Accept);

    if (!m_acceptEx(Handle, m_acceptSocket.Handle, m_acceptSocketBufferAddress, 0,
            m_acceptSocketBufferSize / 2,
            m_acceptSocketBufferSize / 2, out bytesReceived, m_inOverlapped.Address))
    {
        var socketError = (SocketError)Marshal.GetLastWin32Error();

        if (socketError != SocketError.IOPending)
        {
            throw new SocketException((int)socketError);
        }                
    }
    else
    {                
        CompletionPort.PostCompletionStatus(m_inOverlapped.Address);
    }
}

  1. 首先初始化用于接收客户套接字的地址。m_boundAddress是当前监听的套接字对象。
    m_boundAddressm_boundAddress.Size则是根据IPV4还是IPV6决定的,具体细节不做分析。通过Marshal.AllocHGlobal分配非托管内存,返回一个地址。
  2. 执行重叠操作异步接收客户端连接。通过调用m_acceptEx异步接收客户连接。前面提到异步I/O接收,先创建套接字用于接收,这样真正到接收客户端连接时就无需再创建套接字了。
  3. 判断返回执行结果。重叠操作执行完毕需要调用GetLastWin32Error判断操作是否执行成功。
    • 当返回SUCCESS时,表示I/O操作完成。若在读取数据时,数据已经在缓存中,则系统不会将I/O请求添加到设备驱动程序的队列,而是直接以同步的方式从高速缓存中的数据复制到我们的缓存中,从而完成I/O操作。
    • 若返回为ERROR_IO_PENDING时,则表示I/O请求已经被成功的加入到了设备驱动程序的队列,会在晚些时候完成。
    • 若返回其他值时,则表示I/O请求无法被添加到设备驱动程序的队列。

接收数据

public override void Receive(byte[] buffer, int offset, int count, SocketFlags flags)
{
    if (buffer == null)
        throw new ArgumentNullException("buffer");

    if (m_receivePinnedBuffer == null)
    {
        m_receivePinnedBuffer = new PinnedBuffer(buffer);
    }
    else if (m_receivePinnedBuffer.Buffer != buffer)
    {
        m_receivePinnedBuffer.Switch(buffer);
    }


    m_receiveWSABuffer.Pointer = new IntPtr(m_receivePinnedBuffer.Address + offset);
    m_receiveWSABuffer.Length = count;

    m_inOverlapped.StartOperation(OperationType.Receive);

    int bytesTransferred;
    SocketError socketError = UnsafeMethods.WSARecv(Handle, ref m_receiveWSABuffer, 1,
        out bytesTransferred, ref flags, m_inOverlapped.Address, IntPtr.Zero);

    if (socketError != SocketError.Success)
    {
        socketError = (SocketError)Marshal.GetLastWin32Error();

        if (socketError != SocketError.IOPending)
        {
            throw new SocketException((int)socketError);
        }
    }
}

接收时首先将接收数据转换为WSABuffer对象。由于异步I/O请求完成之前,一定不能移动或销毁所使用的数据缓存和重叠接口,因此我们需要将数据缓存钉住,防止它被垃圾回收,且防止垃圾回收内存整理时对象被移动导致地址发生变化。

class PinnedBuffer : IDisposable
{
    private GCHandle m_handle;
    public PinnedBuffer(byte[] buffer)
    {
        SetBuffer(buffer);
    }

    public byte[] Buffer { get; private set; }
    public Int64 Address { get; private set; }

    public void Switch(byte[] buffer)
    {
        m_handle.Free();

        SetBuffer(buffer);
    }

    private void SetBuffer(byte[] buffer)
    {
        Buffer = buffer;
        m_handle = GCHandle.Alloc(buffer, GCHandleType.Pinned);
        Address = Marshal.UnsafeAddrOfPinnedArrayElement(Buffer, 0).ToInt64();
    }
    public void Dispose()
    {
        m_handle.Free();
        Buffer = null;
        Address = 0;
    }
}

由于我们传递的值数据缓存地址,因此异步I/O不会发生内存复制,提高了性能。
当标记了Pinned或Normal,GC都不会回收资源,但是标记为Normal时由于垃圾回收内存整理地址可能会变,而Pinned则表示该对象不要移动。这样就保证了重叠操作不会发生错误。

因此在重叠操作处理的时候,我们通过m_inOverlapped.StartOperation(OperationType.Receive);设置重叠对象的InProgress属性为true,表示重叠操作正在处理中。

发送数据

发送数据和接收数据类似,这里不做具体说明。下面将与接收数据不同的代码列出来。

public override void Send(byte[] buffer, int offset, int count, SocketFlags flags)
{
    ...
    m_sendWSABuffer.Pointer = new IntPtr(m_sendPinnedBuffer.Address + offset);
    m_sendWSABuffer.Length = count;

    m_outOverlapped.StartOperation(OperationType.Send);
    int bytesTransferred;
    SocketError socketError = UnsafeMethods.WSASend(Handle, ref m_sendWSABuffer, 1,
        out bytesTransferred, flags, m_outOverlapped.Address, IntPtr.Zero);
    ...
}

释放套接字

当网络传输完成时,需要释放套接字,同时还需要释放相关的非托管资源。

private void Dispose(bool disposing)
{
    if (!m_disposed)
    {
        m_disposed = true;                

        m_inOverlapped.Dispose();
        m_outOverlapped.Dispose();

        // for Windows XP
#if NETSTANDARD1_3
        UnsafeMethods.CancelIoEx(Handle, IntPtr.Zero);
#else
        if (Environment.OSVersion.Version.Major == 5)
            UnsafeMethods.CancelIo(Handle);
        else
            UnsafeMethods.CancelIoEx(Handle, IntPtr.Zero);
#endif

        int error = UnsafeMethods.closesocket(Handle);

        if (error != 0)
        {
            error = Marshal.GetLastWin32Error();
        }
        ...
        if (m_acceptSocket != null)  
            m_acceptSocket.Dispose();                    
    }
}

释放套接字资源的时候首先需要释放相关的重叠资源。前面已经看过释放重叠资源的代码,这里为了方便分析,再次列一下。

public void Dispose()
{
    if (!InProgress)
    {
        Free();
    }

    Disposed = true;            
}

private void Free()
{
    Marshal.FreeHGlobal(m_address);

    if (m_handle.IsAllocated)
    {
        m_handle.Free();
    }        
}
  1. 前面提到过,在重叠操作正在进行的时候,不能将数据缓存和重叠结构释放掉,否则系统处理可能出现异常。假设发生了垃圾回收将资源释放了,但是此时发生了I/O读写,可能该地址指向是其他的对象,因此可能会造成内存溢出等问题。同时出现了该问题还非常难以排查原因。
  2. 取消完成端口通知。
  3. 关闭套接句柄。

分析问题

前面详细的介绍和分析了异步(重叠)I/O和完成端口的原因,那么接下来对内存泄露的具体原因进行分析。我们通过dump文件已经知道了套接字对象实际已经被释放了。套接字对象和重叠资源对象形成了循环引用,但是GC是非常聪明的,能够识别这种情况,仍然是可以将其回收掉。但是为什么套接字对象和重叠资源还是没有被回收掉呢?

这是因为由于我们的重叠操作正在处理,因此InProgress设置成了true,但是由于释放重叠资源的时候重叠操作正在处理,因此我们不能通过Free释放重叠资源的句柄。而是要等重叠操作成后才能释放。而之后就没有在收到I/O完成通知。那么分析以下没有I/O完成通知的可能情况有以下:

  1. 在调用重叠操作的时候,当时返回的结果就不是SUCCESSERROR_IO_PENDING,因此实际I/O操作并没有加入到设备驱动队列中,自然不会有I/O请求完成的通知。
  2. 在我们释放I/O资源的时候,通过调用了CancelIoEx function取消文件句柄的I/O完成端口。调用了取消操作会有以下三种情况
    • I/O操作仍处理完成。当取消时,可能之前提交的I/O操作已经完成。
    • I/O操作已取消。此时通过GetLastError将会返回ERROR_OPERATION_ABORTED
    • 其他错误。

    需要注意的是,若异步I/O操作已经待处理,此时取消操作将会进入到I/O完成队列。因此若取消I/O操作后重叠资源可以被安全释放。

处理I/O完成操作事件的代码如下

private void HandleCompletionStatus(out CompletionStatus completionStatus, IntPtr overlappedAddress, IntPtr completionKey, int bytesTransferred)
{
    ...
    var overlapped = Overlapped.CompleteOperation(overlappedAddress);
    ...
}

在处理完成事件时,会判断当前重叠资源是否已经释放,若已经释放则将相关句柄释放掉,此时就可以被GC回收。

public static Overlapped CompleteOperation(IntPtr overlappedAddress)
{
    IntPtr managedOverlapped = Marshal.ReadIntPtr(overlappedAddress, MangerOverlappedOffset);

    GCHandle handle = GCHandle.FromIntPtr(managedOverlapped);

    Overlapped overlapped = (Overlapped) handle.Target;
    overlapped.Complete();
    if (overlapped.Disposed)
    {
        overlapped.Free();
        overlapped.Success = false;
    }
    else
    {
        overlapped.Success = Marshal.ReadIntPtr(overlapped.m_address).Equals(IntPtr.Zero);
    }

    return overlapped;          
}

确认问题

以接收数据为例,可以对问题的原因进行确认。
当我们调用重叠操作的时候。若重叠操作返回的结果是SUCCESSERROR_IO_PENDING以外的值,则重叠操作并没有被真正的提交。就如我们前面所将,重叠操作提交到设备驱动队列时会返回ERROR_IO_PENDING,而以同步方式执行完成时则直接返回SUCCESS

修复问题

在发生和接收时判断以下返回结果的若不是SUCCESSERROR_IO_PENDING,则通过m_outOverlapped.Complete();设置InProgress对象值为true。这样在释放资源的时候就直接将重叠资源释放掉。

public override void Send(byte[] buffer, int offset, int count, SocketFlags flags)
{
    ...
    m_outOverlapped.StartOperation(OperationType.Send);
    int bytesTransferred;
    SocketError socketError = UnsafeMethods.WSASend(Handle, ref m_sendWSABuffer, 1,
        out bytesTransferred, flags, m_outOverlapped.Address, IntPtr.Zero);

    if (socketError != SocketError.Success)
    {
        socketError = (SocketError)Marshal.GetLastWin32Error();

        if (socketError != SocketError.IOPending)
        {
            m_outOverlapped.Complete();
            throw new SocketException((int)socketError);
        }
    }
}

public override void Receive(byte[] buffer, int offset, int count, SocketFlags flags)
{
    ...
    m_inOverlapped.StartOperation(OperationType.Receive);

    int bytesTransferred;
    SocketError socketError = UnsafeMethods.WSARecv(Handle, ref m_receiveWSABuffer, 1,
        out bytesTransferred, ref flags, m_inOverlapped.Address, IntPtr.Zero);

    if (socketError != SocketError.Success)
    {
        socketError = (SocketError)Marshal.GetLastWin32Error();

        if (socketError != SocketError.IOPending)
        {
            m_outOverlapped.Complete();
            throw new SocketException((int)socketError);
        }
    }
}

重现及验证

由于这并不是必现的,因此写一个脚本发生大量的连接后客户马上重置的包进行重现及验证是否解决。
RSTTEST.ps1内容如下,在创建了socket之后不要正常关闭,采用exit退出的方式,让GC直接回收对象。

$endpoint = "127.0.0.1" 
$port =12345
$IP = [System.Net.Dns]::GetHostAddresses($EndPoint) 
$Address = [System.Net.IPAddress]::Parse($IP) 
$Socket = New-Object System.Net.Sockets.TCPClient($Address,$Port) 
exit

MUTIRSTTEST.ps1,通过调用多次RSTTEST.ps1达到不断的发生异常连接包。

param([int]$count,[string]$path)

$command = (Join-Path $path RSTTEST.ps1)
for($i = 1;$i -le $count;$i++ ){
    powershell . $command
    Write-Host $i
}

总结

本文记录了一次真实生产环境的内存泄漏事件进行分析过程。最终通过内存分析、抓包分析、源码分析等方式确定了最终问题产生的原因。在本次分析中对于非托管资源释放、重叠I/O和完成端口进行了深入的学习。

参考文档

  1. 使用WinDbg
  2. 手把手教你玩转SOCKET模型:完成端口(Completion Port)详解
  3. Reactor与Proactor的概念
  4. 如何深刻理解reactor和proactor?
  5. Handling IRPs
  6. CancelIoEx function
  7. I/O Completion Ports
  8. 《Windows via C/C++ 第五版》
  9. When to Complete an IRP
  10. WSASend function

20191127212134.png
微信扫一扫二维码关注订阅号杰哥技术分享
本文地址:https://www.cnblogs.com/Jack-Blog/p/11295815.html
作者博客:杰哥很忙
欢迎转载,请在明显位置给出出处及链接

posted @ 2019-08-03 19:25  杰哥很忙  阅读(2280)  评论(3编辑  收藏  举报