public void StartOperation(OperationType operationType)
InProgress = true;
Success = false;
OperationType = operationType;
再对StartOperation
查找引用,一共有4个地方调用。
可以发现该字段适用于表示重叠I/O是否正在处理。在如果重叠I/O正在处理,则不释放相关的资源,具体原因后面讲到重叠I/O时会进行说明。
使用wireshark抓包分析
与此同时,我们对程序日志也进行了分析。发现我们的程序接收到了大量的Http请求。
由于我们和客户接口是通过TCP协议传输,而非HTTP协议,因此理论上不应该会有HTTP请求发到我们程序端口上。又因为我们程序有接收超时机制,即使有我们无法解析的无效请求,超过了超时时间我们也会将对应的资源释放。而且从dump文件来看也没有我们未释放的资源对象。
为了搞清楚到底是什么请求发到我们程序上,因此要求客户在服务器抓包。我们对抓包文件进行分析。发现抓到了大量的异常连接,每5秒会有2个。
然后我通过计算未释放对象的数量基本与接收到这个包数量吻合。因此初步断定内存泄漏是由于该包引起的。这个包应该是一个服务监控程序发的,每五秒发一次,有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));
创建重叠资源。在创建重叠资源的时候,会通过GCHandle.Alloc
分配句柄,防止托管对象被GC回收导致非托管资源被回收。只有调用Free
才能被回收。
初始化输入输出对象WSABuffer
。当发送或接收数据时会直接使用该对象地址,而不会发生内存复制。
初始化一个套接字对象
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);
CompletionPort.PostCompletionStatus(m_inOverlapped.Address);
首先初始化用于接收客户套接字的地址。m_boundAddress
是当前监听的套接字对象。
m_boundAddress
,m_boundAddress.Size
则是根据IPV4还是IPV6决定的,具体细节不做分析。通过Marshal.AllocHGlobal
分配非托管内存,返回一个地址。
执行重叠操作异步接收客户端连接。通过调用m_acceptEx
异步接收客户连接。前面提到异步I/O接收,先创建套接字用于接收,这样真正到接收客户端连接时就无需再创建套接字了。
判断返回执行结果。重叠操作执行完毕需要调用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);
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();
前面提到过,在重叠操作正在进行的时候,不能将数据缓存和重叠结构释放掉,否则系统处理可能出现异常。假设发生了垃圾回收将资源释放了,但是此时发生了I/O读写,可能该地址指向是其他的对象,因此可能会造成内存溢出等问题。同时出现了该问题还非常难以排查原因。
取消完成端口通知。
关闭套接句柄。
前面详细的介绍和分析了异步(重叠)I/O和完成端口的原因,那么接下来对内存泄露的具体原因进行分析。我们通过dump文件已经知道了套接字对象实际已经被释放了。套接字对象和重叠资源对象形成了循环引用,但是GC是非常聪明的,能够识别这种情况,仍然是可以将其回收掉。但是为什么套接字对象和重叠资源还是没有被回收掉呢?
这是因为由于我们的重叠操作正在处理,因此InProgress
设置成了true,但是由于释放重叠资源的时候重叠操作正在处理,因此我们不能通过Free
释放重叠资源的句柄。而是要等重叠操作成后才能释放。而之后就没有在收到I/O完成通知。那么分析以下没有I/O完成通知的可能情况有以下:
在调用重叠操作的时候,当时返回的结果就不是SUCCESS和ERROR_IO_PENDING,因此实际I/O操作并没有加入到设备驱动队列中,自然不会有I/O请求完成的通知。
在我们释放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;
overlapped.Success = Marshal.ReadIntPtr(overlapped.m_address).Equals(IntPtr.Zero);
return overlapped;
以接收数据为例,可以对问题的原因进行确认。
当我们调用重叠操作的时候。若重叠操作返回的结果是SUCCESS和ERROR_IO_PENDING以外的值,则重叠操作并没有被真正的提交。就如我们前面所将,重叠操作提交到设备驱动队列时会返回ERROR_IO_PENDING,而以同步方式执行完成时则直接返回SUCCESS。
在发生和接收时判断以下返回结果的若不是SUCCESS和ERROR_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)
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和完成端口进行了深入的学习。
使用WinDbg
手把手教你玩转SOCKET模型:完成端口(Completion Port)详解
Reactor与Proactor的概念
如何深刻理解reactor和proactor?
Handling IRPs
CancelIoEx function
I/O Completion Ports
《Windows via C/C++ 第五版》
When to Complete an IRP
WSASend function