線程基礎 視頻已經發佈到B站 參考文章: 《Threading in C# 》(Joseph Albahari)https://www.albahari.com/threading/ 《Threading in C# 》中文翻譯(GKarch ):https://blog.gkarch.com/to ...
線程基礎
參考文章:
《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速度更快,但通過檢查實時記憶體圖可以發現其“根引用”和“實例引用”釋放的記憶體更少。