本文目錄:
- 線程的簡單使用
- 並發和異步的區別
- 並發控制 - 鎖
- 線程的信號機制
- 線程池中的線程
- 案例:支持並發的異步日志組件
線程的簡單使用
常見的並發和異步大多是基於線程來實現的,所以本文先講線程的簡單使用方法。
使用線程,我們需要引用System.Threading命名空間。創建一個線程最簡單的方法就是在 new 一個 Thread,並傳遞一個ThreadStart委托(無參數)或ParameterizedThreadStart委托(帶參數),如下:
class Program {
static void Main(string[] args) {
// 使用無參數委托ThreadStart
Thread t = new Thread(Go);
t.Start();
// 使用帶參數委托ParameterizedThreadStart
Thread t2 = new Thread(GoWithParam);
t2.Start("Message from main.");
t2.Join();// 等待線程t2完成。
Console.WriteLine("Thread t2 has ended!");
Console.ReadKey();
}
static void Go() {
Console.WriteLine("Go!");
}
static void GoWithParam(object msg) {
Console.WriteLine("Go With Param! Message: " + msg);
Thread.Sleep(1000);// 模擬耗時操作
}
}
運行結果:

線程的用法,我們只需要了解這么多。下面我們再來通過一段代碼來講講並發和異步。
並發和異步的區別
關於並發和異步,我們先來寫一段代碼,模擬多個線程同時寫1000條日志:
class Program {
static void Main(string[] args) {
Thread t1 = new Thread(Working);
t1.Name = "Thread1";
Thread t2 = new Thread(Working);
t2.Name = "Thread2";
Thread t3 = new Thread(Working);
t3.Name = "Thread3";
// 依次啟動3個線程。
t1.Start();
t2.Start();
t3.Start();
Console.ReadKey();
}
// 每個線程都同時在工作
static void Working() {
// 模擬1000次寫日志操作
for (int i = 0; i < 1000; i++) {
// 異步寫文件
Logger.Write(Thread.CurrentThread.Name + " writes a log: " + i + ", on " + DateTime.Now.ToString() + ".\n");
}// 做一些其它的事件
for (int i = 0; i < 1000; i++) { }
}
}
代碼很簡單,相信大家都能看得懂。Logger 大家可以把它看做是一個寫日志的組件,先不關心它的具體實現,只要知道它是一個提供了寫日志功能的組件就行。
那么,這段代碼跟並發和異步有什么關系呢?
我們先用一張圖來描述這段代碼:

觀察上圖,3個線程同時調用Logger寫日志,對於Logger來說,3個線程同時交給了它任務,這種情況就是並發。對於其中一個線程來說,它在工作過程中,在某個時間請求Logger幫它寫日志,同時又繼續在自己的其它工作,這種情況就是異步。
(經讀者反饋,為不“誤導”讀者(盡管我個人不覺得是誤導。之前我的定義和解釋不全 面,沒有從操作系統和CPU層次去區分這兩個概念。我的文章不喜歡搬教科書,只是想用通俗易讀的白話讓大家理解),為了知識的專業性和嚴謹,現已把我理解 的對並發和異步的定義刪除,感謝園友們的熱心討論)。
接下來,我們繼續講幾個很有用的有關線程和並發的知識 - 鎖、信號機制和線程池。
並發控制 - 鎖
CLR 會為每個線程分配自己的內存堆空間,以使他們的本地變量保持分離互不干擾。
線程之間也可以共享通用的數據,比如同一對象的某個屬性或全局靜態變量。但線程間共享數據是存在安全問題的。舉個例子,下面的主線程和新線程共享了變量done,done用來標識某件事已經做過了(告訴其它線程不要再重復做了):
class Program {
static bool done;
static void Main(string[] args) {
new Thread(Go).Start(); // 在新的線程上調用Go
Go(); // 在主線程上調用Go
Console.ReadKey();
}
static void Go() {
if (!done) {
Thread.Sleep(500); // 模擬耗時操作
Console.WriteLine("Done");
done = true;
}
}
}
輸出結果:

輸出了兩個“Done”,事件被做了兩次。由於沒有控制好並發,這就出現了線程的安全問題,無法保證數據的狀態。
要解決這個問題,就需要用到鎖(Lock,也叫排它鎖或互斥鎖)。使用lock語句,可以保證共享數據只能同時被一個線程訪問。lock的數據對象 要求是不能null的引用類型的對象,所以lock的對象需保證不能為空。為此需要創建一個不為空的對象來使用鎖,修改一下上面的代碼如下:
class Program {
static bool done;
static object locker = new object(); // !!
static void Main(string[] args) {
new Thread(Go).Start(); // 在新的線程上調用Go
Go(); // 在主線程上調用Go
Console.ReadKey();
}
static void Go() {
lock (locker) {
if (!done) {
Thread.Sleep(500); // Doing something.
Console.WriteLine("Done");
done = true;
}
}
}
}
再看結果:

使用鎖,我們解決了問題。但使用鎖也會有另外一個線程安全問題,那就是“死鎖”,死鎖的概率很小,但也要避免。保證“上鎖”這個操作在一個線程上執行是避免死鎖的方法之一,這種方法在下文案例中會用到。
這里我們就不去深入研究“死鎖”了,感興趣的朋友可以去查詢相關資料。
線程的信號機制
有時候你需要一個線程在接收到某個信號時,才開始執行,否則處於等待狀態,這是一種基於信號的事件機制。.NET框架提供一個 ManualResetEvent類來處理這類事件,它的 WaiOne 實例方法可使當前線程一直處於等待狀態,直到接收到某個信號。它的Set方法用於打開發送信號。下面是一個信號機制的使用示例:
static void Main(string[] args) {
var signal = new ManualResetEvent(false);
new Thread(() => {
Console.WriteLine("Waiting for signal...");
signal.WaitOne();
signal.Dispose();
Console.WriteLine("Got signal!");
}).Start();
Thread.Sleep(2000);
signal.Set();// 打開“信號”
Console.ReadKey();
}
運行結果:

當執行Set方法后,信號保持打開狀態,可通過Reset方法將其關閉,若不再需要,通過Dispose將其釋放。如果預期的等待時間很短,可以用 ManualResetEventSlim代替ManualResetEvent,前者在等待時間較短時性能更好。信號機制非常有用,后面的日志案例會用 到它。
線程池中的線程
線程池中的線程是由CLR來管理的。在下面兩種條件下,線程池能起到最好的效用:
- 任務運行的時候比較短(<250ms),這樣CLR可以充分調配現有的空閑線程來處理該任務;
- 大量時間處於等待(或阻塞)的任務不去支配線程池的線程。
要使用線程中的線程,主要有下面兩種方式:
// 方式1:Task.Run,.NET Framework 4.5 才有
Task.Run (() => Console.WriteLine ("Hello from the thread pool"));
// 方式2:ThreadPool.QueueUserWorkItem
ThreadPool.QueueUserWorkItem (t => Console.WriteLine ("Hello from the thread pool"));
線程池使得線程可以充分有效地被使用,減少了任務啟動的延遲。但是不是所有的情況都適合使用線程池中的線程,比如下面要講的日志案例 - 異步寫文件。
這里講線程池,是為了讓大家大致了解什么時候用線程池中的線程,什么時候不用。即,耗時長或有阻塞情況的不用線程池中的線程。
創建不走線程池中的線程,可以直接通過new Thread來創建,也可以通過下面的代碼來創建:
Task task = Task.Factory.StartNew (() => ...,TaskCreationOptions.LongRunning);// 注意必須帶TaskCreationOptions.LongRunning參數
這里用到了Task,大家不用關心它,后續博文會詳細講。
關於線程的知識很多,這里不再深入了,因為這些已經足夠讓我們應付Web開發了。
案例:支持並發的異步日志組件
上文的“並發和異步的區別”的代碼中我們用到了一個Logger類,現在我們就來做一個這樣的Logger。
基於上面的知識,我們可以實現應用程序的並發寫日志日志功能。在應用程序中,寫日志是常見的功能,簡單分析一下該功能的需求:
- 在后台異步執行,和其它線程互不影響。
根據上文線程池的兩個最優使用條件,由寫日志線程會長時間處於阻塞(或運行等待)狀態,所以它不適合使用線程池。即不能使用Task.Run,而最好使用new Thread。 - 支持並發,即多個任務(分布在不同線程上)可同時調用寫日志功能,但需保證線程安全。
支持並發,必然要用到鎖,但要完全保證線程安全,那就要想辦法避免“死鎖”。只要我們把“上鎖”的操作始終由同一個線程來做即可避免“死鎖”問題,但這樣的話,並發請求的任務只能放在隊列中由該線程依次執行(因為是后台執行,無需即時響應用戶,所以可以這么做)。 - 單個實例,單個線程。
任何地方調用寫日志功能都調用的是同一個Logger實例(顯然不能每次寫日志都新建一個實例),即需使用單例模式。不管有多少任務調用寫日志功能,都必須始終使用同一個線程來處理這些寫日志操作,以保證不占用過多的線程資源和避免新建線程帶來的延遲。
運用上面的知識,我們來寫一個這樣的類。簡單理一下思路:
- 需要一個用來存放寫日志任務的隊列。
- 需要有一個信號機制來標識是否有新的任務要執行。
- 當有新的寫日志任務時,將該任務加入到隊列中,並發出信號。
- 用一個方法來處理隊列中的任務,當接收新任務信號時,就依次調用隊列中的任務。
開發一個功能前需要有個簡單的思路,保證心里面有底。具體開發的時候會發現問題,然后再去補充擴展和完善等。剛開始很難想得太周全,先有個簡單的思路,然后代碼寫起來!
下面是這樣一個Logger類初步實現:
public class Logger {
// 用於存放寫日志任務的隊列
private Queue<Action> _queue;
// 用於寫日志的線程
private Thread _loggingThread;
// 用於通知是否有新日志要寫的“信號器”
private ManualResetEvent _hasNew;
// 構造函數,初始化。
private Logger() {
_queue = new Queue<Action>();
_hasNew = new ManualResetEvent(false);
_loggingThread = new Thread(Process);
_loggingThread.IsBackground = true;
_loggingThread.Start();
}
// 使用單例模式,保持一個Logger對象
private static readonly Logger _logger = new Logger();
private static Logger GetInstance() {
/* 不安全代碼
lock (locker) {
if (_logger == null) {
_logger = new Logger();
}
}*/
return _logger;
}
// 處理隊列中的任務
private void Process() {
while (true) {
// 等待接收信號,阻塞線程。
_hasNew.WaitOne();
// 接收到信號后,重置“信號器”,信號關閉。
_hasNew.Reset();
// 由於隊列中的任務可能在極速地增加,這里等待是為了一次能處理更多的任務,減少對隊列的頻繁“進出”操作。
Thread.Sleep(100);
// 開始執行隊列中的任務。
// 由於執行過程中還可能會有新的任務,所以不能直接對原來的 _queue 進行操作,
// 先將_queue中的任務復制一份后將其清空,然后對這份拷貝進行操作。
Queue<Action> queueCopy;
lock (_queue) {
queueCopy = new Queue<Action>(_queue);
_queue.Clear();
}
foreach (var action in queueCopy) {
action();
}
}
}
private void WriteLog(string content) {
lock (_queue) { // todo: 這里存在線程安全問題,可能會發生阻塞。
// 將任務加到隊列
_queue.Enqueue(() => File.AppendAllText("log.txt", content));
}
// 打開“信號”
_hasNew.Set();
}
// 公開一個Write方法供外部調用
public static void Write(string content) {
// WriteLog 方法只是向隊列中添加任務,執行時間極短,所以使用Task.Run。
Task.Run(() => GetInstance().WriteLog(content));
}
}
類寫好了,用上文“並發和異步的區別”中的代碼測試一下這個Logger類,在我的電腦上運行的一次結果:

共3000條日志,結果沒有問題。
上面的Logger類注釋寫得很詳細,我就不再解析了。
通過這個示例,目的是讓大家掌握線程和並發在開發中的基本應用和要注意的問題。
遺憾的是這個Logger類並不完美,而且存在線程安全問題(代碼中用紅色字體標出),雖然實際環境概率很小。可能上面代碼多次運行都很難看到有異常發生(我多次運行未發生異常),但同時再添加幾個線程可能就會有問題了。
那么,如何解決這個線程安全問題呢?

