逐步解說:使用 Visual C# 撰寫簡單的多執行緒元件

BackgroundWorker 元件會取代並加入功能至 System.Threading 命名空間;不過,您可以依選擇為回溯相容性 (Backward Compatibility) 和未來使用而保留 System.Threading 命名空間。 如需詳細資訊,請參閱 BackgroundWorker 元件概觀

您可寫入能夠同時執行多個工作的應用程式。 這種能力稱為「多執行緒處理」或「無限制執行緒」,對於設計耗用大量處理器資源並需使用者輸入的元件是一項強大的工具。 計算薪資資訊的元件就是利用多執行緒處理的元件範例。 這類元件可在一執行緒上處理由使用者輸入資料庫的資料,而同時又在另一個執行緒上執行需要大量處理器資源的薪資計算。 由於這些處理程序是在不同的執行緒上執行,因此使用者不需要等到電腦完成計算後才輸入其他資料。 在這個逐步解說中,您將建立可同時執行數項複雜計算的簡單多執行緒元件。

建立專案

您的應用程式將包括一個單一表單和一個元件。 使用者將輸入值和通知元件開始計算的信號。 接著,表單會從元件接收值,並將它們顯示在標籤控制項中。 元件會執行需佔用大量處理器的計算,並在計算完成時通知表單。 您將在元件中建立公用變數,用來存放從使用者介面接收的值。 您也將在您的元件中實作方法來根據這些變數值執行計算。

注意事項注意事項

雖然通常函式要比方法適合計算值,但引數無法在執行緒之間傳遞,也無法傳回值。有許多簡單的方法可將值提供給執行緒並從其接收值。在這個範例中,您將會藉由更新公用變數的方式,將值傳回至使用者介面,而且當執行緒執行完成時,將會使用事件來通知主程式。

根據您目前使用的設定或版本,您所看到的對話方塊與功能表指令可能會與 [說明] 中描述的不同。若要變更設定,請從 [工具] 功能表中選擇 [匯入和匯出設定]。如需詳細資訊,請參閱 Visual Studio 中的自訂開發設定

若要建立表單

  1. 建立新的 [Windows 應用程式] 專案。

  2. 為應用程式 Calculations 命名,並將 Form1.cs 重新命名為 frmCalculations.cs。 當 Visual Studio 提示您重新命名 Form1 程式碼項目時,請按一下 []。

    這個表單將當做應用程式的主要使用者介面。

  3. 在表單中加入五個 Label 控制項、四個 Button 控制項以及一個 TextBox 控制項。

  4. 將這些控制項的屬性設定如下:

    控制項

    名稱

    文字

    label1

    lblFactorial1

    (空白)

    label2

    lblFactorial2

    (空白)

    label3

    lblAddTwo

    (空白)

    label4

    lblRunLoops

    (空白)

    label5

    lblTotalCalculations

    (空白)

    button1

    btnFactorial1

    Factorial

    button2

    btnFactorial2

    Factorial - 1

    button3

    btnAddTwo

    Add Two

    button4

    btnRunLoops

    Run a Loop

    textBox1

    txtValue

    (空白)

若要建立 Calculator 元件

  1. 從 [專案] 功能表中選取 [加入元件]。

  2. 將這個元件命名為 Calculator。

若要將公用變數加入至 Calculator 元件

  1. 開啟 Calculator 的 [程式碼編輯器]。

  2. 加入陳述式以建立公用變數,用來傳遞 frmCalculations 的值至每個執行緒。

    變數 varTotalCalculations 將會記錄元件所執行計算的執行總數,其他變數則會接收表單的值。

    public int varAddTwo; 
    public int varFact1;
    public int varFact2;
    public int varLoopValue;
    public double varTotalCalculations = 0;
    

若要將方法和事件加入至 Calculator 元件

  1. 宣告元件將用來傳遞數值給表單的事件委派 (Delegate)。

    注意事項注意事項

    雖然您將宣告四個事件,但只需建立三個委派,因為其中兩個事件將具有相同的簽章 (Signature)。

    在上一個步驟中輸入的變數宣告後面緊接輸入下列程式碼:

    // This delegate will be invoked with two of your events.
    public delegate void FactorialCompleteHandler(double Factorial, double TotalCalculations);
    public delegate void AddTwoCompleteHandler(int Result, double TotalCalculations);
    public delegate void LoopCompleteHandler(double TotalCalculations, int Counter);
    
  2. 宣告您元件將用來與您應用程式通訊的事件。 方式是在上一個步驟中輸入的程式碼後面緊接加入下列程式碼。

    public event FactorialCompleteHandler FactorialComplete;
    public event FactorialCompleteHandler FactorialMinusOneComplete;
    public event AddTwoCompleteHandler AddTwoComplete;
    public event LoopCompleteHandler LoopComplete;
    
  3. 在您上一個步驟中輸入的程式碼後面緊接輸入下列程式碼:

    // This method will calculate the value of a number minus 1 factorial
    // (varFact2-1!).
    public void FactorialMinusOne()
    {
       double varTotalAsOfNow = 0;
       double varResult = 1;
       // Performs a factorial calculation on varFact2 - 1.
       for (int varX = 1; varX <= varFact2 - 1; varX++)
       {
          varResult *= varX;
          // Increments varTotalCalculations and keeps track of the current 
          // total as of this instant.
          varTotalCalculations += 1;
          varTotalAsOfNow = varTotalCalculations;
       }
       // Signals that the method has completed, and communicates the 
       // result and a value of total calculations performed up to this 
       // point.
       FactorialMinusOneComplete(varResult, varTotalAsOfNow);
    }
    
    // This method will calculate the value of a number factorial.
    // (varFact1!)
    public void Factorial()
    {
       double varResult = 1;
       double varTotalAsOfNow = 0;
       for (int varX = 1; varX <= varFact1; varX++)
       {
          varResult *= varX;
          varTotalCalculations += 1;
          varTotalAsOfNow = varTotalCalculations;
       }
       FactorialComplete(varResult, varTotalAsOfNow);
    }
    
    // This method will add two to a number (varAddTwo+2).
    public void AddTwo()
    {
       double varTotalAsOfNow = 0;  
       int varResult = varAddTwo + 2;
       varTotalCalculations += 1;
       varTotalAsOfNow = varTotalCalculations;
       AddTwoComplete(varResult, varTotalAsOfNow);
    }
    
    // This method will run a loop with a nested loop varLoopValue times.
    public void RunALoop()
    {
       int varX;
       double varTotalAsOfNow = 0;
       for (varX = 1; varX <= varLoopValue; varX++)
       {
        // This nested loop is added solely for the purpose of slowing down
        // the program and creating a processor-intensive application.
          for (int varY = 1; varY <= 500; varY++)
          {
             varTotalCalculations += 1;
             varTotalAsOfNow = varTotalCalculations;
          }
       }
       LoopComplete(varTotalAsOfNow, varLoopValue);
    }
    

將使用者的輸入傳輸至元件

下一步是將程式碼加入至 frmCalculations 以接收使用者的輸入,以及將值傳送至 Calculator 元件並從其中接收值。

若要將前端功能實作至 frmCalculations

  1. 在 [程式碼編輯器] 中開啟 frmCalculations。

  2. 找出 public partial class frmCalculations 陳述式。 在 { 下面緊接著輸入:

    Calculator Calculator1;
    
  3. 找出建構函式。 緊接著在 } 的前面加入下列程式碼:

    // Creates a new instance of Calculator.
    Calculator1 = new Calculator();
    
  4. 在設計工具中按一下每個按鈕,為每個控制項的 Click 事件處理常式產生程式碼大綱,並加入程式碼來建立處理常式。

    完成時,您的 Click 事件處理常式看起來應該如下列所示:

    // Passes the value typed in the txtValue to Calculator.varFact1.
    private void btnFactorial1_Click(object sender, System.EventArgs e)
    {
       Calculator1.varFact1 = int.Parse(txtValue.Text);
       // Disables the btnFactorial1 until this calculation is complete.
       btnFactorial1.Enabled = false;
       Calculator1.Factorial();
    }
    
    private void btnFactorial2_Click(object sender, System.EventArgs e)
    {
       Calculator1.varFact2 = int.Parse(txtValue.Text);
       btnFactorial2.Enabled = false;
       Calculator1.FactorialMinusOne();
    }
    private void btnAddTwo_Click(object sender, System.EventArgs e)
    {
       Calculator1.varAddTwo = int.Parse(txtValue.Text);
       btnAddTwo.Enabled = false;
       Calculator1.AddTwo();
    }
    private void btnRunLoops_Click(object sender, System.EventArgs e)
    {
       Calculator1.varLoopValue = int.Parse(txtValue.Text);
       btnRunLoops.Enabled = false;
       // Lets the user know that a loop is running
       lblRunLoops.Text = "Looping";
       Calculator1.RunALoop();
    }
    
  5. 在上一個步驟中加入的程式碼後面,輸入下列程式碼來處理您的表單將從 Calculator1 接收的事件:

    private void FactorialHandler(double Value, double Calculations)
    // Displays the returned value in the appropriate label.
    {
       lblFactorial1.Text = Value.ToString();
       // Re-enables the button so it can be used again.
       btnFactorial1.Enabled = true;
       // Updates the label that displays the total calculations performed
       lblTotalCalculations.Text = "TotalCalculations are " + 
          Calculations.ToString();
    }
    
    private void FactorialMinusHandler(double Value, double Calculations)
    {
       lblFactorial2.Text = Value.ToString();
       btnFactorial2.Enabled = true;
       lblTotalCalculations.Text = "TotalCalculations are " + 
          Calculations.ToString();
    }
    
    private void AddTwoHandler(int Value, double Calculations)
    {
       lblAddTwo.Text = Value.ToString();
       btnAddTwo.Enabled = true;
       lblTotalCalculations.Text = "TotalCalculations are " +
          Calculations.ToString();
    }
    
    private void LoopDoneHandler(double Calculations, int Count)
    {
       btnRunLoops.Enabled = true;
       lblRunLoops.Text = Count.ToString();
       lblTotalCalculations.Text = "TotalCalculations are " +
          Calculations.ToString();
    }
    
  6. 在 frmCalculations 的建構函式中,緊接著在 } 這一行的前面加入下列程式碼,以便處理您的表單將從 Calculator1 接收的自訂事件。

    Calculator1.FactorialComplete += new
       Calculator.FactorialCompleteHandler(this.FactorialHandler);
    Calculator1.FactorialMinusOneComplete += new
       Calculator.FactorialCompleteHandler(this.FactorialMinusHandler);
    Calculator1.AddTwoComplete += new
       Calculator.AddTwoCompleteHandler(this.AddTwoHandler);
    Calculator1.LoopComplete += new
       Calculator.LoopCompleteHandler(this.LoopDoneHandler);
    

測試應用程式

您現在已建立專案,其中包含能夠執行數個複雜計算的表單和元件。 雖然您尚未實作多執行緒功能,不過您將在繼續之前先測試您的專案來驗證其功能。

若要測試您的專案

  1. 從 [偵錯] 功能表中選擇 [開始偵錯]。

    接著會啟動應用程式並出現 frmCalculations。

  2. 在文字方塊中輸入 4,接著按一下標示為 [Add Two] 的按鈕。

    數字 "6" 應該出現在它下方的標籤中,而 "Total Calculations are 1" 則應顯示在 lblTotalCalculations 中。

  3. 現在按一下標示為 [Factorial - 1] 的按鈕。

    數字 "6" 應該出現在按鈕之下,而 lblTotalCalculations 現在則顯示為 "Total Calculations are 4"。

  4. 將文字方塊中的值變更為 20,接著按一下標示為 [Factorial] 的按鈕。

    數字 "2.43290200817664E+18" 會出現在下面,而 lblTotalCalculations 現在則顯示為 "Total Calculations are 24"。

  5. 將文字方塊中的值變更為 50000,然後按一下 [Run a Loop] 按鈕。

    請注意,在重新啟用這個按鈕之前,會間隔一小段時間。 這個按鈕之下的標籤應顯示 "50000",而顯示的總計計算為 "25000024"。

  6. 將文字方塊中的值變更為 5000000,然後按一下標示為 [Run A Loop] 的按鈕,緊接著按一下標示為 [Add Two] 的按鈕。 再按一下這個按鈕。

    在迴圈完成之前,這個按鈕不會回應,而且表單上的任何控制項也都不會回應。

    如果您的程式只執行單一執行緒,那麼像是上面範例中需要大量處理器資源的計算就可能會佔用整個程式,直到計算完成為止。 在接下來的章節中,您會將多執行緒功能加入您的應用程式,如此一來多個執行緒就可同時執行。

加入多執行緒功能

先前的範例示範了只執行單一執行緒的應用程式的限制。 在接下來的章節中,您將使用 Thread 類別物件,將執行的多個執行緒加入至元件。

若要加入 Threads 副程式

  1. 在 [程式碼編輯器] 中開啟 Calculator.cs

  2. 在接近程式碼頂端處,找出類別宣告,接著在 { 後面緊接著輸入下列程式碼:

    // Declares the variables you will use to hold your thread objects.
    public System.Threading.Thread FactorialThread; 
    public System.Threading.Thread FactorialMinusOneThread;  
    public System.Threading.Thread AddTwoThread; 
    public System.Threading.Thread LoopThread;
    
  3. 在程式碼底部的類別宣告之前緊接加入以下方法:

    public void ChooseThreads(int threadNumber)
    {
    // Determines which thread to start based on the value it receives.
    switch(threadNumber)
       {
          case 1:
             // Sets the thread using the AddressOf the subroutine where
             // the thread will start.
             FactorialThread = new System.Threading.Thread(new
                System.Threading.ThreadStart(this.Factorial));
             // Starts the thread.
             FactorialThread.Start();
             break;
          case 2:
             FactorialMinusOneThread = new
                System.Threading.Thread(new
                   System.Threading.ThreadStart(this.FactorialMinusOne));
             FactorialMinusOneThread.Start();
             break;
          case 3:
             AddTwoThread = new System.Threading.Thread(new
                 System.Threading.ThreadStart(this.AddTwo));
             AddTwoThread.Start();
             break;
          case 4:
             LoopThread = new System.Threading.Thread(new
                System.Threading.ThreadStart(this.RunALoop));
             LoopThread.Start();
             break;
       }
    }
    

    Thread 具現化 (Instantiated) 時,它需要 ThreadStart 形式的引數。 ThreadStart 物件是委派,會指向執行緒開始所在的方法位址。 ThreadStart 物件無法接受參數或傳遞值,因此只能表示 void 方法。 您剛才實作的 ChooseThreads 方法將會從呼叫方法的程式接收值,並使用該值來決定開始的適當執行緒。

若要將適當的程式碼加入至 frmCalculations

  1. 在 [程式碼編輯器] 中開啟 [frmCalculations.cs] 檔案,然後找出 private void btnFactorial1_Click。

    1. 直接註解呼叫 Calculator1.Factorial1 方法的程式碼行,如下所示:

      // Calculator1.Factorial()
      
    2. 加入下列程式碼行以呼叫 Calculator1.ChooseThreads 方法:

      // Passes the value 1 to Calculator1, thus directing it to start the 
      // correct thread.
      Calculator1.ChooseThreads(1);
      
  2. 對其他的 button_click 方法進行類似的修改。

    注意事項注意事項

    請確定 Threads 引數包含適當的值。

    當您完成時,您的程式碼應該像這樣:

    private void btnFactorial1_Click(object sender, System.EventArgs e)
    // Passes the value typed in the txtValue to Calculator.varFact1
    {
       Calculator1.varFact1 = int.Parse(txtValue.Text);
       // Disables the btnFactorial1 until this calculation is complete
       btnFactorial1.Enabled = false;
       // Calculator1.Factorial();
       Calculator1.ChooseThreads(1);
    }
    
    private void btnFactorial2_Click(object sender, System.EventArgs e)
    {
       Calculator1.varFact2 = int.Parse(txtValue.Text); 
       btnFactorial2.Enabled = false;         
       // Calculator1.FactorialMinusOne();
       Calculator1.ChooseThreads(2);
    }
    private void btnAddTwo_Click(object sender, System.EventArgs e)
    {
       Calculator1.varAddTwo = int.Parse(txtValue.Text);
       btnAddTwo.Enabled = false;
       // Calculator1.AddTwo();
       Calculator1.ChooseThreads(3);
    }
    
    private void btnRunLoops_Click(object sender, System.EventArgs e)
    {
       Calculator1.varLoopValue = int.Parse(txtValue.Text);
       btnRunLoops.Enabled = false;
       // Lets the user know that a loop is running
       lblRunLoops.Text = "Looping";
       // Calculator1.RunALoop();
       Calculator1.ChooseThreads(4);
    }
    

控制項的封送處理呼叫

您現在將加速更新表單上的顯示。 由於控制項都是由主執行緒所擁有的,所以從副程式執行緒呼叫控制項都需要「封送處理」呼叫。 封送處理 (Marshaling) 是指將呼叫移過執行緒界限的動作,就資源而言,非常昂貴。 為將所需封送處理量減至最低,同時為了確定您的呼叫是以安全執行緒的方式處理,您將使用 Control.BeginInvoke 方法來叫用執行的主執行緒上的方法,藉此將必須進行的跨執行緒界線封送處理量減至最低。 當呼叫操作控制項的方法時,這種呼叫是必要的。 如需詳細資訊,請參閱 如何:管理執行緒的控制項

若要建立控制項叫用的程序

  1. 為 frmCalculations 開啟 [程式碼編輯器]。 在宣告區段中,加入下列程式碼:

    public delegate void FHandler(double Value, double Calculations);
    public delegate void A2Handler(int Value, double Calculations);
    public delegate void LDHandler(double Calculations, int Count);
    

    InvokeBeginInvoke 都需要有適當方法的委派做為引數。 這些程式碼行會宣告委派簽章,BeginInvoke 可用它來叫用適當方法。

  2. 將下列空白方法加入您的程式碼。

    public void FactHandler(double Value, double Calculations)
    {
    }
    public void Fact1Handler(double Value, double Calculations)
    {
    }
    public void Add2Handler(int Value, double Calculations)
    {
    }
    public void LDoneHandler(double Calculations, int Count)
    {
    }
    
  3. 在 [編輯] 功能表中,使用 [剪下] 和 [貼上],將 FactorialHandler 方法中的所有程式碼剪下,然後將其貼入 FactHandler。

  4. 為 FactorialMinusHandler 和 Fact1Handler、AddTwoHandler 和 Add2Handler,以及 LoopDoneHandler 和 LDoneHandler 重複上一個步驟。

    完成時,FactorialHandler、Factorial1Handler、AddTwoHandler 和 LoopDoneHandler 中應不會剩下任何程式碼,而曾包含在其中的所有程式碼應都已移至適當的新方法。

  5. 呼叫 BeginInvoke 方法,非同步地叫用方法。 您可以從表單 (this) 或表單中的任何控制項呼叫 BeginInvoke

    完成時,您的程式碼應該看起來如下:

    protected void FactorialHandler(double Value, double Calculations)
    {
       // BeginInvoke causes asynchronous execution to begin at the address
       // specified by the delegate. Simply put, it transfers execution of 
       // this method back to the main thread. Any parameters required by 
       // the method contained at the delegate are wrapped in an object and 
       // passed. 
       this.BeginInvoke(new FHandler(FactHandler), new Object[]
          {Value, Calculations});
    }
    protected void FactorialMinusHandler(double Value, double Calculations)
    {
       this.BeginInvoke(new FHandler(Fact1Handler), new Object []
          {Value, Calculations});
    }
    
    protected void AddTwoHandler(int Value, double Calculations)
    {
       this.BeginInvoke(new A2Handler(Add2Handler), new Object[]
          {Value, Calculations});
    }
    
    protected void LoopDoneHandler(double Calculations, int Count)
    {
       this.BeginInvoke(new LDHandler(LDoneHandler), new Object[]
          {Calculations, Count});
    }
    

    這樣看起來似乎是事件處理常式就只是呼叫下一個方法。 實際上,事件處理常式是讓方法在作業的主執行緒上被叫用。 這種方法能夠省下跨執行緒界線呼叫,同時允許您的多執行緒應用程式有效執行而不需擔心引發鎖定。 如需在多執行緒環境中使用控制項的詳細資訊,請參閱 如何:管理執行緒的控制項.

  6. 儲存您的工作。

  7. 從 [偵錯] 功能表中選擇 [開始偵錯] 來測試您的方案。

    1. 在文字方塊中輸入 10000000,接著按一下 [Run A Loop]。

      在這個按鈕下面的標籤中會顯示 "Looping"。 這個迴圈在執行時需要相當長的時間。 如果太早完成,則請調整數字的大小。

    2. 連續快速按一下這三個仍然啟用的按鈕。 您會發現所有按鈕都回應您的輸入。 [Add Two] 下的標籤應會先顯示結果。 稍後結果將會顯示在階乘按鈕之下的標籤中。 這些結果會判定為無限,因為 10,000,000 階乘所傳回的數字超過了雙精度變數所能包含的範圍。 最後,在稍微延遲之後,結果會傳回 [Run A Loop] 按鈕下方。

      就您剛才所見,四組個別的計算是在四個個別執行緒上同時執行。 使用者介面仍能回應輸入,而在每個執行緒完成後就會傳回結果。

協調您的執行緒

對多執行緒應用程式熟悉的使用者可能會在剛才輸入的程式碼中發現一個不易察覺的問題。 請回想一下 Calculator 中每個執行計算的方法的程式碼:

varTotalCalculations += 1;
varTotalAsOfNow = varTotalCalculations;

上面兩行程式碼會遞增公用變數 varTotalCalculations,並將區域變數 varTotalAsOfNow 設為這個值。 然後將這個值傳回至 frmCalculations,並顯示在標籤控制項中。 但傳回的是正確的值嗎? 如果只執行單一執行緒,那麼答案很顯然是肯定的。 但如果執行多個執行緒,則答案就變得不確定。 每個執行緒都能夠遞增變數 varTotalCalculations。 因此很可能其中一個執行緒累加了這個變數,但是在它將這個值複製到 varTotalAsOfNow 中之前,另一個執行緒又累加一次,因而改變了這個變數的值。 這也就是說,每個執行緒實際上都可能報告錯誤的結果。 Visual C# 提供 lock 陳述式 (C# 參考) 允許執行緒同步,以確保每個執行緒永遠都會傳回正確的結果。 lock 的語法如下:

lock(AnObject)
{
   // Insert code that affects the object.
   // Insert more code that affects the object.
   // Insert more code that affects the object.
// Release the lock.
}

當輸入 lock 區塊時,特定運算式上的執行會被封鎖,直至指定執行緒在該物件上具有獨佔鎖定。 在上述範例中,AnObject 上的執行會停止。 lock 必須和會傳回參考 (而非傳回值) 的物件一起使用。 接著就可繼續以區塊方式執行而不會干擾其他執行緒。 當做一個單位來執行的一組陳述式稱為「原子單位」。 當出現 } 時,就會釋出運算式並允許執行緒照常進行。

若要將 lock 陳述式加入至您的應用程式

  1. 在 [程式碼編輯器] 中開啟 Calculator.cs

  2. 找出下列程式碼的每個執行個體:

    varTotalCalculations += 1;
    varTotalAsOfNow = varTotalCalculations;
    

    這段程式碼應有四個執行個體,每個計算方法中都有一個。

  3. 將這段程式碼修改如下:

    lock(this)
    {
       varTotalCalculations += 1;
       varTotalAsOfNow = varTotalCalculations;
    }
    
  4. 儲存您的工作並依先前範例中的步驟測試它。

    您可能會注意到您的程式效能稍受影響。 這是因為當獨佔鎖定您的元件時,執行緒的執行會停止。 雖然這樣可以確保正確性,但是這種方式對多執行緒的效能會造成一些影響。 您應仔細考慮鎖定執行緒的需求,同時只在絕對需要時實作它們。

請參閱

工作

如何:協調多執行緒的執行

逐步解說:使用 Visual Basic 撰寫簡單的多執行緒元件

參考

BackgroundWorker

概念

事件架構非同步模式概觀

其他資源

使用元件進行程式設計

元件程式撰寫逐步解說

元件中的多執行緒