天天看點

【To .NET】C#異步程式設計,從線程到async/await

👩‍🌾“人生苦短,你用Python”,“Java内卷,我用C#”。

從Java到C#,不僅僅是語言使用的改變,更是我從理想到現實,從象牙塔到大熔爐的第一步。.NET是微軟的一盤棋,而C#是我的棋子,隻希望微軟能下好這盤棋,而我能好好利用這個棋子。

文章目錄

  • ​​一、線程與線程池​​
  • ​​1、線程的建立、暫停、等待及終止​​
  • ​​(1)線程的建立​​
  • ​​(2)線程的暫停​​
  • ​​(3)線程等待​​
  • ​​(4)線程終止​​
  • ​​2、線程的狀态、優先級​​
  • ​​3、前台線程與背景線程​​
  • ​​4、lock關鍵字​​
  • ​​5、線程池​​
  • ​​二、TPL​​
  • ​​1、TPL介紹​​
  • ​​2、任務​​
  • ​​(1)建立任務​​
  • ​​(2)從任務中擷取結果值​​
  • ​​三、async/await​​
  • ​​1、異步與async/await原理說明​​
  • ​​(1)異步說明​​
  • ​​(2)異步函數說明​​
  • ​​(3)async/await原理​​
  • ​​2、異步傳回類型​​
  • ​​(1)Task 傳回類型​​
  • ​​(2)Task​​

一、線程與線程池

1、線程的建立、暫停、等待及終止

(1)線程的建立

使用線程類需要導入命名空間System.Threading,我們通過new Thread(方法名)的方式來建立線程,然後通過線程對象.Start來啟動線程。

using System;
using System.Threading;

namespace CSharp.Learn
{
    internal class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("Main線程啟動...");
            //線程建立
            Thread t = new Thread(PrintNumbers);
            //線程啟動
            t.Start();
            Console.WriteLine("Main線程結束");
        }
        static void PrintNumbers()
        {
            Console.WriteLine("非主線程啟動...");
            for (int i = 0; i < 10; i++)
            {
                Console.WriteLine("非主線程:"+i);
            }
        }
    }
}      

(2)線程的暫停

線程調用sleep()後會處于休眠狀态。

using System;
using System.Threading;

namespace CSharp.Learn
{
    internal class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("Main線程啟動...");
            Thread t = new Thread(PrintNumbers);
            t.Start();
            Console.WriteLine("Main線程結束");
        }
        static void PrintNumbers()
        {
            Console.WriteLine("非主線程啟動...");
            for (int i = 0; i < 10; i++)
            {
                //線程暫停
                Thread.Sleep(TimeSpan.FromSeconds(2));
                Console.WriteLine("非主線程:"+i);
            }
        }
    }
}      

(3)線程等待

在一個線程中,另一個線程調用Join方法會讓目前線程進入等待狀态,直到另一個線程執行完畢後,目前線程才能執行。

using System;
using System.Threading;

namespace CSharp.Learn
{
    internal class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("Main線程啟動...");
            Thread t = new Thread(PrintNumbers);
            t.Start();
            //線程等待
            t.Join();
            for (int i = 0; i < 10; i++)
            {
                Console.WriteLine("主線程:" + i);
            }
            Console.WriteLine("Main線程結束");
        }
        static void PrintNumbers()
        {
            Console.WriteLine("非主線程啟動...");
            for (int i = 0; i < 10; i++)
            {
                Thread.Sleep(TimeSpan.FromSeconds(2));
                Console.WriteLine("非主線程:"+i);
            }
        }
    }
}      

(4)線程終止

使用Abort方法将給線程注入ThreadAbortException,導緻線程被終結,但因為該異常可以在任何時刻發生并可能徹底摧毀應用程式。

Thread.Abort();      

2、線程的狀态、優先級

我們可以通過以下語句來檢視目前線程的線程狀态。

Thread.CurrentThread.ThreadState.ToString();      

我們可以通過設定Thread中的Priority屬性來設定線程的優先級。

t.Priority = ThreadPriority.Highest;
t.Priority = ThreadPriority.Lowest;      

3、前台線程與背景線程

預設情況下,顯式建立的線程為前台線程,我們通過修改其屬性IsBackground屬性為true來建立一個背景線程。

t.IsBackground = true;      

前台線程和背景線程的主要差別:程序會等待所有的前台線程完成後再結束工作,但是如果隻剩下背景線程,則會直接結束工作。

4、lock關鍵字

當多個線程同時操作同一對象時就可能發生線程安全問題,我們可以通過lock關鍵字将一個代碼段設定為臨界資源,當出現競争時,後進入線程會進行排隊等待,直到占有臨界資源的線程釋放該資源。實際上關鍵字lock是類Monitor用例中的一個文法糖。

例如:List的内部類中SynchronizedList的Add方法就采用lock關鍵字來達成線程同步。

private Object _root;

public void Add(T item) {
  lock (_root) { 
    _list.Add(item); 
  }
}      

5、線程池

.NET Framework的ThreadPool類提供一個線程池,該線程池可用于執行任務、發送工作項、處理異步 I/O、代表其他線程等待以及處理計時器。那麼什麼是線程池?線程池其實就是一個存放線程對象的“池子(pool)”,他提供了一些基本方法,如:設定pool中最小/最大線程數量、把要執行的方法排入隊列等等。ThreadPool是一個靜态類,是以可以直接使用,不用建立對象。

線程池的特點:

  • 一個程序有且隻能管理一個線程池,線程池線程都是背景線程(即不會阻止程序的停止)。
  • 每個線程都使用預設堆棧大小,以預設的優先級運作,并處于多線程單元中。超過最大值的其他線程需要排隊,但它們要等到其他線程完成後才啟動。
  • 線程上限可以改變,通過使用ThreadPool.GetMax+Threads和ThreadPool.SetMaxThreads方法,可以擷取和設定線程池的最大線程數。
  • 預設情況下,每個處理器維持一個空閑線程,即預設最小線程數 = 處理器數。
  • 當程序啟動時,線程池并不會自動建立。當第一次将回調方法排入隊列(比如調用ThreadPool.QueueUserWorkItem方法)時才會建立線程池。在對一個工作項進行排隊之後将無法取消它。
  • 線程池中線程在完成任務後并不會自動銷毀,它會以挂起的狀态傳回線程池,如果應用程式再次向線程池送出請求,那麼這個挂起的線程将激活并執行任務,而不會建立新線程,這将節約了很多開銷。隻有線程達到最大線程數量,系統才會以一定的算法銷毀回收線程。

線程池的優勢:

  • 可以避免建立和銷毀消除的開支,進而可以實作更好的性能和系統穩定性。
  • 把線程交給系統進行管理,程式員不需要費力于線程管理,可以集中精力處理應用程式任務。

二、TPL

官方文檔:https://docs.microsoft.com/zh-cn/dotnet/standard/parallel-programming/task-parallel-library-tpl

1、TPL介紹

TPL(Task Parallel Library) 的目的是通過簡化将并行和并發添加到應用程式的過程來提高開發人員的工作效率。

使用線程池可以減少并行操作時作業系統資源的開銷,然而使用線程池并不簡單,從線程池的工作線程中擷取結果也并不容易。于是就有了TPL,TPL可被認為是線程池上的又一個抽象層,其對開發人員隐藏了與線程池互動的底層代碼,并提供了更細粒度的API。

​ 任務并行庫 (TPL) 以“任務”的概念為基礎,後者表示異步操作。 在某些方面,任務類似于線程或 ​​ThreadPool​​ 工作項,但是抽象級别更高。 術語“任務并行”是指一個或多個獨立的任務同時運作。 任務提供兩個主要好處:

  • 系統資源的使用效率更高,可伸縮性更好。

    在背景,任務排隊到已使用算法增強的​​ThreadPool​​,這些算法能夠确定線程數并随之調整,提供負載平衡以實作吞吐量最大化。 這會使任務相對輕量,你可以建立很多任務以啟用細化并行。

  • 對于線程或工作項,可以使用更多的程式設計控件。

    任務和圍繞它們生成的架構提供了一組豐富的 API,這些 API 支援等待、取消、繼續、可靠的異常處理、詳細狀态、自定義計劃等功能。

出于這兩個原因,在 .NET 中,TPL 是用于編寫多線程、異步和并行代碼的首選API。

2、任務

(1)建立任務

using System;
using System.Threading;
using System.Threading.Tasks;

namespace CSharp.Learn
{
    internal class Program
    {
        static void Main(string[] args)
        {
            Task t1 = new Task(() => TaskMethod("t1"));
            Task t2 = new Task(() => TaskMethod("t2"));
            t1.Start();
            t2.Start();
            Task.Run(() => TaskMethod("t3"));
            Task.Factory.StartNew(() => TaskMethod("t5"));
            Task.Factory.StartNew(() => TaskMethod("t6"), TaskCreationOptions.LongRunning);
            Thread.Sleep(TimeSpan.FromSeconds(1));
        }
        static void TaskMethod(string name)
        {
            Console.WriteLine(
                $"{name}:" +
                $"-ManagedThreadId:{Thread.CurrentThread.ManagedThreadId} \n" +
                $"-isThreadPoolThread: {Thread.CurrentThread.IsThreadPoolThread}");
        }
    }
}      

我們使用Task的構造函數建立了兩個任務,然後通過Start方法運作。使用Task.Run和Task.Factory.StartNew也可以運作任務。

(2)從任務中擷取結果值

using System;
using System.Threading;
using System.Threading.Tasks;

namespace CSharp.Learn
{
    internal class Program
    {
        static void Main(string[] args)
        {
            Task<int> task = CreateTask("Task-1");
            task.Start();
            int result = task.Result;
            Console.WriteLine($"Task-1-result:{result}");
            Console.WriteLine($"Task-1-status:{task.Status}");

            task = CreateTask("Task-2");
            task.RunSynchronously();
            result = task.Result;
            Console.WriteLine($"Task-2-result:{result}");
            Console.WriteLine($"Task-2-status:{task.Status}");

        }

        static Task<int> CreateTask(string name)
        {
            return new Task<int>(()=>TaskMethod(name));
        }
        
        static int TaskMethod(string name)
        {
           Console.WriteLine(
                $"{name}:" +
                $"-ManagedThreadId:{Thread.CurrentThread.ManagedThreadId} \n" +
                $"-isThreadPoolThread: {Thread.CurrentThread.IsThreadPoolThread}");
            Thread.Sleep(TimeSpan.FromSeconds(2));
            return 42;
        }
    }
}      

任務運作後,我們可以通過Task的Result屬性來擷取任務運作的傳回值,Result的類型為Task<T>的指定泛型類型。通過RunSynchronously()運作的Task在主線程中運作,而Start開啟的運作線上程池中運作。

三、async/await

​ 官方文檔:​​C# 中的異步程式設計 | Microsoft Docs​​

1、異步與async/await原理說明

(1)異步說明

同步:當一個方法被調用時,調用者需要等待該方法執行完畢并傳回才能繼續執行,我們稱這個方法是同步方法。

異步:當一個方法被調用時立即傳回,并擷取一個線程執行該方法内部的業務,調用者不用等待該方法執行完畢,我們稱這個方法為異步方法。如下圖,官方使用了做早餐為例子進行同步與異步的說明。

【To .NET】C#異步程式設計,從線程到async/await
【To .NET】C#異步程式設計,從線程到async/await

(2)異步函數說明

異步函數編寫:

  • C#5.0引入了異步函數,要建立一個異步函數,首先需要用async關鍵字标注一個方法,才能擁有async屬性。除此,異步函數必須傳回Task或Task<T>類型。
  • 在async關鍵字标注的方法内部,可以使用await操作符,異步函數中至少要擁有一個await操作符,否則會出現編譯警告。(如果程式中有兩個連續await操作符,它們實際上依舊是順序執行)
  • 最好在異步函數方法名加Async,表明該方法為異步函數

調用過程:await調用的等待期間,.NET會把目前的線程傳回給線程池,工作線程繼續執行後續操作,等異步方法調用執行完畢後,架構會從線程池再取出來一個線程執行後續的代碼。

使用限制:

  • 不能在catch、finally、lock或unsafe代碼塊中使用await操作符
  • 不允許對任何異步函數使用ref或out參數

異步方法也可以不标注async和使用await關鍵字:當該方法隻需要傳回Task或Task<>類型的參數時,可以(最好)不加async和await關鍵字。因為async和await是文法糖,在編譯過程中會重新建立一個類,在不必要的時刻可以不适用async。

注意:使用了async後不要使用sleep來進行線程執行的暫停,因為Thread.Sleep()會使導緻線程阻塞,線程切換會增加程式運作負擔。通常使用如下語句。

await Task.Delay();      

(3)async/await原理

如果使用 async 修飾符将某種方法指定為異步方法:标記的異步方法可以使用await來指定暫停點。 await運算符通知編譯器異步方法:在等待的異步過程完成後才能繼續通過該點。 同時,控制傳回至異步方法的調用方。

await運算符暫停對其所屬的await方法的求值,直到其操作數表示的異步操作完成。

異步操作完成後,await運算符将傳回操作的結果(如果有)。 當await運算符應用到表示已完成操作的操作數時,它将立即傳回操作的結果,而不會暫停其所屬的方法。

2、異步傳回類型

(1)Task 傳回類型

不包含 return 語句的異步方法或包含不傳回操作數的 return 語句的異步方法通常具有傳回類型 Task。 如果此類方法同步運作,它們将傳回 void。 如果在異步方法中使用 Task 傳回類型,調用方法可以使用 await 運算符暫停調用方的完成,直至被調用的異步方法結束。

下例中的 WaitAndApologizeAsync 方法不包含 return 語句,是以該方法會傳回 Task 對象。 傳回 Task 可等待 WaitAndApologizeAsync。Task 類型不包含 Result 屬性,因為它不具有任何傳回值。

public static async Task DisplayCurrentInfoAsync()
{
    await WaitAndApologizeAsync();

    Console.WriteLine($"Today is {DateTime.Now:D}");
    Console.WriteLine($"The current time is {DateTime.Now.TimeOfDay:t}");
    Console.WriteLine("The current temperature is 76 degrees.");
}

static async Task WaitAndApologizeAsync()
{
    await Task.Delay(2000);

    Console.WriteLine("Sorry for the delay...\n");
}      

通過使用 await 語句而不是 await 表達式等待 WaitAndApologizeAsync,類似于傳回 void 的同步方法的調用語句。 Await 運算符的應用程式在這種情況下不生成值。 當 await 的右操作數是 Task<T> 時,await 表達式生成的結果為 T。 當 await 的右操作數是 Task 時,await 及其操作數是一個語句。

(2)Task<TResult> 傳回類型

Task<TResult> 傳回類型用于某種異步方法,此異步方法包含 return 語句,其中操作數是 TResult。

在下面的示例中,GetLeisureHoursAsync 方法包含傳回整數的 return 語句。 該方法聲明必須指定 Task<int> 的傳回類型。 FromResult 異步方法是傳回 DayOfWeek 的操作的占位符。

public static async Task ShowTodaysInfoAsync()
{
    int i = await GetLeisureHoursAsync()
    Console.WriteLine(i);
}

static async Task<int> GetLeisureHoursAsync()
{
    DayOfWeek today = await Task.FromResult(DateTime.Now.DayOfWeek);

    int leisureHours =
        today is DayOfWeek.Saturday || today is DayOfWeek.Sunday
        ? 16 : 5;
    return leisureHours;
}      

3、WhenAll與WhenAny:高效等待

(1)WhenAll

可以通過使用 Task 類的方法改進上述代碼末尾的一系列 await 語句。 其中一個 API 是 WhenAll,它将傳回一個其參數清單中的所有任務都已完成時才完成的 Task,如以下代碼中所示:

await Task.WhenAll(eggsTask, baconTask, toastTask);
Console.WriteLine("Eggs are ready");
Console.WriteLine("Bacon is ready");
Console.WriteLine("Toast is ready");
Console.WriteLine("Breakfast is ready!");      

(2)WhenAny

另一個選項是使用 WhenAny,它傳回在其任何參數完成時完成的一個 Task<Task> 。 你可以等待傳回的任務,了解它已經完成了。 以下代碼展示了可以如何使用WhenAny等待第一個任務完成,然後再處理其結果。 處理已完成任務的結果之後,可以從傳遞給 WhenAny 的任務清單中删除此已完成的任務。

var breakfastTasks = new List<Task> { eggsTask, baconTask, toastTask };
while (breakfastTasks.Count > 0)
{
    Task finishedTask = await Task.WhenAny(breakfastTasks);
    if (finishedTask == eggsTask)
    {
        Console.WriteLine("Eggs are ready");
    }
    else if (finishedTask == baconTask)
    {
        Console.WriteLine("Bacon is ready");
    }
    else if (finishedTask == toastTask)
    {
        Console.WriteLine("Toast is ready");
    }
    breakfastTasks.Remove(finishedTask);
}      

4、取消令牌:CancellationToken在Web API的請求應用

作者:BeckJin

連結:https://www.jianshu.com/p/f1b9960a8b39

前端調用後端的接口一般是基于 Ajax 來實作,當浏覽器網頁被 連續 F5 重新整理 或 頁面加載中被停止 或 Ajax 請求被主動 abort 時,控制台 network 看闆中會出現一些狀态為 canceled 的請求,如下:

【To .NET】C#異步程式設計,從線程到async/await

對于這類請求,用戶端雖然主動放棄了,如果服務端沒有相應處理,其實接口對應的後端程式還是在不停的執行,隻是這個執行結果不會被使用而已,是以這其實是非常浪費伺服器資源的。

實際上浏覽器取消請求時,服務端會将 HttpContext.RequestAborted 中的 Token 綁定到 Action 的 CancellationToken 參數。我們隻需在接口中增加參數 CancellationToken,并将其傳入其他接口調用中,程式識别到令牌被取消就會自動放棄繼續執行。

[HttpGet]
public async Task<string> Index(CancellationToken cancellationToken)
{
  try
  {
    await _userService.GetListAsync(cancellationToken);
    await Task.Delay(5000); // 等待期間,取消請求(Postman 即可模拟)
    await _githubService.GetHomeAsync(cancellationToken);
  }
  catch (Exception ex)
  {
    Console.WriteLine(ex.Message + Environment.NewLine + ex.StackTrace);
  }

  return "ok";
}      

5、EFCore中的Async方法

異步方法大部分是定義在Microsoft.EntityFrameworkCore這個命名空間下EntityFrameworkQueryableExtensions等類中的擴充方法。

常用EF Core的異步方法:SaveChanges()、SaveChangesAsync()、AddAsync()、AddRangeAsync()、AllAsync()、AnyAsync、AverageAsync、ContainsAsync、CountAsync、FirstAssync、FirstOrDefaultAsync、ForEachAsync、LongCountAsync、MaxAsync、MinAsync、SingleAsync、SingleOrDefaultAsync、SumAsync等

一般隻有“立即執行方法”才有對應的Async方法:lQueryable的這些異步的擴充方法都是“立即執行"方法,而GroupBy、OrderBy、Join、Where等“非立即執行"方法則沒有對應的異步方法。為什麼?“非立即執行"方法并沒有實際執行SQL語句,并不是消耗IO的操作。

6、Web API中使用異步函數示例

倉儲層:

public Task<List<Project>> GetAll(CancellationToken cancellationToken)
{
  return _dbContext.Projects.ToListAsync(cancellationToken);
}      
public Task<List<Project>> GetAll(CancellationToken cancellationToken)
{
  return _repository.GetAll(cancellationToken);
}      
[HttpGet]
[Route("getAllProject")]
public async Task<UnifiedResponseResult<List<Project>>> GetAllProjects(CancellationToken cancellationToken)
{
  var result = await _projectService.GetAll(cancellationToken);
  return new UnifiedResponseResult<List<Project>> {
                Status = 200,
                Message = "擷取成功",
                Data = result };
}      

繼續閱讀