談談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#開發人員都不能輕視它,必須熟練掌握。 雖然在一知半解的情況下也能寫多線程程序,寫的程序也能跑,但就是那些平時一切正常偶爾抽風一下的錯誤會讓頭痛不已。只有深刻了解多線程的內部原理,並遵循結構化的設計原則才能寫出健壯、優美的代碼。

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM