併發程式設計-6.並行程式設計概念

F(x)_King發表於2024-03-30

TPL 入門

TPL 由新增到 .NET Framework 4.0 中的 System.Threading 和 System.Threading.Tasks 名稱空間的型別組成。 TPL 提供的功能使 .NET 開發人員的並行性和併發性變得更簡單。 無需在程式碼中管理 ThreadPool 任務。 TPL 處理執行緒管理並根據處理器能力和可用性自動調整活動執行緒的數量。

當開發人員需要在程式碼中引入並行性或併發性以提高效能時,應使用 TPL。 然而,TPL 並不是適合所有場景的正確選擇。 您如何知道何時選擇 TPL 以及哪種 TPL 構造是每種場景的最佳選擇?

I/O 密集型操作

當處理 I/O 密集型操作(例如檔案操作、資料庫呼叫或 Web 服務呼叫)時,使用 Task 物件進行非同步程式設計和 C# 非同步/等待操作是您的最佳選擇。 如果您的服務要求迴圈訪問大型集合,為迴圈中的每個物件進行服務呼叫,則應考慮重構服務以將資料作為單個服務呼叫返回。這將最大限度地減少與每個網路操作相關的開銷。 它還允許您的客戶端程式碼對服務進行單個非同步呼叫,同時保持主執行緒空閒以執行其他工作。

I/O 密集型操作通常不適合並行操作,但每個規則都有例外。 如果您需要迭代檔案系統中的一組資料夾和子資料夾,則並行迴圈非常適合此目的。 但是,重要的是,迴圈的任何迭代都不會嘗試訪問同一檔案,以避免鎖定問題。

CPU 密集型操作

CPU 密集型操作不依賴於外部資源,例如檔案系統、網路或網際網路。 它們涉及在應用程式程序中處理記憶體中的資料。 有許多型別的資料轉換都屬於這一類。 您的應用程式可能正在序列化或反序列化資料、在檔案型別之間進行轉換或處理影像或其他二進位制資料。

這些型別的操作對於資料並行性和並行迴圈特別有意義,但有一些例外。 首先,如果每次迭代對 CPU 的消耗不是很大,那麼使用 TPL 就不值得它帶來的開銷。 如果該過程非常密集,但需要迭代的物件很少,請考慮使用 Parallel.Invoke 而不是並行迴圈之一:Parallel.For 或 Parallel.ForEach。 由於使用 TPL 的開銷,對 CPU 密集程度較低的操作使用並行結構通常會減慢程式碼速度。 在第10章中,我們將學習如何使用Visual Studio來確定並行和併發程式碼的效能。

.NET 中的並行迴圈

在本節中,我們將探討在 .NET 專案中利用資料並行性的一些示例。 C# for 和 foreach 迴圈的並行版本 Parallel.ForParallel.ForEachSystem.Threading.Tasks.Parallel 名稱空間的一部分。 使用這些並行迴圈與在 C# 中使用它們的標準對應迴圈類似。

一個關鍵的區別是並行迴圈的主體被宣告為 lambda 表示式。 因此,繼續或中斷並行迴圈的方式會發生一些變化。 您可以使用 return 語句,而不是使用 continue 來停止迴圈的當前迭代而不中斷整個迴圈。 與使用break 退出並行迴圈等效的是使用Stop()Break() 語句。

基本 Parallel.For 迴圈

我們將建立一個新的 WinForms 應用程式,允許使用者在其工作站上選擇一個資料夾並檢查有關所選資料夾中檔案的一些資訊。 該專案的 FileProcessor 類將迭代檔案以聚合檔案大小並查詢最近寫入的檔案:

  1. 首先在 Visual Studio 中建立一個新的 .NET 6 WinForms 專案

  2. 新增一個名為FileData 的新類。 此類將包含來自 FileProcessor 的資料:

public class FileData
{
    public List<FileInfo> FileInfoList { get; set; } =new();
    public long TotalSize { get; set; } = 0;
    public string LastWrittenFileName{ get; set; } = "";
    public DateTime LastFileWriteTime { get; set; }
}

我們將返回所選資料夾中檔案的 FileInfo 物件列表、所有檔案的總大小、上次寫入檔案的名稱以及寫入檔案的日期和時間。

3.接下來,建立一個名為FileProcessor的新類

  1. 將名為 GetInfoForFiles 的靜態方法新增到 FileProcessor
public static FileData GetInfoForFiles(string[] files)
{
    var results = new FileData();
    var fileInfos = new List<FileInfo>();
    long totalFileSize = 0;
    DateTime lastWriteTime = DateTime.MinValue;
    string lastFileWritten = "";
    object dateLock = new();
    
    Parallel.For(0, files.Length,
        index => {
            FileInfo fi = new(files[index]);
            long size = fi.Length;
            DateTime lastWrite = fi.LastWriteTimeUtc;
            lock (dateLock)
            {
                if (lastWriteTime < lastWrite)
                {
                    lastWriteTime = lastWrite;
                    lastFileWritten = fi.Name;
                }
            }
            Interlocked.Add(ref totalFileSize, size);
        fileInfos.Add(fi);
        });
    
    results.FileInfoList = fileInfos;
    results.TotalSize = totalFileSize;
    results.LastFileWriteTime = lastWriteTime;
    results.LastWrittenFileName = lastFileWritten;
    return results;
}

Parallel.For 迴圈及其主體的 lambda 表示式在前面的程式碼中突出顯示。 迴圈內的程式碼有幾點需要注意:

  1. 首先,索引作為 lambda 表示式的引數提供,以便表示式主體可以使用它來訪問檔案陣列的當前成員。

  2. TotalFileSizeInterlocked.Add 呼叫中更新。 這是在並行程式碼中安全新增值的最有效方法。

  3. 沒有一種簡單的方法可以利用 Interlocked 來更新 lastWriteTime DateTime 值。 因此,我們使用帶有 dateLock 物件的鎖塊來安全地讀取和設定 lastWriteTime 方法級變數。

  4. 接下來,開啟 Form1.cs 的設計器並將以下控制元件新增到表單中:

private GroupBox FileProcessorGroup;
private Button FolderProcessButton;
private Button FolderBrowseButton;
private TextBox FolderToProcessTextBox;
private Label label1;
private TextBox FolderResultsTextBox;
private Label label2;
private FolderBrowserDialog folderToProcessDialog;

完成後,表單設計器應如下所示:

圖 6.1 – Visual Studio 中完成的 Form1.cs 設計器檢視

image

  1. 接下來,雙擊Form1設計器中的瀏覽按鈕,程式碼隱藏檔案中將生成FolderBrowserButton_Click事件處理程式。 新增以下程式碼以使用folderToProcessDialog 物件向使用者顯示資料夾選擇器對話方塊:
private void FolderBrowseButton_Click(object sender,EventArgs e)
{
    var result = folderToProcessDialog.ShowDialog();
    if (result == DialogResult.OK)
    {
        FolderToProcessTextBox.Text = folderToProcessDialog.SelectedPath;
    }
}

選定的資料夾路徑將設定在FolderToProcessTextBox 中以供下一步使用。 使用者也可以在欄位中手動鍵入或貼上資料夾路徑。 如果您想阻止手動輸入,可以將FolderToProcessTextBox.ReadOnly 設定為true

  1. 接下來,雙擊設計器檢視中的Process按鈕。 後面的程式碼中將生成一個FolderProcessButton_Click 事件處理程式。 新增以下程式碼來呼叫 FileProcessor 並將結果顯示在FolderResultsTextBox 中:
private void FolderProcessButton_Click(object sender,EventArgs e)
{
    if (!string.IsNullOrWhiteSpace(FolderToProcessTextBox.Text) &&
    	Directory.Exists(FolderToProcessTextBox.Text))
    {
        string[] filesToProcess = Directory.GetFiles(FolderToProcessTextBox.Text);
        FileData? results = FileProcessor.GetInfoForFiles(filesToProcess);
        if (results == null)
        {
            FolderResultsTextBox.Text = "";
            return;
        }
        StringBuilder resultText = new();
        resultText.Append($"Total file count:{results.FileInfoList.Count}; ");
        resultText.AppendLine($"Total file size:{results.TotalSize} bytes");
        resultText.Append($"Last written file:{results.LastWrittenFileName} ");
        resultText.Append($"at{results.LastFileWriteTime}");
        FolderResultsTextBox.Text =resultText.ToString();
    }
}

這裡的程式碼很簡單。 靜態 GetInfoForFiles 方法返回帶有檔案資訊的 FileData 例項。 我們使用 StringBuilder 建立要在FolderResultsTextBox 中設定的輸出。

  1. 我們已準備好執行應用程式。 開始在 Visual Studio 中除錯專案並嘗試一下。 你的結果應該是這樣的:

圖 6.2 – 執行並行迴圈應用程式

image

這裡的所有都是它的。 如果您想嘗試更高階的操作,可以嘗試修改專案以處理所選資料夾的所有子資料夾中的檔案。 讓我們對專案進行不同的更改,以便減少對 Interlocked.Add 的鎖定呼叫。

具有執行緒區域性變數的並行迴圈

Parallel.For 構造有一個過載,它允許我們的程式碼為參與迴圈的每個執行緒保留總檔案大小的執行小計。 這意味著我們只需要在將每個執行緒的小計彙總到totalFileSize 時使用Interlocked.Add。這是透過向迴圈提供執行緒區域性變數來完成的。 以下程式碼中的小計是針對每個執行緒離散儲存的。 因此,如果迴圈有 200 次迭代,但只有 5 個執行緒參與迴圈,則 Interlocked.Add 將僅被呼叫 5 次,而不是 200 次,而不會失去任何執行緒安全性:

public static FileData GetInfoForFilesThreadLocal(string[] files)
{
    var results = new FileData();
    var fileInfos = new List<FileInfo>();
    long totalFileSize = 0;
    DateTime lastWriteTime = DateTime.MinValue;
    string lastFileWritten = "";
    object dateLock = new();
    
	Parallel.For<long>(0, files.Length, () => 0,
        (index, loop, subtotal) => {
            FileInfo fi = new(files[index]);
            long size = fi.Length;
            DateTime lastWrite = fi.LastWriteTimeUtc;
            lock (dateLock)
            {
                if (lastWriteTime < lastWrite)
                {
                    lastWriteTime = lastWrite;
                    lastFileWritten = fi.Name;
                }
            }
            subtotal += size;
            fileInfos.Add(fi);
            return subtotal;
        },
       (runningTotal) => Interlocked.Add(ref totalFileSize, runningTotal)
    );
    
    results.FileInfoList = fileInfos;
    results.TotalSize = totalFileSize;
    results.LastFileWriteTime = lastWriteTime;
    results.LastWrittenFileName = lastFileWritten;
    return results;
}

總結一下前面的更改,您會注意到我們使用 Parallel.For<long> 泛型方法來指示小計執行緒區域性變數應該是 long 而不是 int (預設型別)。 大小會新增到第一個 lambda 表示式中的小計中,而無需任何鎖定表示式。 我們現在必須返回小計,以便其他迭代可以訪問資料。 最後,我們使用 lambda 表示式向 For 新增了最後一個引數,該表示式使用 Interlocked.Add 將每個執行緒的 runningTotal 新增到totalFileSize

如果您更新FolderProcessButton_Click以呼叫GetInfoForFilesThreadLocal,輸出將是相同的,但效能將得到改善,也許不明顯。 效能改進取決於所選資料夾中的檔案數量。

簡單的 Parallel.ForEach 迴圈

Parallel.ForEach 方法(例如 Parallel.For)在使用上與其非並行對應方法類似。 當您有 IEnumerable 集合要處理時,您可以使用 Parallel.ForEach 而不是 Parallel.For。 在此示例中,我們將建立一個新方法,該方法接受影像檔案的 List<string> 來迭代並轉換為 Bitmap 物件:

  1. 首先在 FileProcessor 類中建立一個名為 ConvertJpgToBitmap 的新私有靜態方法。 此方法將開啟每個 JPG 檔案並返回包含影像資料的新點陣圖:
private static Bitmap ConvertJpgToBitmap(string fileName)
{
    Bitmap bmp;
    using (Stream bmpStream = File.Open(fileName,FileMode.Open))
    {
        Image image = Image.FromStream(bmpStream);
        bmp = new Bitmap(image);
    }
    return bmp;
}
  1. 接下來,在名為 ConvertFilesToBitmaps 的同一類中建立一個公共靜態方法:
public static List<Bitmap> ConvertFilesToBitmaps(List<string> files)
{
    var result = new List<Bitmap>();
    Parallel.ForEach(files, file =>
    {
        FileInfo fi = new(file);
        string ext = fi.Extension.ToLower();
        if (ext == ".jpg" || ext == ".jpeg")
        {
        	result.Add(ConvertJpgToBitmap(file));
        }
    });
    return result;
}

此方法接受包含所選資料夾中的檔案的 List<string>。 在 Parallel.ForEach 迴圈內,它檢查檔案是否具有 .jpg 或 .jpeg 副檔名。 如果是,則將其轉換為點陣圖並新增到結果集合中。

  1. Form1.cs 中新增一個新按鈕。 將“Name屬性設定為ProcessJpgsButton,將“文字”屬性設定為 Process JPGs。
  2. 雙擊新按鈕以在程式碼隱藏檔案中建立事件處理程式。 將以下程式碼新增到新的事件處理程式中:
private void ProcessJpgsButton_Click(object sender,
EventArgs e)
{
    if (!string.IsNullOrWhiteSpace(FolderToProcessTextBox.Text) &&
    	Directory.Exists(FolderToProcessTextBox.Text))
    {
        List<string> filesToProcess = Directory.GetFiles(FolderToProcessTextBox.Text).ToList();
        List<Bitmap> results = FileProcessor.ConvertFilesToBitmaps(filesToProcess);
        StringBuilder resultText = new();
        foreach (var bmp in results)
        {
        	resultText.AppendLine($"Bitmap height: {bmp.Height}");
        }
        FolderResultsTextBox.Text = resultText.ToString();
    }
}
  1. 現在,執行專案,選擇包含一些 JPG 檔案的資料夾,然後單擊新的 Process JPGs按鈕。 您應該會看到輸出中列出的每個轉換後的 JPG 的高度。

這就是簡單的 Parallel.ForEach 迴圈所需的全部內容。 如果需要取消長時間執行的並行迴圈,該怎麼辦? 讓我們更新示例以使用 Parallel.ForEachAsync 來實現這一點。

取消 Parallel.ForEachAsync 迴圈

Parallel.ForEachAsync 是 .NET 6 中的新增功能。它是 Parallel.ForEach 的可等待版本,以非同步 lambda 表示式作為其主體。 讓我們更新前面的示例以使用這個新的並行方法並新增取消操作的功能:

  1. 我們將首先製作名為 ConvertFilesToBitmapsAsyncConvertFilesToBitmaps 非同步副本。 差異突出如下:
public static async Task<List<Bitmap>>ConvertFilesToBitmapsAsync(
    List<string> files,
    CancellationTokenSource cts)
{
    ParallelOptions po = new()
    {
        CancellationToken = cts.Token,
        MaxDegreeOfParallelism =
        Environment.ProcessorCount == 1 ? 1 : Environment.ProcessorCount - 1
    };
    var result = new List<Bitmap>();
    try
    {
        await Parallel.ForEachAsync(files, po, async (file, _cts) =>{
            FileInfo fi = new(file);
            string ext = fi.Extension.ToLower();
            if (ext == ".jpg" || ext == "jpeg")
            {
                result.Add(ConvertJpgToBitmap(file));
                await Task.Delay(2000, _cts);
            }
        });
    }
    catch (OperationCanceledException e)
    {
    	MessageBox.Show(e.Message);
    }
    finally
    {
    	cts.Dispose();
    }
    return result;
}

新方法是非同步的,返回 Task<List<Bitmap>>,接受 CancellationTokenSource,並在建立 ParallelOptions 時使用它來傳遞給 Parallel.ForEachAsync 方法。 Parallel.ForEachAsync 已等待,並且其 lambda 表示式被宣告為非同步,因此我們可以等待已新增的新 Task.Delay,以便我們有足夠的時間在迴圈完成之前單擊取消按鈕。

Parallel.ForEachAsync 包含在處理 OperationCanceledExceptiontry/catch 塊中,使該方法能夠捕獲取消。 處理取消後,我們將向使用者顯示一條訊息。

該程式碼還設定 ProcessorCount 選項。 如果只有一個CPU核心可用,我們將值設定為1; 否則,我們希望使用的核心數不超過可用核心數減一。 .NET 執行時通常會很好地管理此值,因此只有在發現此選項可以提高應用程式的效能時才應更改此選項。

  1. 在 Form1.cs 檔案中,新增一個新的 CancellationTokenSource 私有變數:
private CancellationTokenSource _cts;
  1. 將事件處理程式更新為非同步,將 _cts 設定為 CancellationTokenSource 的新例項,並將其傳遞給 ConvertFilesToBitmapsAsync。同時將等待新增到該呼叫中。

以下程式碼片段突出顯示了所有必要的更改:

private async void ProcessJpgsButton_Click(object sender, EventArgs e)
{
    if (!string.IsNullOrWhiteSpace
    (FolderToProcessTextBox.Text) && Directory.Exists(FolderToProcessTextBox.Text))
    {
        _cts = new CancellationTokenSource();
        List<string> filesToProcess = Directory.GetFiles(FolderToProcessTextBox.Text) .ToList();
        List<Bitmap> results = await FileProcessor.ConvertFilesToBitmapsAsync(filesToProcess, _cts);
        StringBuilder resultText = new();
        foreach (var bmp in results)
        {
        	resultText.AppendLine($"Bitmap height:{bmp.Height}");
        }
        FolderResultsTextBox.Text = resultText.ToString();
    }
}
  1. 在表單中新增一個名為 CancelButton 且標題為 Cancel 的新按鈕
  2. 雙擊取消按鈕並新增以下事件處理程式程式碼:
private void CancelButton_Click(object sender,EventArgs e)
{
    if (_cts != null)
    {
    	_cts.Cancel();
    }
}
  1. 執行應用程式,瀏覽並選擇包含 JPG 檔案的資料夾,單擊Process JPGs按鈕,然後立即單擊Cancel按鈕。 您應該收到一條訊息,指示處理已被取消。 不再處理進一步的記錄。

並行任務之間的關係

在上一章(第 5 章)中,我們學習瞭如何使用 asyncwait 並行執行工作並使用ContinueWith 管理任務流。

在 Parallel.Invoke 的幕後

在第2章中,我們學習瞭如何使用Parallel.Invoke方法並行執行多個任務。 我們現在將重新審視 Parallel.Invoke 並瞭解幕後發生的事情。 考慮使用它來呼叫兩個方法:

Parallel.Invoke(DoFirstAction, DoSectionAction);

這是幕後發生的事情:

List<Task> taskList = new();
taskList.Add(Task.Run(DoFirstAction));
taskList.Add(Task.Run(DoSectionAction));
Task.WaitAll(taskList.ToArray());

將建立兩個任務並線上程池中排隊。 假設系統有可用資源,這兩個任務應該被選取並並行執行。 呼叫方法將阻塞當前執行緒,等待並行任務完成。 該操作將在執行時間最長的任務期間阻塞呼叫執行緒。

如果這對於您的應用程式來說是可以接受的,那麼使用 Parallel.Invoke 可以使程式碼更清晰且易於理解。 但是,如果您不想阻塞呼叫執行緒,有幾個選項。首先,讓我們對第二個示例進行更改以使用await

List<Task> taskList = new();
taskList.Add(Task.Run(DoFirstAction));
taskList.Add(Task.Run(DoSectionAction));
await Task.WhenAll(taskList.ToArray());

透過等待 Task.WhenAll 而不是使用 Task.WaitAll,我們允許當前執行緒在等待兩個子任務並行完成處理的同時執行其他工作。 為了使用 Parallel.Invoke 獲得相同的結果,我們可以將其包裝在 Task 中:

await Task.Run(() => Parallel.Invoke(DoFirstTask,DoSecondTask));

可以對 Parallel.For 使用相同的技術,以避免在等待迴圈完成時阻塞呼叫執行緒。 這對於 Parallel.ForEach 來說不是必需的。 我們可以將 Parallel.ForEach 包裝在 Task 中,而不是將其替換為 Parallel.ForEachAsync。我們在本章前面瞭解到,.NET 6 新增了 Parallel.ForEachAsync,它返回 Task 並且可以等待。

理解並行子任務

當執行巢狀任務時,預設情況下,父任務不會等待其子任務,除非我們使用 Wait() 方法或await 語句。 但是,在使用 Task.Factory.StartNew() 時,可以透過一些選項來控制此預設行為。 為了說明可用的選項,我們將建立一個新的示例專案:

  1. 首先,建立一個名為 ParallelTaskRelationshipsSample 的新 C# 控制檯應用程式。

  2. 將一個類新增到名為 ParallelWork 的專案中。 我們將在這裡建立父方法及其子方法。

  3. 將以下三個方法新增到ParallelWork 類中。 這些將是我們的子方法。每個方法在開始和完成時都會寫入一些控制檯輸出。 延遲是透過 Thread.SpinWait 注入的。 如果您不熟悉 Thread.SpinWait,它會將當前執行緒放入指定迭代次數的迴圈中,注入等待,而不會從排程程式的考慮中刪除該執行緒:

public void DoFirstItem()
{
    Console.WriteLine("Starting DoFirstItem");
    Thread.SpinWait(1000000);
    Console.WriteLine("Finishing DoFirstItem");
}
public void DoSecondItem()
{
    Console.WriteLine("Starting DoSecondItem");
    Thread.SpinWait(1000000);
    Console.WriteLine("Finishing DoSecondItem");
}
public void DoThirdItem()
{
    Console.WriteLine("Starting DoThirdItem");
    Thread.SpinWait(1000000);
    Console.WriteLine("Finishing DoThirdItem");
}
  1. 接下來,新增一個名為 DoAllWork 的方法。 該方法將建立一個父任務,該父任務透過子任務呼叫上述三個方法。 沒有新增程式碼來等待子任務:
public void DoAllWork()
{
    Console.WriteLine("Starting DoAllWork");
    Task parentTask = Task.Factory.StartNew(() =>
    {
        var child1 = Task.Factory.StartNew (DoFirstItem);
        var child2 = Task.Factory.StartNew (DoSecondItem);
        var child3 = Task.Factory.StartNew (DoThirdItem);
    });
    parentTask.Wait();
    Console.WriteLine("Finishing DoAllWork");
}
  1. 現在,新增一些程式碼以從 Program.cs 執行 DoAllWork
using ParallelTaskRelationshipsSample;
var parallelWork = new ParallelWork();
parallelWork.DoAllWork();
Console.ReadKey();
  1. 執行程式並檢查輸出。 正如您所期望的,父任務先於其子任務完成:

圖 6.3 – 控制檯應用程式執行 DoAllWork

image

  1. 接下來,我們建立一個名為 DoAllWorkAttached 的方法。 此方法將執行相同的三個子任務,但子任務將包含 TaskCreationOptions.AttachedToParent 選項:
public void DoAllWorkAttached()
{
    Console.WriteLine("Starting DoAllWorkAttached");
    Task parentTask = Task.Factory.StartNew(() =>
    {
        var child1 = Task.Factory.StartNew(DoFirstItem, TaskCreationOptions.AttachedToParent);
        var child2 = Task.Factory.StartNew (DoSecondItem, TaskCreationOptions.AttachedToParent);
        var child3 = Task.Factory.StartNew (DoThirdItem, TaskCreationOptions.AttachedToParent);
    });
    parentTask.Wait();
    Console.WriteLine("Finishing DoAllWorkAttached");
}
  1. 更新 Program.cs 以呼叫 DoAllWorkAttached 而不是 DoAllWork 並再次執行應用程式:

圖 6.4 – 執行我們的應用程式並呼叫 DoAllWorkAttached

image

您可以看到,即使我們沒有顯式等待子任務,父任務也不會在其子任務完成之前完成。

現在,假設您有另一個父任務不應等待其子任務,無論它們是否使用 TaskCreationOptions.AttachedToParent 選項啟動。 讓我們建立一個可以處理這種情況的新方法:

  1. 使用以下程式碼建立名為 DoAllWorkDenyAttach 的方法:
public void DoAllWorkDenyAttach()
{
    Console.WriteLine("Starting DoAllWorkDenyAttach");
    Task parentTask = Task.Factory.StartNew(() =>
    {
        var child1 = Task.Factory.StartNew(DoFirstItem, TaskCreationOptions.AttachedToParent);
        
        var child2 = Task.Factory.StartNew(DoSecondItem, TaskCreationOptions.AttachedToParent);
        
        var child3 = Task.Factory.StartNew (DoThirdItem, TaskCreationOption.AttachedToParent);
        
     }, TaskCreationOptions.DenyChildAttach);
    
    parentTask.Wait();
    Console.WriteLine("Finishing DoAllWork DenyAttach");
}

仍使用 AttachedToParent 選項建立子任務,但父任務現在設定了 DenyChildAttach 選項。 這將取代子級附加到父級的請求。

  1. 更新 Program.cs 以呼叫 DoAllWorkDenyAttach 並再次執行應用程式:

圖 6.5 – 控制檯應用程式呼叫 DoAllWorkDenyAttach

image

您可以看到 DenyChildAttach 確實覆蓋了每個子任務上設定的 AttachToParent 選項。 父級無需等待子級即可完成,就像呼叫 DoAllWork 時一樣。

關於這個例子的最後一點說明。 您可能已經注意到,即使我們不需要設定 TaskCreationOption,我們也使用 Task.Factory.StartNew 而不是 Task.Run。 這是因為 Task.Run 將禁止任何子任務附加到父任務。 如果您在 DoAllWorkAttached 方法中使用 Task.Run 作為父任務,則父任務將首先完成,就像在其他方法中一樣。

並行性的常見陷阱

使用 TPL 時,需要避免一些做法,以確保應用程式獲得最佳結果。 在某些情況下,並行性使用不當可能會導致效能下降。 在其他情況下,它可能會導致錯誤或資料損壞。

不保證並行性

當使用並行迴圈或 Parallel.Invoke 之一時,迭代可以並行執行,但不能保證這樣做。 這些並行委託中的程式碼應該能夠在任一情況下成功執行。

並行迴圈並不總是更快

我們在本章前面討論過這一點,但重要的是要記住,forforeach 迴圈的並行版本並不總是更快。 如果每個迴圈迭代執行得很快,那麼新增並行性的開銷可能會減慢應用程式的速度。

在嚮應用程式引入任何執行緒時,記住這一點很重要。 始終在引入併發或並行性之前和之後測試程式碼,以確保效能提升值得執行緒開銷。

注意阻塞UI執行緒

請記住,Parallel.ForParallel.ForEach 是阻塞呼叫。 如果您在 UI 執行緒上使用它們,它們將在呼叫期間阻塞 UI。 該阻塞持續時間至少是執行時間最長的迴圈迭代的持續時間。

正如我們在上一節中討論的,您可以將並行程式碼包裝在對 Task.Run 的呼叫中,以將執行從 UI 執行緒移動到執行緒池上的後臺執行緒。

執行緒安全

不要在並行迴圈中呼叫非執行緒安全的 .NET 方法。 Microsoft Docs 中記錄了每種 .NET 型別的執行緒安全性。 使用 .NET API 瀏覽器快速查詢有關特定 .NET API 的資訊:https://docs.microsoft.com/dotnet/api/。
限制在並行迴圈中使用靜態 .NET 方法,即使它們被標記為執行緒安全的。 它們不會導致資料一致性錯誤或問題,但會對迴圈效能產生負面影響。 即使呼叫 Console.WriteLine 也只能用於測試或演示目的。 不要在生產程式碼中使用它們。

使用者介面控制元件

在 Windows 客戶端應用程式中,不要嘗試訪問並行迴圈內的 UI 控制元件。 WinForms 和 WPF 控制元件只能從建立它們的執行緒訪問。 您可以使用 Dispatcher.Invoke 呼叫其他執行緒上的操作,但這會對效能產生影響。 最好在並行迴圈完成後更新 UI。

執行緒本地資料

請記住在並行迴圈中利用 ThreadLocal 變數。 我們在本章前面的“使用執行緒區域性變數的並行迴圈”部分中說明了如何執行此操作。
其中介紹了使用 C# 和 .NET 進行並行程式設計。 最後,讓我們回顧一下我們在本章中學到的所有內容。

總結

在本章中,我們學習瞭如何在 .NET 應用程式中利用並行程式設計概念。 我們親身體驗了 Parallel.ForParallel.ForEachParallel.ForEachAsync 迴圈。 在這些部分中,我們學習瞭如何安全地聚合資料,同時保持執行緒安全。 接下來,我們學習瞭如何管理父任務與其並行子任務之間的關係。 這將有助於確保您的應用程式保持預期的操作順序。
最後,我們介紹了在應用程式中實現並行性時要避免的一些重要陷阱。開發人員需要密切注意,以避免在自己的應用程式中出現任何這些陷阱。
要了解有關 .NET 中資料並行性的更多資訊,Microsoft Docs 上的資料並行性文件是一個很好的起點:https://docs.microsoft.com/dotnet/standard/parallel-programming/data-parallelism-task-parallel-library.

相關文章