C#多線程、並行和異步編程學習筆記

一,進程、應用程序域和對象上下文

1,CLR

CLR(Common Language Runtime,公共語言運行庫),主要作用使定位、加載和管理.Net類型,同時負責一些底層細節的工作,如內存管理、應用託管、處理線程、安全檢查等。

2,進程

進程是一個運行程序。進程是一個操作系統級別的概念,用來描述一組資源(比如外部代碼庫和主線程)和程序運行必須的內存分配。對於每一個加載到內存的*.exe,在它的生命週期中操作系統會爲之創建一個單獨且隔離的進程。 
由於一個進程的失敗不會影響其他進程,使用這種隔離方式,運行環境將更加健壯和穩定。此外,一個進程無法訪問另外一個進程中的數據,除非 使用WCF這種分佈式計算編程的API。所以,進程是一個正在運行的應用程序的固定的安全的邊界。 
每一個Windows進程都有一個唯一的進程標識符,即PID,當需要時,它們能被操作系統加載和卸載。

3,線程

線程是進程中的基本執行單元。 
每一個Windows進程都包含一個用作程序入口點的主線程。進程的入口點創建的第一個線程被稱爲主線程。.Net控制檯程序使用Main()方法作爲程序入口點。當調用該方法時,會自動創建主線程。 
僅包含一個主線程的進程是線程安全的,這是由於在某個特定時刻只有一個線程訪問程序中的數據。然而,如果這個線程正在執行一個複雜的操作,那麼這個線程所在的進程(特別是GUI程序)對用戶來說會顯得沒有響應一樣。 
因此,主線程可以產生次線程(也稱爲工作者線程,worker thread)。每一個線程(無論主線程還是次線程)都是進程中的一個獨立執行單元,它們能夠同時訪問那些共享數據。 
開發者可以使用多線程改善程序的總體響應性,讓人感覺大量的活動幾乎在同一時間發送。比如,一個應用程序可以產生一個工作者線程來執行強度大的工作。當這個次線程正在忙碌的時候,主線程仍然對用戶的輸入保持響應,這使得整個進程具有更強的性能。當然,如果單個進程中的線程過多的話,性能反而會下降,因爲CPU需要花費不少時間在這些活動的線程來回切換。 
單CPU的計算機並沒有能力在同一時間運行多個線程。準確地說,在一個單位時間(即一個時間片)內,單CPU只能根據線程優先級執行一個線程。當一個線程的時間片用完的時候,它會被掛起,以便執行其他線程。對於線程來說,它們需要在掛起前記住發生了什麼,它們把這些情況寫到線程本地存儲中(Thread Local Storage,TLS),並且它們還要獲得一個獨立的調用棧(call stack)。 
這裏寫圖片描述

4,.Net應用程序域

實際上,.Net可執行程序承載在進程的一個邏輯分區中,稱爲應用程序域(AppDomain)。一個進程可以包含多個應用程序域,每一個應用程序域中承載一個.Net可執行程序。 
單個進程可以承載多個應用程序域,其中每一個程序域都和該進程(或其它進程)中其它的程序域完全徹底隔離開。如果不使用分佈式編程協議(如WCF),運行在某個應用程序域中的應用程序將無法訪問其它應用程序域中的任何數據(無論是全局變量還是靜態變量)。

5,對象上下文

應用程序域是承載.Net程序集的進程的邏輯分區。應用程序域也可以進一步被劃分成多個上下文邊界。即,.Net上下文爲單獨的應用程序域提供了一種方式,該方式能爲一個給定對象建立“特定的家”。 
和一個進程定義了默認的應用程序域一樣,每個應用程序域都有一個默認的上下文。這個默認的上下文(由於它總是應用程序創建的第一個上下文,所以有時稱爲上下文0,即context0)用於組合那些對上下文沒有具體的或唯一性需求的.Net對象。大多數.Net對象都會被加載打上下文0中。如果CLR判斷一個新創建的對象有特殊需求,一個新的上下文邊界將會在承載它的應用程序域中被創建。 
舉例來說,如果定義一個需要自動線程安全(使用[Synchronization]特性)的C#類型,CLR將會在分配期間創建“上下文同步”。如果一個已分配的對象從一個同步的上下文轉移到一個非同步的上下文,對象將突然不再是線程安全的並且極有可能變成大塊的壞數據,而大量線程還在視圖與這個(現在已是線程不穩定的)引用對象交互。 
這裏寫圖片描述

進程、應用程序域和上下文是熟悉多線程編程需要了解的知識點。

  • 一個.Net進程可以承載多個應用程序域。每一個應用程序域可以承載多個相關的.Net程序集,並且可由CLR獨立地加載或卸載應用程序域。
  • 一個給定的應用程序域中包含一個或多個上下文。使用上下文,CLR能夠將“有特殊需求的”對象放置到一個邏輯容器中,確保該對象的運行時需要能夠被滿足。
  • -

二,多線程的併發問題

幾乎無法控制底層操作系統和CLR對線程的調度。 
舉例來說,如果精心編寫一段創建一個新線程的代碼,你不能保證這個線程被立即執行。更準確地說,這段代碼僅僅通知操作系統或CLR儘快地執行這個線程(通常是線程調度程序給這個線程分配時間)。

線程具有不穩定操作(thread-volatile)和原子型(atomic)操作。 
舉例來說,假如有一個線程正則調用某個特定對象的一個方法,爲了讓另一個線程也訪問同一對象的同一方法,線程調度程序將發出指令掛起第一個線程。 
而此時,如果前一個線程沒有全部完成當前的操作,那麼後來的線程可能看到對象處於被部分修改狀態。這樣它所讀到的數據基本上是虛假的,而這會使應用程序發生非常奇怪的(並且是非常難以發現的)bug,而且這些bug都難以重現和調試。 
另一方面,原子型操作在多線程環境下總是(線程)安全的。可是.Net基礎類庫中只有很少的操作能保證原子型。甚至將一個賦值給一個成員變量的操作也不是原子型的。


三,創建次線程

Thred類支持設置Name屬性。如果沒有設置這個值的話,Name將返回一個空字符串。如果需要用vs調試的話,可以爲線程設置一個友好的Name,方便debug。

Thred類定義了一個名爲Priority的屬性,默認情況下,所有線程的優先級都處於Normal級別。但是,在線程生命週期的任何時候,都可以使用ThredPriority屬性修改線程的優先級。 
如果給線程的優先級指定一個非默認值,這並不能控制線程調度器切換線程的過程。實際上,一個線程的優先級僅僅是把線程活動的重要程度提供給CLR。因此,一個帶有Highest優先級的線程並不一定保證能得到最高的優先級。 
理論上,提高一些線程的優先級別會阻止那些低優先級別的線程執行任務。

使用ThreadStart委託創建線程

 

class Program
    {
        static void Main(string[] args)
        {
            Printer p = new Printer();
            Thread backgroundThread = new Thread(new ThreadStart(p.PrintNumbers));
            backgroundThread.Start();

            Console.ReadLine();
        }
    }

    public class Printer
    {
        public void PrintNumbers()
        {
            Console.Write("Your numbers: ");
            for (int i = 0; i < 10; i++)
            {
                Console.Write("{0}, ", i);
                Thread.Sleep(2000);
            }
            Console.WriteLine();
        }
    }
  •  
  • 使用ParameterizedThreadStart委託創建線程,傳遞數據

ThreadStart委託僅僅支持指向無返回值、無參數的方法。如果想把數據傳遞給在次線程上執行的方法,則需要使用ParameterizedThreadStart委託類型。

 

class Program
    {
        static void Main(string[] args)
        {
            AddParams ap = new AddParams(10, 10);
            Thread t = new Thread(new ParameterizedThreadStart(Add));
            t.Start(ap);

            Console.ReadLine();
        }

        static void Add(object data)
        {
            if (data is AddParams)
            {
                AddParams ap = (AddParams)data;
                Console.WriteLine("{0} + {1} is {2}",
                  ap.a, ap.b, ap.a + ap.b);

            }
        }
    }

    class AddParams
    {
        public int a, b;

        public AddParams(int numb1, int numb2)
        {
            a = numb1;
            b = numb2;
        }
    }

使用AutoResetEvent 類強制線程等待,直到其他線程結束

class Program
    {
        private static AutoResetEvent waitHandle = new AutoResetEvent(false);
        static void Main(string[] args)
        {
            AddParams ap = new AddParams(10, 10);
            Thread t = new Thread(new ParameterizedThreadStart(Add));
            t.Start(ap);

            // Wait here until you are notified         
            waitHandle.WaitOne();

            Console.WriteLine("Other thread is done!");

            Console.ReadLine();
        }

        static void Add(object data)
        {
            if (data is AddParams)
            {
                AddParams ap = (AddParams)data;
                Console.WriteLine("{0} + {1} is {2}",
                  ap.a, ap.b, ap.a + ap.b);

                // Tell other thread we are done.
                waitHandle.Set();
            }
        }
    }

    class AddParams
    {
        public int a, b;

        public AddParams(int numb1, int numb2)
        {
            a = numb1;
            b = numb2;
        }
    }

前臺線程和後臺線程

前臺線程能阻止應用程序的終結。一直到所有的前臺線程終止後,CLR才能關閉應用程序(即卸載承載的應用程序域)。 
後臺線程被CLR認爲是程序執行中可做出犧牲的線程,即在任何時候(即使這個線程此時正在執行某項工作)都可能被忽略。因此,如果所有的前臺線程終止,當應用程序卸載時,所有的後臺線程也會被自動終止。

前臺線程和後臺線程並不等同於主線程和工作者線程。默認情況下,所有通過Thread.Start()方法創建的線程都自動成爲前臺線程。可以通過修改線程的IsBackground屬性將前臺線程配置爲後臺線程。

多數情況下,當程序的主任務完成,而工作者線程正在執行無關緊要的任務時,把工作線程配置成後臺類型時很有用的。例如,構建一個每隔幾分鐘就ping一次郵件服務器看有沒有新郵件的應用程序,或更新當前天氣條件等其他無關緊要的任務。


四,解決線程的併發問題

在構建多線程應用程序時,需要確保任何共享數據都需要處於被保護狀態,以防止多個線程修改它的值。由於一個應用程序域中的所有線程都能夠併發訪問共享數據,所以,想象一下當它們正在訪問其中的某個數據項時,由於線程調度器會隨機掛起線程,所以如果線程A在完成之前被掛起了,線程B讀到的就是一個不穩定的數據。

    public class Printer
    {

        public void PrintNumbers()
        {
            // Display Thread info.
            Console.WriteLine("-> {0} is executing PrintNumbers()",
              Thread.CurrentThread.Name);

            // Print out numbers.
            Console.Write("Your numbers: ");
            for (int i = 0; i < 10; i++)
            {
                Random r = new Random();
                Thread.Sleep(100 * r.Next(5));
                Console.Write("{0}, ", i);
            }
            Console.WriteLine();
        }
    }


    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("*****Synchronizing Threads *****\n");

            Printer p = new Printer();

            // Make 10 threads that are all pointing to the same
            // method on the same object.
            Thread[] threads = new Thread[10];
            for (int i = 0; i < 10; i++)
            {
                threads[i] =
                  new Thread(new ThreadStart(p.PrintNumbers));
                threads[i].Name = string.Format("Worker thread #{0}", i);
            }

            // Now start each one.
            foreach (Thread t in threads)
                t.Start();
            Console.ReadLine();
        }
    }

每一次執行都可能產生不同的輸出結果。當每個線程都調用printer來輸出數字的時候,線程調度器可能正在切線程。這導致了不同的輸出結果。此時需要通過編程控制對共享數據的同步訪問。

使用C#的lock關鍵字進行同步

同步訪問共享資源的首先技術是C#的lock關鍵字。這個關鍵字允許定義一段線程同步的代碼語句。採用這項技術,後進入的線程不會中斷當前線程,而是停止自身下一步執行。lcok關鍵字需要定義一個標記(即一個對象引用),線程進入鎖定範圍的時候必須獲得這個標記。當試圖鎖定的是一個實例級對象的私有方法時,使用方法本身所在對象的引用就可以,如下所示:

    //使用當前對象作爲線程標記
        private void Do()
        {
            lock (this)
            {
                //所有在這個範圍內的代碼是線程安全的
            }
        }

然而,如果需要鎖定公共成員中的一段代碼,比較安全也比較推薦的方式是聲明私有的object成員作爲鎖標識,如上面的printer方法如果需要線程同步,可以修改爲:

    // Lock token.
        private object threadLock = new object();

        public void PrintNumbers()
        {
            lock (threadLock)
            {
                // Display Thread info.
                Console.WriteLine("-> {0} is executing PrintNumbers()",
                  Thread.CurrentThread.Name);

                // Print out numbers.
                Console.Write("Your numbers: ");
                for (int i = 0; i < 10; i++)
                {
                    Random r = new Random();
                    Thread.Sleep(100 * r.Next(5));
                    Console.Write("{0}, ", i);
                }
                Console.WriteLine();
            }
        }
    }
  • 3

此時在執行上面的printer代碼,每一次執行的結果都會相同。 
一旦一個線程進入鎖定範圍,在它退出鎖定範圍且釋放鎖定之前,其他線程將無法訪問鎖定標記。如果線程A獲得鎖定標記,直到它放棄這個鎖定標記,其他線程才能夠進入鎖定範圍。

使用Interlocked類型進行同步

.Net中並不是所有賦值和數值運算都是原子型操作。Interlocked允許我們原子型操作單個數據。

成員 作用
CompareExchange 安全地比較兩個值是否相等。如果相等,則替換其中一個值。
Decrement 以原子操作的形式遞減指定變量的值並存儲結果。
Exchange 以原子操作的形式,將對象設置爲指定的值並返回對原始對象的引用。
Increment 以原子操作的形式遞增指定變量的值並存儲結果。

使用TimerCallback編程

許多程序需要定期調用具體的方法,可以使用TimerCallback編程。

    class Program
    {
        static void PrintTime(object state)
        {
            Console.WriteLine("Time is: {0}",
              DateTime.Now.ToLongTimeString());
        }

        static void Main(string[] args)
        {
            Console.WriteLine("***** Working with Timer type *****\n");

            // Create the delegate for the Timer type.
            TimerCallback timeCB = new TimerCallback(PrintTime);

            // Establish timer settings.
            Timer t = new Timer(
              timeCB,     // The TimerCallback delegate type.
              "Hello From Main",       // Any info to pass into the called method (null for no info).
              0,          // Amount of time to wait before starting.
              1000);      // Interval of time between calls (in milliseconds).

            Console.WriteLine("Hit key to terminate...");
            Console.ReadLine();
        }

    }

線程池

    public class Printer
    {
        private object lockToken = new object();

        public void PrintNumbers()
        {
            lock (lockToken)
            {
                // Display Thread info.
                Console.WriteLine("-> {0} is executing PrintNumbers()",
                  Thread.CurrentThread.ManagedThreadId);

                // Print out numbers.
                Console.Write("Your numbers: ");
                for (int i = 0; i < 10; i++)
                {
                    Console.Write("{0}, ", i);
                    Thread.Sleep(1000);
                }
                Console.WriteLine();
            }
        }
    }

    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("***** Fun with the CLR Thread Pool *****\n");

            Console.WriteLine("Main thread started. ThreadID = {0}",
              Thread.CurrentThread.ManagedThreadId);

            Printer p = new Printer();

            WaitCallback workItem = new WaitCallback(PrintTheNumbers);

            // Queue the method 10 times
            for (int i = 0; i < 10; i++)
            {
                ThreadPool.QueueUserWorkItem(workItem, p);
            }

            Console.WriteLine("All tasks queued");
            Console.ReadLine();
        }

        static void PrintTheNumbers(object state)
        {
            Printer task = (Printer)state;
            task.PrintNumbers();
        }
    }

線程池的好處:

  • 線程池減少了線程創建、開始和停止的次數,提高了效率。
  • 使用線程池,能夠使我們將注意力放到業務邏輯上而不是多線程架構上。

但是線程池中的線程總是後臺線程,且它的優先級是默認的normal。


五,使用任務並行庫進行並行編程

使用TPL並行編程庫,可以構建細粒度的、可擴展的並行代碼,而不必直接與線程和線程池打交道。

TPL(Task Parallel Library),即任務並行庫,使用CLR線程池自動將應用程序的工作動態分配到可用的CPU中。TPL還處理工作分區、線程調度、狀態管理和其他低級別的細節操作。使用TPL可以最大限度地提升.Net應用程序的性能,並且避免直接操作線程所帶來的複雜性。

TPL通過Parallel類從線程池中爲我們提取線程(和管理併發)。

數據並行

Parallel類支持兩個主要的靜態方法——Parallel.For和Parallel.ForEach方法,這兩個方法以並行方式對數組或集合中的數據進行迭代

    class Program
    {
        // New Form level variable.
        private static CancellationTokenSource cancelToken = new CancellationTokenSource();

        static void Main(string[] args)
        {
            ProcessFiles();
            Console.WriteLine("ok");
            Console.ReadLine();
        }

        private static void ProcessFiles()
        {
            // Use ParallelOptions instance to store the CancellationToken
            ParallelOptions parOpts = new ParallelOptions();
            parOpts.CancellationToken = cancelToken.Token;
            parOpts.MaxDegreeOfParallelism = System.Environment.ProcessorCount;

            // Load up all *.jpg files, and make a new folder for the modified data.
            string[] files = Directory.GetFiles(@"C:\Users\Public\Pictures\Sample Pictures", "*.jpg",
                SearchOption.AllDirectories);
            string newDir = @"C:\ModifiedPictures";
            Directory.CreateDirectory(newDir);

            //  Process the image data in a parallel manner! 
            Parallel.ForEach(files, parOpts, currentFile =>
            {
                parOpts.CancellationToken.ThrowIfCancellationRequested();

                string filename = Path.GetFileName(currentFile);
                using (Bitmap bitmap = new Bitmap(currentFile))
                {
                    bitmap.RotateFlip(RotateFlipType.Rotate180FlipNone);
                    bitmap.Save(Path.Combine(newDir, filename));

                }
            }
            );
        }

    }
  •  
  •  

任務並行

TPL通過Parallel.Invoke方法觸發多個異步任務。

    class Program
    {
        static void Main(string[] args)
        {
            Process();
            Console.WriteLine("ok");
            Console.ReadLine();
        }

        private static void Process()
        {
            string theEBook = "電子書的內容";
            // Get the words from the e-book.
            string[] words = theEBook.Split(new char[] { ' ', '\u000A', ',', '.', ';', ':', '-', '?', '/' },
                StringSplitOptions.RemoveEmptyEntries);
            string[] tenMostCommon = null;
            string longestWord = string.Empty;

            Parallel.Invoke(
                () =>
                {
                    // Now, find the ten most common words.
                    tenMostCommon = FindTenMostCommon(words);
                },
                () =>
                {
                    // Get the longest word. 
                    longestWord = FindLongestWord(words);
                });

            // Now that all tasks are complete, build a string to show all
            // stats in a message box.
            StringBuilder bookStats = new StringBuilder("Ten Most Common Words are:\n");
            foreach (string s in tenMostCommon)
            {
                bookStats.AppendLine(s);
            }
            bookStats.AppendFormat("Longest word is: {0}", longestWord);
            bookStats.AppendLine();
            Console.WriteLine(bookStats.ToString());
        }

        private static string[] FindTenMostCommon(string[] words)
        {
            var frequencyOrder = from word in words
                                 where word.Length > 6
                                 group word by word into g
                                 orderby g.Count() descending
                                 select g.Key;

            string[] commonWords = (frequencyOrder.Take(10)).ToArray();
            return commonWords;
        }

        private static string FindLongestWord(string[] words)
        {
            return (from w in words orderby w.Length descending select w).FirstOrDefault();
        }

    }
  •  
  •  
  •  

六,工作者線程和I/O線程

對於線程所執行的任務來說,可以將線程任務分爲兩種類型:工作者(worker)線程和I/0線程。

工作者線程用來完成計算密集的任務,在任務的執行過程中,需要CPU不間斷地處理,所以,在工作者線程的執行過程中,CPU和線程的資源是充分利用的。

I/O線程典型的情況是用來完成輸入和輸出工作,在這種情況下,計算機需要通過I/O設備完成輸入和輸出任務。在處理過程中,CPU僅僅需要在任務開始的時候,將任務的參數傳遞給設備,然後啓動硬件設備即可。等到任務完成的時候,CPU收到一個通知,一般來說,是一個硬件的中斷信號,此時,CPU繼續後續的處理工作。

在處理的過程中,CPU是不必完全參與處理過程的,如果正在運行的線程不交出CPU的控制權,那麼,線程也只能處於等待狀態,在任務完成後纔會有事可做,此時,線程會處於等待狀態。即使操作系統將當前的CPU調度給其他的線程,此時形成所佔用的空間還將被使用,但是並沒有CPU在使用這個線程,可能出現線程資源浪費的問題。

如果我們的程序是一個網絡服務程序,針對一個網絡連接都使用一個線程進行管理,那麼,此時將會出現大量的線程在等待網絡通信,隨着網絡連接的不斷增加,處於等待狀態的線程將會很快消耗盡所有的內存資源。

線程是一個昂貴的資源,僅僅從內存的角度來說,每個線程就將佔用1M以上的內存,而且,初始化內存中的數據結構,包括在銷燬線程時的處理,都更加顯得線程是一個昂貴的資源。

針對這種情況,我們可以考慮使用少量的線程來管理大量的網絡連接,比如說,在啓動輸入輸出處理之後,只使用一個線程監控網絡通信的狀況,在這種情況下,需要進行網絡通信的線程在啓動通信開始之後,就已經可以結束了,也就是說,可以被系統回收了。在通信的傳輸階段,由於不需要CPU參與,可以沒有線程介入。監控線程將負責在信息到達之後,重新啓動一個計算密集的線程完成本地的處理工作。這樣帶來的好處就是將沒有線程處於等待狀態消耗有限的內存資源。

所以,對於I/O線程來說,可以將輸入輸出的操作分爲三個步驟:啓動、實際輸入輸出、處理結果。由於實際的輸入輸出可由硬件完成,並不需要CPU的參與,而啓動和處理結果也並不需要必須在同一個線程上進行。

爲了提高線程的利用效率,減少創建線程、銷燬線程所帶來的效率損失,同時也爲了能夠節約寶貴的內存,可以考慮創建一個線程池,提供線程的工廠服務,這樣,就沒有必要總是創建新的線程,而是當需要線程的時候從線程池中借出一個線程,當不再使用這個線程的時候,將這個線程歸還給線程池,以方便後繼的使用。


七,異步編程

C#的async關鍵字用來指定某個方法,Lambda表達式或匿名方法自動以異步的方式來調用,CLR會創建新的執行線程來處理任務。在調用async方法時,await關鍵字會自動暫停當前線程中任何其他活動,知道任務完成,離開調用線程,並繼續未完成的任務。

    class Helper
    {
        public async Task<string> DoWorkAsync()
        {
            return await Task.Run(() =>
            {
                Thread.Sleep(10000);
                return "Done with work!";
            });
        }

        //返回void的異步方法
        public async Task MethodReturningVoidAsync()
        {
            await Task.Run(() =>
            { /* Do some work here... */
                Thread.Sleep(4000);
            });
        }

        //具有多個await的異步方法
        public async void MutliAwait()
        {
            await Task.Run(() => { Thread.Sleep(2000); });
            Console.WriteLine("Done with first task!");

            await Task.Run(() => { Thread.Sleep(2000); });
            Console.WriteLine("Done with second task!");

            await Task.Run(() => { Thread.Sleep(2000); });
            Console.WriteLine("Done with third task!");
        }
    }

    class Program
    {
        static void Main(string[] args)
        {
            Test1();
            Test2();
            Test3();
            Console.WriteLine("ok");
            Console.ReadLine();
        }

        private static async void Test1()
        {
            Helper helper = new Helper();
            string str = await helper.DoWorkAsync();
            Console.WriteLine(str);
        }

        private static async void Test2()
        {
            Helper helper = new Helper();
            await helper.MethodReturningVoidAsync();
        }

        private static void Test3()
        {
            Helper helper = new Helper();
            helper.MutliAwait();
        }

    }
  •  
  •  

方法標記了async關鍵字,表示該方法可以作爲非阻塞式調用的成員。如果用async關鍵字修飾某個方法,但方法內部沒有一個await方法調用,那麼實際上仍將構建一個阻塞的、同步的方法調用,實際上回得到一個編譯器警告。


八,參考資料

《精通C#(第6版)》 
《ASP.NET本質論》

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章