異常連線導致的記憶體洩漏排查

傑哥很忙發表於2019-08-03

異常連線導致的記憶體洩漏排查


背景

在生產環境中,部署在客戶的程式在執行了將近兩個月後發生了閃退。而且兩個伺服器的程式先後都出現了閃退現象。通過排查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

我使用的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寫完成的通知。所以實際資料寫入緩衝區時我們的工作執行緒仍然可以併發處理其他事情。

根據WSK_SEND文件描述,WSK子系統在通過套接字傳送資料時不執行任何資料緩衝。因此,在實際傳送所有資料之前,WSK子系統不會完成對WskSend函式的呼叫。根據個人對該描述的理解,非同步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

本文地址:https://www.cnblogs.com/Jack-Blog/p/11295815.html
作者部落格:傑哥很忙
歡迎轉載,請在明顯位置給出出處及連結

相關文章