幫忙看了一個 Excel 批次匯出作業記憶體不足錯誤,是我之前沒見過的樣態,增廣見聞之餘順手記錄一下。

問題情境是有個能批次匯出 Excel 報表檔的 Windows Form 程式,負責查詢資料庫後以來源代碼為單位,一個來源匯成一個 Excel 檔案,程式以 VB.NET 撰寫 (哈,好久沒看到你了,近來可好?),呼叫第三方廠商元件產生 Excel 檔。近期因業務調整,來源代碼數量倍增,批次匯出作業開始發生記憶體不足錯誤。

報案人已完成初步分析跟測試,觀察到以下現象:

  1. 迴圈大約產出到第 50 個 Excel 檔時出現 Out Of Memory 錯誤,此時工作管理員顯示程式的記憶體用量約 800MB,離經驗中 32bit 程式的 1.6GB 上限還有段距離
  2. 檢查程式碼,Excel 輸出函式用的都是區域變數,理論上函式結束即會釋出,主要 Excel 元件也有確實呼叫 Close() 或 Dispose(),未發現可能造成 Memory Leak 的失誤
  3. 以 For 迴圈呼叫元件產生 Excel 過程,以工作管理員觀察記憶體變化,一開始記憶體還會升升降降維持在 50MB 上下,一段時間後工作管理員回報程序「沒有回應」,一旦出現,記憶體便只升不降一路飆上 800MB,接著 Excel 元件發生 Out Of Memory 錯誤
  4. 試著用 Try ... Catch 包住 Excel 產生程式,出錯時 MessageBox 顯示錯誤後允許執行下一筆。這時有個好現的狀況,按下 MessageBox 確認鈕關閉訊息視窗,記憶體用量會降回 50MB,迴圈可繼續跑匯出 Excel 報表,但之後還再出現「沒有回應」重演記憶體只增不減升到 800MB,觸發 Out Of Memory 錯誤
  5. 嘗試加入 GC.Collect() 強制回收記億體,但無任何改善
    註:官方文件 說得沒錯 - 「在幾乎所有情況下,您都不需要呼叫 GC.Collect(),因為垃圾收集行程會持續執行。這個方法主要用於獨特的情況和測試。」,Runtime 對記憶體狀況的掌握比我們精準,我至今還沒遇過需要用 GC.Collect() 解決問題的案例

歸納蒐集的線索,我推測第三方 Excel 元件可能存在 Memory Leak 問題,但只在特定狀況發生,而程序出現「沒有回應」似乎是觸發點。Try ... Catch 時關掉 MessageBox 會釋放記這點很有趣,我想不出二者的關聯性。

做了一些嘗試,在 For 迴圈中加入 Thread.Sleep(2000) 只能延緩而無法阻止 Out Of Memory 發生。

「沒有回應」應是 UI Thread 長時間忙於執行迴圈造成,原本想改成另開 Thread 執行,但 VB.NET 我寫起來超級卡,要先用 C# 想再逐行翻譯,再加上如此架構異動蠻多的,決定這招先保留,當成其他方法無效時的殺手鐧。

另一個思考方向是 - 若問題出在第三方 Excel 元件,升級或改換其他元件產出 Excel 也是條路,但這異動層面又更大了,整個程式要重測,只能當成最後的大絕。

把方向再拉回避免「沒有回應」上,既然另開 Thread 工程太大,老司機想起 VB6/VBA 裡有個 DoEvents(),以前會夾在迴圈中,暫時將控制權傳遞給作業系統處理完佇列中的事件(回應滑鼠、鍵盤動作,重繪 UI 視窗... 等等),如此可避免程式陷入「沒有回應」狀態及畫面凍結。但這是 VB6 時代的事,.NET 還有嗎? 幸好,還有 - Application.DoEvents()

最後試出的解法是在填入 Excel 儲存格的 For 迴圈中加入 Application.DoEvents(),防止「沒有回應」發生,記憶體維持在 50MB 左右不再無止境飆升,記憶體不足錯誤也自此消失。

由以上結果,我推測第三方 Excel 元件有 Bug (爬文查到有人反映類似問題),釋放記憶體動作受作業系統訊息佇列影響失效,當程式陷入「沒有回應」狀態時便會出現 Memory Leak。但為什麼只到 800MB 就出現 Out of Memory 錯誤,而不是經驗中的 1.6GB,這點對我仍是個謎,而在官方文件確實也找得到 800MB 的說法:Troubleshoot Out of Memory issues (System.OutOfMemoryException) in ASP.NET

Your process is using much memory (typically over 800 MB in a 32-bit environment.)

這部分又是個有趣的問題,擇日再另案調查。

A weird case of OutOfMemory exception while Excel exporting loop. Adding DoEvents() solved the problem.


Comments

# by Huang

可能是這段 Common Causes of High Memory When your worker process approaches 800MB in private bytes, your chances of seeing an OOM condition begin to increase simply because the chances of finding a large, contiguous piece of memory within the 2GB address space begin to decrease significantly. Therefore, you want to avoid these high memory conditions.

# by Jeffrey

to Huang, 謝謝分享。把大家提供的 800MB OOM 討論整理在這裡: * Who is this OutOfMemory guy and why does he make my process crash when I have plenty of memory left? https://docs.microsoft.com/en-us/archive/blogs/tess/who-is-this-outofmemory-guy-and-why-does-he-make-my-process-crash-when-i-have-plenty-of-memory-left * Troubleshooting System.OutOfMemoryExceptions in ASP.NET https://blogs.iis.net/webtopics/troubleshooting-system-outofmemoryexceptions-in-asp-net * How to troubleshoot a memory leak or an out-of-memory exception in the BizTalk Server process https://docs.microsoft.com/en-us/troubleshoot/biztalk/troubleshoot-memory-leak

# by 貓老大

我以為 Win Form 程式跑長時間的迴圈,裡面要加 Application DoEvents 是常識呢

# by Jeffrey

to 貓老大,.NET WinForm 跑長期迴圈的主流做法是用 BackgroundWorker 或 Task (Application.DoEvents() 官方文件的最後一段警告有提到),DoEvents 是老人的常識,對年輕人未必是,呵。

# by 貓老大

老常識適用於老程式,若早早想到,就不用寫這一大篇文章了,不是嗎?

# by Jeffrey

to 貓老大,可以換個角度想,您的常識對別人可能是新知識,整理分享出來就有機會幫助不知道、沒想到的朋友,是一椿美事。 歡迎一起加入分享的行列。

# by 璉璉

這篇 VBNET DoEvents 的文,當初跟你的網誌兩篇有互動: https://tlcheng.wordpress.com/2007/03/07/vb6vbnet-cpu-%E8%B3%87%E6%BA%90%E9%87%8B%E5%87%BA/

Post a comment