這個做法都是以 closure(閉包)為討論主軸,並討論背後轉換的原理以及為什麼在使用 closure 的時候可能會導致記憶體流失的問題。
首先 closure 這個名詞很常在 javascript 中討論以及只使用到, closure 是一種能讓內部方法取得外部變數的一種寫法,
例如以下範例中的 counter
變數明明作用範圍是在 Main 底下,但是我們透過 lambda 把 delegate 當成參數傳給 Generate
方法,
之後在最內部的執行範圍竟然可以操作不同作用範圍的 counter
變數。
void Main()
{
var counter = 0;
var numbers = Extensions.Generate(30, () => counter++);
Console.WriteLine(numbers);
Console.WriteLine(counter);
}
public static class Extensions
{
public static IEnumerable<int> Generate(int max, Action func)
{
for (int i = 0; i <= max; i++)
{
func();
yield return i;
}
}
}
為什麼可以做到這樣的效果,可以透過反編譯軟體了解它背後做的處理,其實也很簡單就是透過建立一個隱藏的類別並把 counter
變數直接變成它的成員變數,
也就是說 counter
原本只是個區域變數居然直接被提升成了成員。
private sealed class <>c__DisplayClass0_0
{
public int counter;
internal void <Main>b__0()
{
counter++;
}
}
public void Main()
{
<>c__DisplayClass0_0 <>c__DisplayClass0_ = new <>c__DisplayClass0_0();
<>c__DisplayClass0_.counter = 0;
IEnumerable<int> value = Extensions.Generate(30, new Action(<>c__DisplayClass0_.<Main>b__0));
Console.WriteLine(value);
Console.WriteLine(<>c__DisplayClass0_.counter);
}
區域變數提升為成員這個概念在做法 17 與做法 21 有提到,也很清楚這樣做會導致的記憶體流失的問題,在這兩個做法建議是如果類別內部有 IDisposable
介面
的成員,那麼這個類別也要實做 IDisposable
介面,例如下面我們約束 IEngine 需要實做 IDisposable
介面同時 driver
又是一個成員,
這種時候就需要在 EngineDriver2
實做 IDisposable
介面。
public sealed class EngineDriver2<T> : IDisposable
where T : IEngine, new()
{
private Lazy<T> driver = new Lazy<T>(() => new T());
public void GetThingsDone() => driver.Value.DoWork();
public void Dispose()
{
if (driver.IsValueCreated)
{
var resource = driver.Value as IDisposable;
resource?.Dispose();
}
}
}
所以 closure 是透過隱藏類別的方式來讓內部變數操作外部變數的能力,這也代表原本的區域變數因為被提升成成員所以生命週期也隨之延長, 在這個情況下需要注意並不是離開方法 scope 時就會呼叫 GC 進行清除,因為這個成員已經跟委派綁定到一起了,而委派又有延遲值型的特性, 所以可能隨時會呼叫也可能等一段時間才會呼叫,導致我們很難判斷什麼時候不需要這個委派,
不過不用太擔心這個問題,因為大多數區域變數都是 managed resource 所以遲早還是會釋放掉的,但是如果你不小心讓 unmanaged resource
自動提升為成員的時候就要注意了,例如下面這個範例中的 ReadNumbersFromStream
方法使用了 query expression
去查詢檔案內的資料,
由於延遲執行的特性,要真正去使用 rowsOfNumbers
內部的值時才會真的去運行 TextReader
。
void Main()
{
var t = new StreamReader(File.OpenRead("TestFile.txt"));
var rowsOfNumbers = ReadNumbersFromStream(t);
}
public static IEnumerable<IEnumerable<int>> ReadNumbersFromStream(TextReader t)
{
var allLines = from line in t.ReadLines()
select line.Split(',');
var matrixOfValues = from line in allLines
select from item in line
select item.DefaultParse(0);
return matrixOfValues;
}
public static class Extensions
{
public static IEnumerable<string> ReadLines(this TextReader reader)
{
var txt = reader.ReadLine();
while (txt != null)
{
yield return txt;
txt = reader.ReadLine();
}
}
public static int DefaultParse(this string input, int defaultValue)
{
int answer;
return (int.TryParse(input, out answer)) ? answer : defaultValue;
}
}
當你運行的時候會發現程式碼已經執行完畢但是 TextReader
並沒有被主動關閉,接下來你可能會用 using 把 TextReader
包起來以達到
讀取完釋放的效果,但實際上執行後會拋出 ObjectDisposedException
錯誤因為 TextReader
已經被釋放掉了。
void Main()
{
IEnumerable<IEnumerable<int>> rowsOfNumbers;
using (TextReader t = new StreamReader(File.OpenRead("TestFile.txt")))
{
rowsOfNumbers = ReadNumbersFromStream(t);
}
Console.WriteLine(rowsOfNumbers);
}
由此可知如果在 closure 內部有 IDisposable
介面的成員,一種是發生記憶體洩漏的問題另一種則是拋出物件已釋放的問題,
這個問題也很簡單處理,只要在物件釋放掉之前進行讀取即可。
void Main()
{
IEnumerable<IEnumerable<int>> rowsOfNumbers;
using (TextReader t = new StreamReader(File.OpenRead("TestFile.txt")))
{
rowsOfNumbers = ReadNumbersFromStream(t);
Console.WriteLine(rowsOfNumbers);
}
}
更好的解決方式是由開啟文件的邏輯同時執行讀取的邏輯。
void Main()
{
var rowsOfNumbers = ReadNumbersFromStream("TestFile.txt");
Console.WriteLine(rowsOfNumbers);
}
public static IEnumerable<IEnumerable<int>> ReadNumbersFromStream(string path)
{
var allLines = from line in Extensions.ParseFile(path)
select line.Split(',');
var matrixOfValues = from line in allLines
select from item in line
select item.DefaultParse(0);
return matrixOfValues;
}
public static class Extensions
{
public static IEnumerable<string> ParseFile(string path)
{
using (var r = new StreamReader(File.OpenRead(path)))
{
var line = r.ReadLine();
while (line != null)
{
yield return line;
line = r.ReadLine();
}
}
}
public static int DefaultParse(this string input, int defaultValue)
{
int answer;
return (int.TryParse(input, out answer)) ? answer : defaultValue;
}
}
也可以搭配委派將數據處理的部分分離出去,例如下面這段程式就是把讀取後的資料透過委派把最大值找出來。
void Main()
{
var maximum = ProcessFile("testFile.txt", (arrayOfNums) =>
(from line in arrayOfNums select line.Max()).Max());
}
public delegate TResult ProcessElementsFromFile<TResult>(
IEnumerable<IEnumerable<int>> values);
public static TResult ProcessFile<TResult>(string filePath,
ProcessElementsFromFile<TResult> action)
{
using (TextReader t = new StreamReader(File.Open(filePath, FileMode.Open)))
{
var allLines = from line in t.ReadLines()
select line.Split(',');
var matrixOfValues = from line in allLines
select from item in line
select item.
DefaultParse(0);
return action(matrixOfValues);
}
}
public static class Extensions
{
public static IEnumerable<string> ReadLines(this TextReader reader)
{
var txt = reader.ReadLine();
while (txt != null)
{
yield return txt;
txt = reader.ReadLine();
}
}
public static int DefaultParse(this string input, int defaultValue)
{
int answer;
return (int.TryParse(input, out answer)) ? answer : defaultValue;
}
}
接下來看另一個案例,下面實例化了 ResourceHog
類別,並且因為延遲執行的特性所以並不會在 ExpensiveSequence
方法結束後就把 hog 釋放掉,
如果 ResourceHog
類別並沒有實做 IDisposable
介面,那麼就是根據一般物件的釋放要等到所有相關的物件都 unreachable 才會被 GC 釋放掉。
IEnumerable<int> ExpensiveSequence()
{
int counter = 0;
var numbers = Extensions.Generate(30, () => counter++);
Console.WriteLine("counter: {0}", counter);
var hog = new ResourceHog();
numbers = numbers.Union(hog.SequenceGeneratedFromResourceHog(
(val) => val < counter));
return numbers;
}
這種時候就可以搭配 ToList
方法盡早把數據生成出來,這樣就能在離開 ExpensiveSequence
方法後馬上釋放掉資源。
IEnumerable<int> ExpensiveSequence()
{
var counter = 0;
var numbers = Extensions.Generate(30, () => counter++);
Console.WriteLine("counter: {0}", counter);
var hog = new ResourceHog();
var mergeSequence = hog.SequenceGeneratedFromResourceHog(
(val) => val < counter).ToList();
numbers = numbers.Union(mergeSequence);
return numbers;
}
最後是多線程的案例,例如下面這段程式碼把變數 i 加入到 closure 內部,並且注意是用 ref 傳遞參數,這種時候就是看哪一條線程運行的更快一點 ,也就是說 i 的值可能隨時都被其中一條線程修改掉。
private static void SomeMethod(ref int i)
{
//...
}
private static void DoSomethingInBackground()
{
var i = 0;
var thread = new Thread(delegate () { SomeMethod(ref i); });
thread.Start();
}
Summary
這個做法全部都在討論使用 closure 所可能帶來的相關問題,關鍵的原因就是 closure 會把區域變數加入到自己的隱藏類別裡面同時又修改為成員, 才有後面的記憶體洩漏與訪問已釋放物件等問題,使用 closure 時首先該考慮是否真的需要用到這些區域變數,如果不需要那直接調整程式碼這樣既不會 有隱藏的類別成員,原本的區域變數也可以照流程釋放。