目錄
- 一、概念
- 二、原理
- 硬體結構
- 運行時
- 三、基礎
- 創建與啟動
- 傳遞引數
- 前臺/后臺執行緒
- 例外處理
- 中斷與中止
- 中斷(Interrupt)
- 中止(Abort)
- 協作取消模式
- 四、異步編程模式
- 異步編程模型(APM)
- 基于事件的異步模式(EAP)
- 基于任務的異步模式 (TAP)
- 五、拓展知識
- 執行緒優先級
- 什么是行程退出?
- windows中通過任務管理器,linux中通過kill去殺掉一個行程,其資源是否會釋放?
一、概念
《Threading in C# 》(Joseph Albahari):https://www.albahari.com/threading/
《Threading in C# 》中文翻譯(GKarch ):https://blog.gkarch.com/topic/threading.html
《圖解系統》(小林coding):https://xiaolincoding.com/os/
并行(parallel):同一時間,多個執行緒/行程同時執行,多執行緒的目的就是為了并行,充分利用cpu多個核心,提高程式性能
執行緒(threading):執行緒是作業系統能夠進行 運算調度的最小單位,是行程的實際運作單位,
一條執行緒指的是行程中一個單一順序的控制流,一個行程中可以并行多個執行緒,每條執行緒并行執行不同的任務,
行程(process):行程是作業系統進行資源分配的基本單位,多個行程并行的在計算機上執行,多個執行緒并行的在行程中執行,
行程之間是隔離的,執行緒之間共享堆,私有堆疊空間,
CLR 為每個執行緒分配各自獨立的 堆疊(stack) 空間,因此區域變數是執行緒獨立的,
static void Main()
{
new Thread(Go).Start(); // 在新執行緒執行Go()
Go(); // 在主執行緒執行Go()
}
static void Go()
{
// 定義和使用區域變數 - 'cycles'
for (int cycles = 0; cycles < 5; cycles++) Console.Write ('?');
}
變數cycles的副本是分別在執行緒各自的堆疊中創建,因此會輸出 10 個問號
??????????
執行緒可以通過對同一物件的參考來共享資料,例如:
static bool done = false;
static void Main()
{
new Thread (tt.Go).Start(); // A
Go(); // B
}
static void Go()
{
if (!done) {
Console.WriteLine ("Done");
done = true;
}
}
這個例子引出了一個關鍵概念 執行緒安全(thread safety) ,由于并發,” Done “ 有可能會被列印兩次
通過簡單的加鎖操作:在讀寫公共欄位時,獲得一個 排它鎖(互斥鎖,exclusive lock ) ,c#中使用lock即可生成 臨界區(critical section)
static readonly object locker = new object();
...
static void Go()
{
lock (locker) // B
{
if (!done) {
Console.WriteLine ("Done");
done = true;
}
}
}
臨界區(critical section):在同一時刻只有一個執行緒能進入,不允許并發,當有執行緒進入臨界區段時,其他試圖進入的執行緒或是行程必須 等待或阻塞(blocking)
執行緒阻塞(blocking):指一個執行緒在執行程序中暫停,以等待某個條件的觸發來解除暫停,阻塞狀態的執行緒不會消耗CPU資源
掛起(Suspend):和阻塞非常相似,在虛擬記憶體管理的作業系統中,通常會把阻塞狀態的行程的物理記憶體空間換出到硬碟,等需要再次運行的時候,再從硬碟換入到物理記憶體,描述行程沒有占用實際的物理記憶體空間的情況,這個狀態就是掛起狀態,
可以通過呼叫Join方法等待執行緒執行結束,例如:
static void Main()
{
Thread t = new Thread(Go);
t.Start();
t.Join(); // 等待執行緒 t 執行完畢
Console.WriteLine ("Thread t has ended!");
}
static void Go()
{
for (int i = 0; i < 1000; i++) Console.Write ("y");
}
也可以使用Sleep使當前執行緒阻塞一段時間:
Thread.Sleep (500); // 阻塞 500 毫秒
Thread.Sleep(0)會立即釋放當前的時間片(time slice),將 CPU 資源出讓給其它執行緒,Framework 4.0的Thread.Yield()方法與其大致相同,不同的是Yield()只會出讓給運行在相同處理器核心上的其它執行緒,
Sleep(0)和Yield在調整代碼性能時偶爾有用,它也是一個很好的診斷工具,可以用于找出執行緒安全(thread safety)的問題,如果在你代碼的任意位置插入Thread.Yield()會影響到程式,
基本可以確定存在 bug,
二、原理
硬體結構
https://xiaolincoding.com/os/1_hardware/how_cpu_run.html#圖靈機的作業方式
運行時
??執行緒在內部由一個 執行緒調度器(thread scheduler) 管理,一般 CLR 會把這個任務交給作業系統完成,執行緒調度器確保所有活動的執行緒能夠分配到適當的執行時間,并且保證那些處于等待或阻塞狀態(例如,等待排它鎖或者用戶輸入)的執行緒不消耗CPU時間,
??在單核計算機上,執行緒調度器會進行 時間切片(time-slicing) ,快速的在活動執行緒中切換執行,在 Windows 作業系統上,一個時間片通常在十幾毫秒(譯者注:默認 15.625ms),遠大于 CPU 在執行緒間進行背景關系切換的開銷(通常在幾微秒區間),
??在多核計算機上,多執行緒的實作是混合了時間切片和 真實的并發(genuine concurrency) ,不同的執行緒同時運行在不同的 CPU 核心上,仍然會使用到時間切片,因為作業系統除了要調度其它的應用,還需要調度自身的執行緒,
??執行緒的執行由于外部因素(比如時間切片)被中斷稱為 被搶占(preempted),在大多數情況下,執行緒無法控制其在什么時間,什么代碼塊被搶占,
??多執行緒同樣也會帶來缺點,最大的問題在于它提高了程式的復雜度,使用多個執行緒本身并不復雜,復雜的是執行緒間的互動(共享資料)如何保證安全,無論執行緒間的互動是否有意為之,都會帶來較長的開發周期,以及帶來間歇的、難以重現的 bug,因此,最好保證執行緒間的互動盡可能少,并堅持簡單和已被證明的多執行緒互動設計,
??當頻繁地調度和切換執行緒時(且活動執行緒數量大于 CPU 核心數),多執行緒會增加系統資源和 CPU 的開銷,執行緒的創建和銷毀也會增加開銷,多執行緒并不總是能提升程式的運行速度,如果使用不當,反而可能降低速度,
三、基礎
創建與啟動
使用Thread類的構造方法來創建執行緒,支持以下兩種委托
public delegate void ThreadStart();
public delegate void ParameterizedThreadStart (object? obj);
關于Thread構造多載方法引數 maxStackSize,不建議使用
https://stackoverflow.com/questions/5507574/maximum-thread-stack-size-net
public void 創建一個執行緒()
{
var t = new Thread(Go); // 開一個執行緒t
t.Start(); // 啟動t執行緒,執行Go方法
Go(); // 主執行緒執行Go方法
}
void Go()
{
_testOutputHelper.WriteLine("hello world!");
}
每一個執行緒都有一個 Name 屬性,我們可以設定它以便于除錯,執行緒的名字只能設定一次,再次修改會拋出例外,
public void 執行緒命名()
{
var t = new Thread(Go); // 開一個執行緒t
t.Name = "worker";
t.Start(); // 啟動t執行緒,執行Go方法
Go(); // 主執行緒執行Go方法
}
void Go()
{
// Thread.CurrentThread屬性會回傳當前執行的執行緒
_testOutputHelper.WriteLine(Thread.CurrentThread.Name + " say: hello!");
}
傳遞引數
Thread類的Start方法多載支持向thread實體傳參
public void Start(object? parameter)
引數被lambda運算式捕獲,傳遞給Go方法
public void 創建一個執行緒()
{
var t = new Thread(msg => Go(msg)); // 開一個執行緒t
t.Start("hello world!"); // 啟動t執行緒,執行Go方法
Go("main thread say:hello world!"); // 主執行緒執行Go方法
}
void Go(object? msg)
{
_testOutputHelper.WriteLine(msg?.ToString());
}
請務必注意,不要在啟動執行緒之后誤修改被捕獲變數(captured variables)
public void 閉包問題()
{
for (int i = 0; i < 10; i++)
{
new Thread (() => Go(i)).Start();
}
}
前臺/后臺執行緒
默認情況下,顯式創建的執行緒都是前臺執行緒(foreground threads),只要有一個前臺執行緒在運行,程式就可以保持存活不結束,
當一個程式中所有前臺執行緒停止運行時,仍在運行的所有后臺執行緒會被強制終止,
這里說的 顯示創建,指的是通過new Thread()創建的執行緒
非默認情況,指的是將Thread的IsBackground屬性設定為true
static void Main (string[] args) { Thread worker = new Thread ( () => Console.ReadLine() ); if (args.Length > 0) worker.IsBackground = true; worker.Start(); }
當行程以強制終止這種方式結束時,后臺執行緒執行堆疊中所有finally塊就會被避開,如果程式依賴finally(或是using)塊來執行清理作業,例如釋放資料庫/網路連接或是洗掉臨時檔案,就可能會產生問題,
為了避免這種問題,在退出程式時可以顯式的等待這些后臺執行緒結束,有兩種方法可以實作:
- 如果是顯式創建的執行緒,在執行緒上呼叫Join阻塞,
- 如果是使用執行緒池執行緒,使用信號構造,如事件等待句柄,
在任何一種情況下,都應指定一個超時時間,從而可以放棄由于某種原因而無法正常結束的執行緒,這是后備的退出策略:我們希望程式最后可以關閉,而不是讓用戶去開任務管理器(╯-_-)╯╧══╧
執行緒的 前臺/后臺狀態 與它的 優先級/執行時間的分配無關,
例外處理
當執行緒開始運行后,其內部發生的例外不會拋到外面,更不會被外面的try-catch-finally塊捕獲到,
void 例外捕獲()
{
try
{
new Thread(Go).Start(); // 啟動t執行緒,執行Go方法
}
catch (Exception e)
{
_testOutputHelper.WriteLine(e.Message);
}
}
void Go() => throw null!; // 拋出空指標例外
解決方案是將例外處理移到Go方法中:自己的例外,自己解決
static void Go()
{
try
{
// ...
throw null; // 例外會在下面被捕獲
// ...
}
catch (Exception ex)
{
// 一般會記錄例外,或通知其它執行緒我們遇到問題了
// ...
}
}
AppDomain.CurrentDomain.UnhandledException 會對所有未處理的例外觸發,因此它可以用于集中記錄執行緒發生的例外,但是它不能阻止程式退出,
void UnhandledException()
{
AppDomain.CurrentDomain.UnhandledException += HandleUnHandledException;
new Thread(Go).Start(); // 啟動t執行緒,執行Go方法
}
void HandleUnHandledException(object sender, UnhandledExceptionEventArgs eventArgs)
{
_testOutputHelper.WriteLine("我發現例外了");
}
并非所有執行緒上的例外都需要處理,以下情況,.NET Framework 會為你處理:
- 異步委托(APM)
- BackgroundWorker(EAP)
- 任務并行庫(TPL)
中斷與中止
所有阻塞方法Wait(), Sleep() or Join(),在阻塞條件永遠無法被滿足且沒有指定超時時間的情況下,執行緒會陷入永久阻塞,
有兩個方式可以實作強行結束:中斷、中止
中斷(Interrupt)
在一個阻塞執行緒上呼叫Thread.Interrupt
會強制釋放它,并拋出ThreadInterruptedException
例外,與上文的一樣,這個例外同樣不會拋出
var t = new Thread(delegate()
{
try
{
Thread.Sleep(Timeout.Infinite); // 無期限休眠
}
catch (ThreadInterruptedException)
{
_testOutputHelper.WriteLine("收到中斷信號");
}
_testOutputHelper.WriteLine("溜溜球~");
});
t.Start();
Thread.Sleep(3000); // 睡3s后中斷執行緒t
t.Interrupt();
如果在非阻塞執行緒上呼叫Thread.Interrupt
,執行緒會繼續執行直到下次被阻塞時,拋出ThreadInterruptedException
,這避免了以下這樣的代碼:
if ((worker.ThreadState & ThreadState.WaitSleepJoin) > 0) // 執行緒不安全的
{
worker.Interrupt();
}
??隨意中斷一個執行緒是極度危險的,這可能導致呼叫堆疊上的任意方法(框架、第三方包)收到意外的中斷,而不僅僅是你自己的代碼!只要呼叫堆疊上發生阻塞(因為使用同步構造),
中斷就會發生在這,如果在設計時沒有考慮中斷(在finally塊中執行適當清理),執行緒中的物件就可能成為一個奇怪狀態(不可用或未完全釋放),
??如果是自己設計的阻塞,完全可以用 信號構造(signal structure) 或者 取消令牌(cancellation tokens) 來達到相同效果,且更加安全,如果希望結束他人代碼導致的阻塞,Abort總是更合適
中止(Abort)
通過Thread.Abort方法也可以使阻塞的執行緒被強制釋放,效果和呼叫Interrupt類似,不同的是它拋出的是ThreadAbortException的例外,另外,這個例外會在catch塊結束時被重新拋出(試圖更好的結束執行緒),
Thread t = new Thread(delegate()
{
try
{
while (true)
{
}
}
catch (ThreadAbortException)
{
_testOutputHelper.WriteLine("收到中止信號");
}
// 這里仍然會繼續拋出ThreadAbortException,以保證此執行緒真正中止
});
_testOutputHelper.WriteLine(t.ThreadState.ToString()); // Unstarted 狀態
t.Start();
Thread.Sleep(1000);
_testOutputHelper.WriteLine(t.ThreadState.ToString()); // Running 狀態
t.Abort();
_testOutputHelper.WriteLine(t.ThreadState.ToString()); // AbortRequested 狀態
t.Join();
_testOutputHelper.WriteLine(t.ThreadState.ToString()); // Stopped 狀態
除非Thread.ResetAbort在catch塊中被呼叫,在此之前,執行緒狀態(thread state) 是AbortRequested,呼叫Thread.ResetAbort來阻止例外被自動重新拋出之后,
執行緒重新進入Running狀態(從這開始,它可能被再次中止)
static void Main()
{
Thread t = new Thread (Work);
t.Start();
Thread.Sleep (1000); t.Abort();
Thread.Sleep (1000); t.Abort();
Thread.Sleep (1000); t.Abort();
}
static void Work()
{
while (true)
{
try { while (true); }
catch (ThreadAbortException) { Thread.ResetAbort(); }
Console.WriteLine ("我沒死!");
}
}
Thread.Abort在NET 5被棄用了:https://learn.microsoft.com/zh-cn/dotnet/core/compatibility/core-libraries/5.0/thread-abort-obsolete
未處理的ThreadAbortException是僅有的兩個不會導致應用程式關閉的例外之一,另一個是AppDomainUnloadException,
Abort幾乎對處于任何狀態的執行緒都有效:Running、Blocked、Suspended以及Stopped,然而,當掛起的執行緒被中止時,會拋出ThreadStateException例外,中止會直到執行緒之后恢復時才會起作用,
try { suspendedThread.Abort(); }
catch (ThreadStateException) { suspendedThread.Resume(); }
// 現在 suspendedThread 才會中止
Interrupt和Abort最大的不同是:呼叫Interrupt執行緒會繼續作業直到下次被阻塞時拋出例外,而呼叫Abort會立即在執行緒正在執行的地方拋出例外(非托管代碼除外),
這將導致一個新的問題:.NET Framework 中的代碼可能會被中止,而且不是安全的中止,如果中止發生在FileStream被構造期間,很可能造成一個非托管檔案句柄會一直保持打開直到應用程式域結束,
協作取消模式
正如上面所說Interrupt和Abort總是危險的
,替代方案就是實作一個協作模式(cooperative )
:作業執行緒定期檢查一個用于指示是否應該結束的標識
,發起者只需要設定這個標識,等待作業執行緒回應,即可取消執行緒執行,
Framework 4.0 提供了兩個類CancellationTokenSource和CancellationToken來完成這個模式:
CancellationTokenSource
定義了Cancel
方法,CancellationToken
定義了IsCancellationRequested
屬性和ThrowIfCancellationRequested
方法,
void 取消令牌()
{
var cancelSource = new CancellationTokenSource();
cancelSource.CancelAfter(3000);
var t = new Thread(() => Work(cancelSource.Token));
t.Start();
t.Join();
}
void Work(CancellationToken cancelToken)
{
while (true)
{
cancelToken.ThrowIfCancellationRequested();
// ...
Thread.Sleep(1000);
}
}
四、異步編程模式
MSDN檔案:https://learn.microsoft.com/zh-cn/dotnet/standard/asynchronous-programming-patterns/
異步編程模型(APM)
異步編程模型(Asynchronous Programming Model),提出于.NET Framework 1.x 的時代,基于IAsyncResult介面實作類似BeginXXX和EndXXX的方法,
APM是建立在委托之上的,Net Core中的委托 不支持異步呼叫,也就是 BeginInvoke 和 EndInvoke 方法,
void APM()
{
var uri = new Uri("https://www.albahari.com/threading/part3.aspx");
Func<Uri, int> f = CalcUriStringCount;
var res = f.BeginInvoke(uri, null, null);
// do something
_testOutputHelper.WriteLine("我可以做別的事情");
_testOutputHelper.WriteLine("共下載字符數:" + f.EndInvoke(res));
}
int CalcUriStringCount(Uri uri)
{
var client = new WebClient();
var res = client.DownloadString(uri);
return res.Length;
}
EndInvoke
會做三件事:
- 如果異步委托還沒有結束,它會等待異步委托執行完成,
- 它會接識訓傳值(也包括
ref
和out
方式的引數), - 它會向呼叫執行緒拋出未處理的例外,
不要因為異步委托呼叫的方法沒有回傳值就不呼叫EndInvoke,因為這將導致其內部的例外無法被呼叫執行緒察覺,MSDN檔案中明確寫了 “無論您使用何種方法,都要呼叫 EndInvoke 來完成異步呼叫,”
BeginInvoke
也可以指定一個回呼委托,這是一個在完成時會被自動呼叫的、接受IAsyncResult
物件的方法,
BeginInvoke
的最后一個引數是一個用戶狀態物件,用于設定IAsyncResult
的AsyncState
屬性,它可以是需要的任何東西,在這個例子中,我們用它向回呼方法傳遞method
委托,這樣才能夠在它上面呼叫EndInvoke
,
var uri = new Uri("https://www.albahari.com/threading/part3.aspx");
Func<Uri, int> func = CalcUriStringCount;
var res = func.BeginInvoke(uri, new AsyncCallback(res =>
{
var target = res.AsyncState as Func<string, int>;
_testOutputHelper.WriteLine("共下載字符數:" + target!.EndInvoke(res));
_testOutputHelper.WriteLine("異步狀態:" + res.AsyncState);
}), func);
// do something
_testOutputHelper.WriteLine("我可以做別的事情");
func.EndInvoke(res);
基于事件的異步模式(EAP)
基于事件的異步模式(event-based asynchronous pattern),EAP 是在 .NET Framework 2.0 中提出的,讓類可以提供多執行緒的能力,而不需要使用者顯式啟動和管理執行緒,這種模式具有以下能力:
- 協作取消模型(cooperative cancellation model)
- 執行緒親和性(thread affinity)
- 將例外轉發到完成事件(forwarding exceptions)
這個模式本質上就是:類提供一組成員,用于在內部管理多執行緒,類似于下邊的代碼:
// 這些成員來自于 WebClient 類:
public byte[] DownloadData (Uri address); // 同步版本
public void DownloadDataAsync (Uri address);
public void DownloadDataAsync (Uri address, object userToken);
public event DownloadDataCompletedEventHandler DownloadDataCompleted;
public void CancelAsync (object userState); // 取消一個操作
public bool IsBusy { get; } // 指示是否仍在運行
當呼叫基于EAP模式的類的XXXAsync方法時,就開始了一個異步操作,EAP模式是基于APM模式之上的,
var client = new WebClient();
client.DownloadStringCompleted += (sender, args) =>
{
if (args.Cancelled) _testOutputHelper.WriteLine("已取消");
else if (args.Error != null) _testOutputHelper.WriteLine("發生例外:" + args.Error.Message);
else
{
_testOutputHelper.WriteLine("共下載字符數:" + args.Result.Length);
// 可以在這里更新UI,,
}
};
_testOutputHelper.WriteLine("我在做別的事情");
client.DownloadStringAsync(new Uri("https://www.albahari.com/threading/part3.aspx"));
BackgroundWorker
是命名空間System.ComponentModel
中的一個工具類,用于管理作業執行緒,它可以被認為是一個 EAP 的通用實作,在EAP功能的基礎上額外提供了:
- 報告作業進度的協議
- 實作了
IComponent
介面
另外BackgroundWorker
使用了執行緒池,意味著絕不應該在BackgroundWorker
執行緒上呼叫Abort
,
void 作業進度報告()
{
worker = new BackgroundWorker();
worker.WorkerReportsProgress = true; // 支持進度報告
worker.WorkerSupportsCancellation = true; // 支持取消
worker.DoWork += DoWoker;
worker.ProgressChanged += (_, args) => _testOutputHelper.WriteLine($"當前進度:{args.ProgressPercentage}%");
worker.RunWorkerCompleted += (sender, args) =>
{
if (args.Cancelled) _testOutputHelper.WriteLine("作業執行緒已被取消");
else if (args.Error != null) _testOutputHelper.WriteLine("作業執行緒發生例外: " + args.Error);
else _testOutputHelper.WriteLine("任務完成,結果: " + args.Result); // Result來自DoWork
};
worker.RunWorkerAsync();
}
private void DoWoker(object? sender, DoWorkEventArgs e)
{
for (int i = 0; i < 100; i+= 10)
{
if (worker.CancellationPending)
{
e.Cancel = true;
return;
}
worker.ReportProgress(i); // 上報進度
Thread.Sleep(1000); // 模擬耗時任務
}
e.Result = int.MaxValue; // 這個值會回傳給RunWorkerCompleted
}
基于任務的異步模式 (TAP)
從 .NET Framework 4 開始引入
五、拓展知識
小林coding:https://xiaolincoding.com/os/4_process/process_base.html#行程的控制結構
執行緒優先級
執行緒的Priority屬性決定了相對于作業系統中的其它活動執行緒,它可以獲得多少CPU 時間片(time slice),
優先級依次遞增,在提升執行緒優先級前請三思,這可能會導致其它執行緒的 資源饑餓(resource starvation)
enum ThreadPriority { Lowest, BelowNormal, Normal, AboveNormal, Highest }
提升執行緒的優先級并不等于直接優先,因為執行緒還受行程優先級影響,因此還需要使用System.Diagnostics中的Process類
using (Process p = Process.GetCurrentProcess())
{
p.PriorityClass = ProcessPriorityClass.High;
}
ProcessPriorityClass.High是一個略低于最高優先級Realtime的級別,將一個行程的優先級設定為Realtime是通知作業系統,我們絕不希望該行程將 CPU 時間出讓給其它行程,
如果你的程式誤入一個死回圈,會發現甚至是作業系統也被鎖住了,就只好去按電源按鈕了o(>_<)o 正是由于這一原因,High 通常是實時程式的最好選擇,
什么是行程退出?
行程退出一般出現在以下幾種情況:
-
正常退出,行程執行完任務,
-
錯誤退出,行程遇到不可繼續運行的錯誤(發生例外未捕獲導致程式退出)
-
被作業系統終止,行程本身有問題,比如行程企圖訪問不屬于自己的記憶體地址
-
被其它行程終止,比如通過資源管理器我們可以選擇終止掉某個行程
以上只有前兩種情況是行程自愿退出的,因此,總體上可以分為三類:行程自愿退出,作業系統終止行程以及行程終止行程,
main()執行結束時會自動隱式呼叫exit(),windows下叫ExitProcess,中止整個程式的執行,把控制返還給作業系統,并回傳一個整數值,通常0表示正常終止,非0表示例外終止,這個值將會回傳給作業系統,
windows中通過任務管理器,linux中通過kill去殺掉一個行程,其資源是否會釋放?
會,行程的特征之一就是動態性,其生存周期就是產生到消亡,當發生行程終止后,呼叫行程終止原語,從PCB總線中將其洗掉,將PCB結構歸還給系統,釋放該行程的資源給其父行程或者作業系統,
但不完全會,如果用戶強行終止了.NET 行程,所有執行緒都會被當作后臺執行緒一般丟棄,有的資源沒來得及釋放,需要等待一段時間
Process類有以下兩種方法:
- CloseMainWindow:向主視窗訊息回圈發送wm_quit訊息以請求關閉行程,這使程式有機會重新呼叫其子視窗和內核物件,
- Kill:強制終止行程,就像在任務管理器中終止行程一樣,
我們可以使用visual studio組件:記憶體分析器 分析發現幾乎在所有情況下,kill速度更快,但通過檢查實時記憶體圖可以發現其“根參考”和“實體參考”釋放的記憶體更少,
轉載請註明出處,本文鏈接:https://www.uj5u.com/net/532473.html
標籤:C#