現象
用C#異步方式實現的網絡底層協議,開發的服務器。上線運行一段時間後,發現一開始內存非常穩定,但是過了一定時間後,內存使用量會開始不停的上漲。直到內存耗盡。
排查
遇到這一問題可以明確的是內存發生了泄漏。由於.Net中,託管對象的內存是由垃圾回收機制負責回收的。所以存在內存增長的情況,往往不是因爲沒有釋放。而是有幾種原因
- 分配的內存,比垃圾回收的還要快
- 對象存在引用,沒有辦法被垃圾回收機制回收。
對於第一種情況,我仔細排查了代碼中使用new分配內存的位置,往往這種情況,是因爲在一個邏輯循環中不斷分配內存,並且該循環佔用了大量的CPU,導致其他線程(GC)沒有空隙執行釋放造成的。常見的是在一些線程函數中的,無限循環(業務邏輯的幀循環)中執行了new。
第二種情況則是我們以爲我們分配的對象在生命週期結束後,不會再被其他對象引用,而應該被GC回收,但實際情況是因爲某些原因(比如,函數返回,輸出參數,將對應的引用返回到其生命週期外部),對象實際被其他對象使用了,導致GC不認爲該對象是死對象,從而無法回收。這種情況比較多見,往往是編寫代碼的疏忽。
但針對以上兩種情況進行排查後,我發現我們的代碼都沒有這種問題,因爲對這個問題的排查陷入一個無解的情況。
由於在進程中,執行new的地方,除了業務邏輯,只剩下網絡底層協議。因爲把目光轉向了網絡底層實現。在我們的網絡底層實現中,使用new的只有對網絡消息的封包,和網絡異步操作時的SocketAsyncEventArgs的使用。根據內存增長的速度,我們得出大概每秒會上漲接近200KB的內存,而我們測試時的峯值人數才100人,也就是平均每人每秒,在基本和網絡只保持心跳的情況下,都要發出去2000字節,而我們的心跳包大概也就2字節。所以基本排除了是消息封包的內存出現問題。
那麼接下來就是SocketAsyncEventArgs的分配了,仔細檢查代碼,這個東西我們是作爲局部變量使用的,每次都是重新分配,理應不會產生上述兩種情況。所以看似又進入了死衚衕。抱着試試看的態度,去網上查了下,看到.net 中異步SOCKET發送數據時碰到的內存問題這篇文章(這裏是我轉載的),發現裏面的問題和我們的非常類似,然後抱着試試的想法,對SocketAsyncEventArgs對象執行了SetBuffer(null, 0, 0),調試,問題解決。
原因
問題解決後,發現原來產生問題的原因是這樣的,按.net 中異步SOCKET發送數據時碰到的內存問題中的實驗來看,是因爲,沒有執行過SetBuffer(null, 0, 0)的話,先前在我們進行異步收發的時候SetBuffer()有設置過一個byte[]對象,而導致SocketAsyncEventArgs一直引用這個byte[],使byte[]無法釋放,產生內存增長。文章中最後總結爲兩個死對象互相引用,從而導致GC無法確定該釋放哪個。從而使兩個對象都無法被回收。
雖然我按他的方法試驗後,確實解決了問題。但我其實對這個原因還是存在一定的疑問。首先按照GC的回收機制,只有當從root對象中無法到達的對象纔會被認定爲死對象。那麼在這個問題中,由於沒有執行SetBuffer(null, 0, 0),那麼byte[]始終被SocketAsyncEventArgs引用,那麼如果SocketAsyncEventArgs是可達的話,byte[]纔會可達,而SocketAsyncEventArgs作爲局部變量,理應在函數執行完後,成爲一個死對象,連帶其中的byte[]都成爲不可達的,而byte[]並不可能引用SocketAsyncEventArgs,也就是不存在兩個死對象互相引用的問題。這也是我一直在開始並沒有懷疑這個new分配的原因。
其實真正的原因並不是兩個死對象互相引用,而是在於非託管資源與託管資源的釋放不同。SocketAsyncEventArgs具有Dispose接口,加之其和網絡相關,因此其內部很可能存在非託管的資源,因此不調用Dispose的話是無法釋放的。而byte[]如果被SocketAsyncEventArgs引用,而SocketAsyncEventArgs沒有釋放,那麼byte[]自然也無法釋放了。
在轉載文章的代碼中,將SetBuffer(null,0,0)修改成Dispose調用,同樣解決問題
class Program
{
public class simpleRef
{
public byte[] byteref;
public void SetBuffer(byte[] buf)
{
byteref = buf;
}
}
static void Main(string[] args)
{
Console.WriteLine("Press any key to start.");
Console.ReadKey();
for (int i = 0; i < 100; ++i)
{
for(int j = 0; j < 10000; ++j)
{
var e = new SocketAsyncEventArgs();
//e.SetBuffer(new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 0 }, 0, 0);
//e.SetBuffer(null, 0, 0);
//e.Dispose();
}
System.Threading.Thread.Sleep(1);
}
//}
Console.WriteLine("Over!!!!!!!!!!!");
Console.ReadLine();
}
}
依次打開代碼中註釋的三行代碼測試,當不做任何處理時,內存峯值穩定在715752kb
使用轉載文章的方法,SetBuffer(null, 0, 0),內存峯值穩定在13120kb
使用Dispose,內存峯值穩定在7980kb
可以看到,在循環中,我們分配的byte,即便完全沒有釋放,也只有10000kb左右,由此可見,真正佔內存的資源是SocketAsyncEventArgs。SetBuffer這個函數會產生大量的內存消耗,其中應該包括大量非託管的資源。雖然SetBuffer(null, 0, 0)執行後,確實能釋放掉一部分資源,但顯然沒有釋放乾淨,只有使用Dispose,纔是正確的釋放方式
經過這個問題,老實說也提醒我平時要注意對於託管資源和非託管資源的釋放,平時太依賴GC,幾乎不太注意這個問題,看來也不是好習慣,敲響了警鐘