[轉(zhuǎn)]C#中的多線程 - 基礎(chǔ)知識(shí)(一)

原文:Threading in C#

1 簡(jiǎn)介及概念

·C# 支持通過(guò)多線程并行執(zhí)行代碼,線程有其獨(dú)立的執(zhí)行路徑,能夠與其它線程同時(shí)執(zhí)行。

·一個(gè) C# 客戶端程序(Console 命令行、WPF 以及 Windows Forms)開始于一個(gè)單線程,這個(gè)線程(也稱為“主線程”)是由 CLR 和操作系統(tǒng)自動(dòng)創(chuàng)建的,并且也可以再創(chuàng)建其它線程。以下是一個(gè)簡(jiǎn)單的使用多線程的例子:

所有示例都假定已經(jīng)引用了以下命名空間:

>using System;
>using System.Threading;
class ThreadTest
{
    static void Main()
    {
        Thread t = new Thread(WriteY);  // 創(chuàng)建新線程
        t.Start();                       // 啟動(dòng)新線程,執(zhí)行WriteY()

        // 同時(shí),在主線程做其它事情
        for (int i = 0; i < 1000; i++) Console.Write("x");
    }

    static void WriteY()
    {
        for (int i = 0; i < 1000; i++) Console.Write("y");
    }
}

輸出結(jié)果:

xxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyy
yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyxxxxxxxxxxxxxxxxxxxxxx
...

主線程創(chuàng)建了一個(gè)新線程t來(lái)不斷打印字母 “ y “,與此同時(shí),主線程在不停打印字母 “ x “。



線程一旦啟動(dòng),線程的IsAlive屬性值就會(huì)為true,直到線程結(jié)束。當(dāng)傳遞給Thread的構(gòu)造方法的委托執(zhí)行完成時(shí),線程就會(huì)結(jié)束。一旦結(jié)束,該線程不能再重新啟動(dòng)。

CLR 為每個(gè)線程分配各自獨(dú)立的棧空間,因此局部變量是獨(dú)立的。在下面的例子中,我們定義一個(gè)擁有局部變量的方法,然后在主線程和新創(chuàng)建的線程中同時(shí)執(zhí)行該方法。

static void Main()
{
    new Thread(Go).Start();      // 在新線程執(zhí)行Go()
    Go();                         // 在主線程執(zhí)行Go()
}

static void Go()
{
    // 定義和使用局部變量 - 'cycles'
    for (int cycles = 0; cycles < 5; cycles++) Console.Write('?');
}

輸出結(jié)果:??????????

變量cycles的副本是分別在各自的棧中創(chuàng)建的,因此才會(huì)輸出 10 個(gè)問(wèn)號(hào)。

線程可以通過(guò)對(duì)同一對(duì)象的引用來(lái)共享數(shù)據(jù)。例如:

class ThreadTest
{
    bool done;

    static void Main()
    {
        ThreadTest tt = new ThreadTest();   // 創(chuàng)建一個(gè)公共的實(shí)例
        new Thread(tt.Go).Start();
        tt.Go();
    }

    // 注意: Go現(xiàn)在是一個(gè)實(shí)例方法
    void Go()
    {
        if (!done) { done = true; Console.WriteLine("Done"); }
    }
}

由于兩個(gè)線程是調(diào)用了同一個(gè)的ThreadTest實(shí)例上的Go(),它們共享了done字段,因此輸出結(jié)果是一次 “ Done “,而不是兩次。

輸出結(jié)果:Done

靜態(tài)字段提供了另一種在線程間共享數(shù)據(jù)的方式,以下是一個(gè)靜態(tài)的done字段的例子:

class ThreadTest
{
    static bool done;    // 靜態(tài)字段在所有線程中共享

    static void Main()
    {
        new Thread(Go).Start();
        Go();
    }

    static void Go()
    {
        if (!done) { done = true; Console.WriteLine("Done"); }
    }
}

以上兩個(gè)例子引出了一個(gè)關(guān)鍵概念線程安全(thread safety)。上述兩個(gè)例子的輸出實(shí)際上是不確定的:” Done “ 有可能會(huì)被打印兩次。如果在Go
方法里調(diào)換指令的順序,” Done “ 被打印兩次的幾率會(huì)大幅提高:

static void Go()
{
    if (!done) { Console.WriteLine("Done"); done = true; }
}

輸出結(jié)果:

Done
Done(很可能!)

這個(gè)問(wèn)題是因?yàn)橐粋€(gè)線程對(duì)if中的語(yǔ)句估值的時(shí)候,另一個(gè)線程正在執(zhí)行WriteLine語(yǔ)句,這時(shí)done還沒有被設(shè)置為true。

修復(fù)這個(gè)問(wèn)題需要在讀寫公共字段時(shí),獲得一個(gè)排它鎖(互斥鎖,exclusive lock )。C# 提供了lock來(lái)達(dá)到這個(gè)目的:

class ThreadSafe
{
    static bool done;
    static readonly object locker = new object();

    static void Main()
    {
        new Thread(Go).Start();
        Go();
    }

    static void Go()
    {
        lock (locker)
        {
            if (!done) { Console.WriteLine("Done"); done = true; }
        }
    }
}

當(dāng)兩個(gè)線程同時(shí)爭(zhēng)奪一個(gè)鎖的時(shí)候(例子中的locker),一個(gè)線程等待,或者說(shuō)阻塞,直到鎖變?yōu)榭捎?。這樣就確保了在同一時(shí)刻只有一個(gè)線程能進(jìn)入臨界區(qū)(critical section,不允許并發(fā)執(zhí)行的代碼),所以 “ Done “ 只被打印了一次。像這種用來(lái)避免在多線程下的不確定性的方式被稱為線程安全(thread-safe)。

在線程間共享數(shù)據(jù)是造成多線程復(fù)雜、難以定位的錯(cuò)誤的主要原因。盡管這通常是必須的,但應(yīng)該盡可能保持簡(jiǎn)單。

一個(gè)線程被阻塞時(shí),不會(huì)消耗 CPU 資源。


1.1 Join 和 Sleep

可以通過(guò)調(diào)用Join方法來(lái)等待另一個(gè)線程結(jié)束,例如:

static void Main()
{
    Thread t = new Thread(Go);
    t.Start();
    t.Join();
    Console.WriteLine("Thread t has ended!");
}

static void Go()
{
    for (int i = 0; i < 1000; i++) Console.Write("y");
}

輸出 “ y “ 1,000 次之后,緊接著會(huì)輸出 “ Thread t has ended! “。當(dāng)調(diào)用Join時(shí)可以使用一個(gè)超時(shí)參數(shù),以毫秒或是TimeSpan形式。如果線程正常結(jié)束則返回true,如果超時(shí)則返回false。

Thread.Sleep會(huì)將當(dāng)前的線程阻塞一段時(shí)間:

Thread.Sleep (TimeSpan.FromHours (1));  // 阻塞 1小時(shí)
Thread.Sleep (500);                     // 阻塞 500 毫秒

當(dāng)使用Sleep或Join等待時(shí),線程是阻塞(blocked)狀態(tài),因此不會(huì)消耗 CPU 資源。

Thread.Sleep(0)會(huì)立即釋放當(dāng)前的時(shí)間片,將 CPU 資源出讓給其它線程。Framework 4.0 新的Thread.Yield()方法與其相同,除了它只會(huì)出讓給運(yùn)行在相同處理器核心上的其它線程。

Sleep(0)和Yield在調(diào)整代碼性能時(shí)偶爾有用,它也是一個(gè)很好的診斷工具,可以用于找出線程安全(thread safety)的問(wèn)題。如果在你代碼的任意位置插入Thread.Yield()會(huì)影響到程序,基本可以確定存在 bug。

1.2 線程是如何工作的

線程在內(nèi)部由一個(gè)線程調(diào)度器(thread scheduler)管理,一般 CLR 會(huì)把這個(gè)任務(wù)交給操作系統(tǒng)完成。線程調(diào)度器確保所有活動(dòng)的線程能夠分配到適當(dāng)?shù)膱?zhí)行時(shí)間,并且保證那些處于等待或阻塞狀態(tài)(例如,等待排它鎖或者用戶輸入)的線程不消耗CPU時(shí)間。

在單核計(jì)算機(jī)上,線程調(diào)度器會(huì)進(jìn)行時(shí)間切片(time-slicing),快速的在活動(dòng)線程中切換執(zhí)行。在 Windows 操作系統(tǒng)上,一個(gè)時(shí)間片通常在十幾毫秒(譯者注:默認(rèn) 15.625ms),遠(yuǎn)大于 CPU 在線程間進(jìn)行上下文切換的開銷(通常在幾微秒?yún)^(qū)間)。

在多核計(jì)算機(jī)上,多線程的實(shí)現(xiàn)是混合了時(shí)間切片和真實(shí)的并發(fā),不同的線程同時(shí)運(yùn)行在不同的 CPU 核心上。幾乎可以肯定仍然會(huì)使用到時(shí)間切片,因?yàn)椴僮飨到y(tǒng)除了要調(diào)度其它的應(yīng)用,還需要調(diào)度自身的線程。

線程的執(zhí)行由于外部因素(比如時(shí)間切片)被中斷稱為被搶占(preempted)。在大多數(shù)情況下,線程無(wú)法控制其在何時(shí)及在什么代碼處被搶占。

1.3 線程 vs 進(jìn)程

好比多個(gè)進(jìn)程并行在計(jì)算機(jī)上執(zhí)行,多個(gè)線程是在一個(gè)進(jìn)程中并行執(zhí)行。進(jìn)程是完全隔離的,而線程是在一定程度上隔離。一般的,線程與運(yùn)行在相同程序中的其它線程共享堆內(nèi)存。這就是線程為何有用的部分原因,一個(gè)線程可以在后臺(tái)獲取數(shù)據(jù),而另一個(gè)線程可以同時(shí)顯示已獲取到的數(shù)據(jù)。

1.4線程的使用與誤用

多線程有許多用處,下面是通常的應(yīng)用場(chǎng)景:

維持用戶界面的響應(yīng)

使用工作線程并行運(yùn)行時(shí)間消耗大的任務(wù),這樣主UI線程就仍然可以響應(yīng)鍵盤、鼠標(biāo)的事件。

有效利用 CPU

多線程在一個(gè)線程等待其它計(jì)算機(jī)或硬件設(shè)備響應(yīng)時(shí)非常有用。當(dāng)一個(gè)線程在執(zhí)行任務(wù)時(shí)被阻塞,其它線程就可以利用這個(gè)空閑出來(lái)的CPU核心。

并行計(jì)算

在多核心或多處理器的計(jì)算機(jī)上,計(jì)算密集型的代碼如果通過(guò)分治策略(divide-and-conquer,見第 5 部分)將工作量分?jǐn)偟蕉鄠€(gè)線程,就可以提高計(jì)算速度。

推測(cè)執(zhí)行(speculative execution)

在多核心的計(jì)算機(jī)上,有時(shí)可以通過(guò)推測(cè)之后需要被執(zhí)行的工作,提前執(zhí)行它們來(lái)提高性能。LINQPad就使用了這個(gè)技術(shù)來(lái)加速新查詢的創(chuàng)建。另一種方式就是可以多線程并行運(yùn)行解決相同問(wèn)題的不同算法,因?yàn)轭A(yù)先不知道哪個(gè)算法更好,這樣做就可以盡早獲得結(jié)果。

允許同時(shí)處理請(qǐng)求

在服務(wù)端,客戶端請(qǐng)求可能同時(shí)到達(dá),因此需要并行處理(如果你使用 ASP.NET、WCF、Web Services 或者 Remoting,.NET Framework 會(huì)自動(dòng)創(chuàng)建線程)。這在客戶端同樣有用,例如處理 P2P 網(wǎng)絡(luò)連接,或是處理來(lái)自用戶的多個(gè)請(qǐng)求。

如果使用了 ASP.NET 和 WCF 之類的技術(shù),可能不會(huì)注意到多線程被使用,除非是訪問(wèn)共享數(shù)據(jù)時(shí)(比如通過(guò)靜態(tài)字段共享數(shù)據(jù))。如果沒有正確的加鎖,就可能產(chǎn)生線程安全問(wèn)題。
多線程同樣也會(huì)帶來(lái)缺點(diǎn),最大的問(wèn)題是它提高了程序的復(fù)雜度。使用多個(gè)線程本身并不復(fù)雜,復(fù)雜的是線程間的交互(一般是通過(guò)共享數(shù)據(jù))。無(wú)論線程間的交互是否有意為之,都會(huì)帶來(lái)較長(zhǎng)的開發(fā)周期,以及帶來(lái)間歇的、難以重現(xiàn)的 bug。因此,最好保證線程間的交互盡量少,并堅(jiān)持簡(jiǎn)單和已被證明的多線程交互設(shè)計(jì)。這篇文章主要就是關(guān)于如何處理這種復(fù)雜的問(wèn)題,如果能夠移除線程間交互,那會(huì)輕松許多。

一個(gè)好的策略是把多線程邏輯使用可重用的類封裝,以便于獨(dú)立的檢驗(yàn)和測(cè)試。.NET Framework 提供了許多高層的線程構(gòu)造,之后會(huì)講到。

當(dāng)頻繁地調(diào)度和切換線程時(shí)(并且如果活動(dòng)線程數(shù)量大于 CPU 核心數(shù)),多線程會(huì)增加資源和 CPU 的開銷,線程的創(chuàng)建和銷毀也會(huì)增加開銷。多線程并不總是能提升程序的運(yùn)行速度,如果使用不當(dāng),反而可能降低速度。 例如,當(dāng)需要進(jìn)行大量的磁盤 I/O 時(shí),幾個(gè)工作線程順序執(zhí)行可能會(huì)比 10 個(gè)線程同時(shí)執(zhí)行要快。(在使用Wait和Pulse進(jìn)行同步中,將會(huì)描述如何實(shí)現(xiàn) 生產(chǎn)者 / 消費(fèi)者隊(duì)列,它提供了上述功能。)

參考文獻(xiàn):

http://www.codeproject.com/Articles/98346/Microsecond-and-Millisecond-NET-Timer
http://www.codeproject.com/Articles/571289/Obtaining-Microsecond-Precision-in-NET
http://www.pinvoke.net/default.aspx/winmm/timeSetEvent.html
http://www.geisswerks.com/ryan/FAQS/timing.html
http://blog.gkarch.com/topic/threading.html
http://omeg.pl/blog/2011/11/on-winapi-timers-and-their-resolution/
https://randomascii.wordpress.com/2013/07/08/windows-timer-resolution-megawatts-wasted/
http://www.windowstimestamp.com/description

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容