1. 程式人生 > >談談C#多執行緒開發:並行、併發與非同步程式設計

談談C#多執行緒開發:並行、併發與非同步程式設計

閱讀導航

一、使用Task

二、並行程式設計

三、執行緒同步

四、非同步程式設計模型

五、多執行緒資料安全

六、異常處理

 

概述

現代程式開發過程中不可避免會使用到多執行緒相關的技術,之所以要使用多執行緒,主要原因或目的大致有以下幾個:

1、 業務特性決定程式就是多工的,比如,一邊採集資料、一邊分析資料、同時還要實時顯示資料;

2、 在執行一個較長時間的任務時,不能阻塞UI介面響應,必須通過後臺執行緒處理;

3、 在執行批量計算密集型任務時,採用多執行緒技術可以提高執行效率。

傳統使用的多執行緒技術有:

  1. Thread & ThreadPool
  2. Timer
  3. BackgroundWorker

目前,這些技術都不再推薦使用了,目前推薦採用基於任務的非同步程式設計模型,包括並行程式設計和Task的使用。

 

一、使用Task:

大部分情況下,多執行緒的應用場景是在後臺執行一個較長時間的任務時,不能阻塞介面響應,同時,任務還是可以取消的。

下面我們實現一個簡單的示例功能:使用者點選Start按鈕時啟動一個任務,任務執行過程中通過進度條顯示任務進度,點選Stop按鈕結束任務。

    public partial class Form1 : Form
    {
        private volatile bool CancelWork = false;

        public Form1()
        {
            InitializeComponent();
        }

        private void btnStart_Click(object sender, EventArgs e)
        {
            this.btnStart.Enabled = false;
            this.btnStop.Enabled = true;

            CancelWork = false;
            Task.Run(() => WorkThread());
        }

        private void btnStop_Click(object sender, EventArgs e)
        {
            CancelWork = true;
        }

        private void WorkThread()
        {
            for (int i = 0; i < 100; i++)
            {
                this.Invoke(new Action(() =>
                {
                    this.progressBar.Value = i;
                }));

                Thread.Sleep(1000);

                if(CancelWork)
                {
                    break;
                }
            }

            this.Invoke(new Action(() =>
            {
                this.btnStart.Enabled = true;
                this.btnStop.Enabled = false;
            }));            
        }
    }
View Code

 這個程式碼寫的中規中矩,沒什麼特別的地方,僅僅是用Tsak取代了早期經常採用的Thread、ThreadPool等,雖然Task內部也是對ThreadPool的封裝,但仍然建議儘量採用TASK來實現多工。

 注意:雖然可以通過程式碼強行結束一個任務,但強烈建議不要這樣做,應該給它一個通知讓其自己結束。

 

二、並行程式設計:

目標:通過一個計算素數的方法,迴圈計算並打印出10000以內的素數。

計算一個數是否素數的方法:

        private static bool IsPrimeNumber(int number)
        {
            if (number < 1)
            {
                return false;
            }

            if (number == 1 && number == 2)
            {
                return true;
            }

            for (int i = 2; i < number; i++)
            {
                if (number % i == 0)
                {
                    return false;
                }
            }

            return true;
        }
View Code

如果不採用並行程式設計,常規實現方法:

            for (int i = 1; i <= 10000; i++)
            {
                bool b = IsPrimeNumber(i);             
                Console.WriteLine($"{i}:{b}");
            }

採用並行程式設計方法:

           Parallel.For(1, 10000, x=> 
           {
                bool b = IsPrimeNumber(x);              
                Console.WriteLine($"{i}:{b}");
            });

執行程式發現時間差異並不大,主要原因是瓶頸在列印控制檯上面,去掉列印程式碼,只保留計算程式碼,就可以看出效能差異。

Parallel實際是通過執行緒池進行任務的分配,執行緒池的最小執行緒數和最大執行緒數將影響到整個程式的效能,需要合理設定。(最小執行緒預設為8。)

            ThreadPool.SetMinThreads(10, 10);
            ThreadPool.SetMaxThreads(20, 20);

 按照上述設定,假設執行緒任務耗時比較長不能很快結束。在啟動前面10個執行緒時速度很快,第10~20個執行緒就比較慢一點,大約0.5秒,到達20個執行緒以後,如果前期任務沒有結束就不能繼續分配任務了。

和Task類似,Parallel類仍然是對ThreadPool的封裝,但Parallel有一個優勢,它能知道所有任務是否完成,如果採用執行緒池來實現批量任務,我們需要自己通過計數的方式確定所有子任務是否全部完成。

 Parallel類還有一個ForEach方法,使用和For類似,就不重複描述了。

 

三、 執行緒(或任務)同步

有時我們需要通知一個任務結束,或一個任務等待某個條件進入下一個狀態,這就需要用到任務同步的技術。

一個比較簡單的方法就是定義一個變數來表示狀態。

      private volatile bool CancelWork = false;

後臺任務可以輪詢該變數進行判斷:

            for (int i = 0; i < 100; i++)
            { 
                if(CancelWork)
                {
                    break;
                }
            }

 這是我們常用的方法,可以稱為執行緒狀態機同步(雖然只有兩個狀態)。需要注意的是在通過輪詢去讀取狀態時,迴圈體內至少應該有1ms的Sleep,不然CPU會很高。

執行緒同步還有一個比較好的辦法就是採用ManualResetEvent 和AutoResetEvent :

   public partial class Form1 : Form
    {  
        private ManualResetEvent manualResetEvent = new ManualResetEvent(false);

        public Form1()
        {
            InitializeComponent();
        }

        private void btnStart_Click(object sender, EventArgs e)
        {
            this.btnStart.Enabled = false;
            this.btnStop.Enabled = true;

            manualResetEvent.Reset();
            Task.Run(() => WorkThread());
        }

        private void btnStop_Click(object sender, EventArgs e)
        {
            manualResetEvent.Set();
        }

        private void WorkThread()
        {
            for (int i = 0; i < 100; i++)
            {
                this.Invoke(new Action(() =>
                {
                    this.progressBar.Value = i;
                }));

               if(manualResetEvent.WaitOne(1000))
                {
                    break;
                }
            }

            this.Invoke(new Action(() =>
            {
                this.btnStart.Enabled = true;
                this.btnStop.Enabled = false;
            }));            
        }
    }
View Code

採用WaitOne來等待比通過Sleep進行延時要更好,因為當執行manualResetEvent.WaitOne(1000)時,如果manualResetEvent沒有呼叫Set,該方法在等待1000ms後返回false,如果期間呼叫了manualResetEvent的Set方法,該方法會立即返回true,不用等待剩下的時間。

採用這種同步方式優於採用通過內部欄位變數進行同步的方式,另外儘量採用ManualResetEvent 而不是AutoResetEvent 。

 

四、非同步程式設計模型(await、async)

假設我們要實現一個簡單的功能:當點選啟動按鈕時,執行一個任務,任務結束時要報告是否成功,如果成功就顯示綠色圖示、如果失敗就顯示紅色圖示,1秒後圖標顏色恢復為白色;任務執行期間啟動按鈕要不可用。

 我寫了相關程式碼:

    public partial class Form1 : Form
    {
        private void btnStart_Click(object sender, EventArgs e)
        {
            this.btnStart.Enabled = false;

            if(DoSomething())
            {
                this.picShow.BackColor = Color.Green;
            }
            else
            {
                this.picShow.BackColor = Color.Red;
            }

            Thread.Sleep(1000);
           
            this.picShow.BackColor = Color.White;
            this.btnStart.Enabled = true;
        }

        private bool DoSomething()
        {
            Thread.Sleep(5000);
            return true;
        }
    }
View Code

 這段程式碼邏輯清晰、條理清楚,一看就能明白,但存在兩個問題:

1、執行期間UI執行緒阻塞了,使用者介面沒有響應;

2、根本不能實現需求,點選啟動後,程式卡死6秒種,也沒有看到顏色變化,因為UI執行緒已經阻塞,當重新獲得控制代碼時圖示已經是白色了。

為了實現需求,我們改用多工來實現相關功能:

    public partial class Form1 : Form
    {
        public Form1()
        {
            InitializeComponent();
        }

        private void btnStart_Click(object sender, EventArgs e)
        {
            this.btnStart.Enabled = false;

            Task.Run(() => 
            {  
                if (DoSomething())
                {
                    this.Invoke(new Action(() =>
                    {
                        this.picShow.BackColor = Color.Green;
                    }));                   
                }
                else
                {
                    this.Invoke(new Action(() =>
                    {
                        this.picShow.BackColor = Color.Red;
                    }));                   
                }

                Thread.Sleep(1000);

                this.Invoke(new Action(() =>
                {
                    this.btnStart.Enabled = true;
                    this.picShow.BackColor = Color.White;
                }));               
            });           
        }

        private bool DoSomething()
        {
            Thread.Sleep(5000);
            return true;
        }
    }
View Code

 以上程式碼完全實現了最初的需求,但有幾個不完美的地方:

1、主執行緒的btnStart_Click方法除了啟動一個任務以外,啥事也沒幹;

2、由於非UI執行緒不能訪問UI控制元件,程式碼裡有很多Invoke,比較醜陋;

3、介面邏輯和業務邏輯摻和在一起,使得程式碼難以理解。

採用C#的非同步程式設計模型,通過使用await、async關鍵字,可以更好地實現上述需求。 

    public partial class Form1 : Form
    {
        public Form1()
        {
            InitializeComponent();
        }

        private async void btnStart_ClickAsync(object sender, EventArgs e)
        {
            this.btnStart.Enabled = false;

            var result = await DoSomethingAsync();
            if(result)
            {
                this.picShow.BackColor = Color.Green;
            }
            else
            {
                this.picShow.BackColor = Color.Red;
            }

            await Task.Delay(1000);
            
            this.picShow.BackColor = Color.White;
            this.btnStart.Enabled = true;
        }

        private async Task<bool> DoSomethingAsync()
        {
            await Task.Run(() =>
            {
                Thread.Sleep(5000);                
            });
            return true;
        }
    }
View Code

這段程式碼看起來就像是同步程式碼,其業務邏輯是如此的清晰優雅,讓人一目瞭然,關鍵是它還不阻塞執行緒,UI正常響應。

可以看到,通過使用await關鍵字,我們可以專注於業務功能實現,特別是後續任務需要前序任務的返回值的情況下,可以大量減少任務之間的同步操作,程式碼的可讀性也大大增強。

 

五、 多執行緒環境下的資料安全

目標:我們要向一個字典加入一些資料項,為了增加效率,我們使用了多個執行緒。

       private async static void Test1()
        {
            Task.Run(() => AddData());
            Task.Run(() => AddData());
            Task.Run(() => AddData());
            Task.Run(() => AddData()); 
        }

        private static void AddData()
        {
            for (int i = 0; i < 100; i++)
            {
                if(!Dic.ContainsKey(i))
                {
                    Dic.Add(i, i.ToString());
                }

                Thread.Sleep(50);
            }
        }
View Code

向字典重複加入同樣的關鍵字會引發異常,所以在增加資料前我們檢查一下是否已經包含該關鍵字。以上程式碼看似沒有問題,但有時還是會引發異常:“已添加了具有相同鍵的項。”原因在於我們在檢查是否包含該Key時是不包含的,但在新增時其他執行緒加入了同樣的KEY,當前執行緒再增加就報錯了。

【注意:也許你多次執行上述程式都能順利執行,不報異常,但還是要清楚認識到上述程式碼是有問題的!畢竟,程式在大部分情況下都執行正常,偶爾報一次故障才是最頭疼的事情。】

上述問題傳統的解決方案就是增加鎖機制。對於核心的修改程式碼通過鎖來確保不會重入。

        private object locker4Add=new object();
        private static void AddData()
        {
            for (int i = 0; i < 100; i++)
            {
                lock (locker4Add)
                {
                    if (!Dic.ContainsKey(i))
                    {
                        Dic.Add(i, i.ToString());
                    }
                }

                Thread.Sleep(50);
            }
        }

以上程式碼可以解決問題,但不是最佳方案。更好的方案是使用執行緒安全的容器:ConcurrentDictionary。

        private static ConcurrentDictionary<int, string> Dic = new ConcurrentDictionary<int, string>();
      
        private async static void Test1()
        {
            Task.Run(() => AddData());
            Task.Run(() => AddData());
            Task.Run(() => AddData());
            Task.Run(() => AddData());   
        }

        private static void AddData()
        {
            for (int i = 0; i < 100; i++)
            {
                Dic.TryAdd(i, i.ToString());
                Thread.Sleep(50);
            }
        }
View Code

 

你可以在新增前繼續檢查一下容器是否已經包含該Key,你也可以不用檢查,TryAdd方法確保不會重複新增且不會產生異常。

 剛才是多個執行緒同時寫某個物件,如果就單個執行緒寫物件,其他多個執行緒僅僅是消費(訪問)物件,是否可以使用非執行緒安全的容器呢?

 基本上來說多個執行緒讀取一個物件是沒有太大問題的,但還是會存在一些要注意的地方:

1、對於常用的List,在對其進行foreach時List物件不能被修改,不僅不能Remove,Add也不可以;否則會報一個異常:異常資訊:”集合已修改;可能無法執行列舉操作。”

2、還有一個類似的問題 就是呼叫Dictionary的ToList方法時有時會報錯,將Dictionary 型別改成ConcurrentDictionary型別,問題依然存在,其原因是ToList會讀取字典的Count,建立相關大小的區域後執行復制,而此時字典的長度增加了。

以上只是描述了多執行緒資料訪問的兩個小例子,實際使用中相關的問題一定會遠遠不止這些,多執行緒程式的大部分異常都是因為資源競爭引起的(包括死鎖),一定要小心處理。

 

六、多執行緒的異常處理

(一) 異常處理的幾個基本原則

1、 基本原則:不要輕易捕獲根異常;

2、 元件或控制元件丟擲異常時可以根據需要自定義一些異常,不要丟擲根異常,可以直接使用的常用異常有:FormatException、IndexOutOfRangException、InvalidOperationException、InvalidEnumArgumentException ;沒有合適的就自定義;

3、 使用者自定義異常從ApplicationException繼承;

4、 多執行緒的內部異常不會傳播到主執行緒,應該在內部進行處理,可以通過事件推到主執行緒來;

5、應用程式層面可以捕獲根異常,做一些記錄工作,切不可隱匿異常。 

(二) 異常處理方案(基於WPF實現)

主執行緒的異常處理:

捕獲你知道的異常,並自行處理,但不要輕易捕獲根異常,下面的程式碼令人深惡痛絕:

            try
            {
                DoSomething();                
            }
            catch(Exception)
            {
                //Do Nothing
            }

 當然,如果你確定有能力捕獲根異常,並且是業務邏輯的一部分,可以捕獲根異常 :

            try
            {
                DoSomething();
                MessageBox.Show("OK");                
            }
            catch(Exception ex)
            {
                MessageBox.Show($"ERROR:{ex.Message}");
            }

  

可等待非同步任務的異常處理:

可等待的任務內的異常是可以傳遞到呼叫者執行緒的,可以按照主執行緒異常統一處理:

            try
            {
                await DoSomething();                                
            }
            catch(FormatException ex)
            {
                //Do Something
            }

  

Task任務內部異常處理:

非可等待的Task任務內部異常是無法傳遞到呼叫者執行緒的,參考下面程式碼:

            try
            {
                Task.Run(() =>
                {
                    string s = "aaa";
                    int i = int.Parse(s);
                });
            }
            catch (FormatException ex)
            {
                MessageBox.Show("Error");
            }

 上面程式碼不會實現你期望的效果,它只會造成程式的崩潰。(有時候不會立即崩潰,後面會有解釋)

處理辦法有兩個:

1、自行處理:(1)處理可以預料的異常,(2)同時處理根異常(寫日誌等),也可以不處理根異常,後面統一處理;

2、或將異常包裝成事件推送到主執行緒,交給主執行緒處理。

public partial class FormSync : Form
{ 
    private event EventHandler<UnhangdledExceptionArgs> UnhandledExceptionCatched;

    private void Form_Load()
    {
       UnhandledExceptionCatched += MainWindow_UnhandledExceptionCatched;
    }
    private void MainWindow_UnhandledExceptionCatched(object sender, UnhangdledExceptionArgs e)
    {          
        MessageBox.Show($"Catch  Exception:{e.InnerException.Message}");
    }

   private  void Thread1()
   { 
        Task.Run(()=>
        { 
            try
            {
                throw new ApplicationException("Thread Exception");
            }
            catch (Exception ex)
            {                
                UnhangdledExceptionArgs args = new UnhangdledExceptionArgs()
                {
                    InnerException = ex
                };
                UnhandledExceptionCatched?.Invoke(null, args);
            }
        });
     }
}

public class UnhangdledExceptionArgs : EventArgs
{
    public Exception InnerException { get; set; }
}
View Code

  

Thread和ThreadPool內部異常:

雖然不推薦使用Thread,如果實在要用,其處理原則和上述普通Task任務內部異常處理方案一致。

 

全域性未處理異常的處理:

雖然我們不推薦catch根異常,但如果一旦發生未知異常程式就崩潰,客戶恐怕難以接受吧,如果要求所有業務模組都處理根異常並進行儲存日誌、彈出訊息等操作又非常繁瑣,所以,處理的思路是業務模組不處理根異常,但應用程式要對未處理異常進行統一處理。

    public partial class App : Application
    {
        App()
        {              
            this.Startup += App_Startup;
        }

        private void App_Startup(object sender, StartupEventArgs e)
        { 
            this.DispatcherUnhandledException += App_DispatcherUnhandledException;                     
            AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException;
            TaskScheduler.UnobservedTaskException += TaskScheduler_UnobservedTaskException;        
        }

        //主執行緒未處理異常
        private void App_DispatcherUnhandledException(object sender, System.Windows.Threading.DispatcherUnhandledExceptionEventArgs e)
        {
            DoSomething(e.Exception);
            e.Handled = true;
        }

        //未處理執行緒異常(如果主執行緒未處理異常已經處理,該異常不會觸發)
        private void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e)
        {
            if (e.ExceptionObject is Exception ex)
            {
                DoSomething(ex);
            }
        }

        //未處理的Task內異常
        private void TaskScheduler_UnobservedTaskException(object sender, UnobservedTaskExceptionEventArgs e)
        {
            DoSomething(e.Exception);
        }

        //儲存、顯示異常資訊
        private void ProcessException(Exception exception)
        { 
            //儲存日誌
            //提醒使用者
        }
    }

解釋一下:

1、 當主執行緒發生未處理異常時會觸發App_DispatcherUnhandledException事件,在該事件中如果設定e.Handled = true,那麼系統不會崩潰,如果沒有設定e.Handled = true,會繼續觸發CurrentDomain_UnhandledException事件(畢竟主執行緒也是執行緒),而CurrentDomain_UnhandledException事件和TaskScheduler_UnobservedTaskException事件觸發後,作業系統都會強行關閉這個應用程式。所以我們應該在App_DispatcherUnhandledException事件中設定e.Handled = true。

2、Thread執行緒異常會觸發CurrentDomain_UnhandledException事件,導致系統崩潰,所以建議儘量不要使用Thread和ThreadPool。

3、非可等待的Task內部異常會觸發TaskScheduler_UnobservedTaskException事件,導致系統崩潰,所以建議Task內部自行處理根異常或將異常封裝為事件推到主執行緒。需要額外注意一點:Task內的未處理異常不會被立即觸發事件,而是要延遲到GC執行回收的時候才觸發,這使得問題更復雜,需要小心處理。

 

總之

當前,非同步程式設計模型已經是.NET框架的基本功能了,特別是WEB開發,後臺程式碼已經全面非同步化了,所以每個C#開發人員都不能輕視它,必須熟練掌握。 雖然在一知半解的情況下也能寫多執行緒程式,寫的程式也能跑,但就是那些平時一切正常偶爾抽風一下的錯誤會讓頭痛不已。只有深刻了解多執行緒的內部原理,並遵循結構化的設計原則才能寫出健壯、優美的程式碼。