異常連接導致的記憶體泄漏排查

  • 2019 年 10 月 3 日
  • 筆記

異常連接導致的記憶體泄漏排查


背景

在生產環境中,部署在客戶的程式在運行了將近兩個月後發生了閃退。而且兩個伺服器的程式先後都出現了閃退現象。通過排查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.2AsyncIO.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.2AsyncIO.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寫完成的通知。所以實際數據寫入緩衝區時我們的工作執行緒仍然可以並發處理其他事情。

根據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
作者部落格:傑哥很忙
歡迎轉載,請在明顯位置給出出處及鏈接