博客 / 詳情

返回

C# 的異步編程

使用 async 和 await 進行異步編程

任務異步編程(TAP)模型在典型的異步編程之上提供了一層抽象。在該模型中,您像往常一樣編寫代碼,將其視為一系列語句。不同之處在於,在編譯器處理每個語句以及開始處理下一條語句之前,您可以以任務為基礎的方式閲讀您的代碼。為了實現這一模型,編譯器會對每個任務執行許多轉換。某些語句可以啓動工作並返回一個表示正在進行的工作的 Task 對象,而編譯器必須解決這些轉換。任務異步編程的目標是使代碼看起來像一系列語句,但執行順序更為複雜。執行基於外部資源分配,並在任務完成時停止。

異步編程模型與人們為包含異步任務的流程下達指令的方式類似。本文通過一個關於製作早餐的示例來説明,如何利用 “async” 和 “await” 關鍵字使對包含一系列異步指令的代碼進行推理變得更加容易。製作早餐的指令可以以列表的形式給出:

  1. 充 n 杯奶粉。
  2. 加熱平底鍋,然後煎 n 個雞蛋。
  3. 煎 n 個饅頭片。
  4. 煎 n 個牛排。
  5. 倒 n 杯白開水(與奶粉同)。

如果您有烹飪經驗,您可能會分步完成這些步驟。您先預熱平底鍋來煎雞蛋,同時另一個平底鍋煎牛排。您把奶粉加入熱水,同時開始煎雞蛋、煎牛排。在每個步驟的過程中,您先啓動一項任務,然後轉到其他已準備好讓您關注的任務上。

準備早餐就是一個很好的異步工作示例,這種工作並非並行進行。一個人(或一個線程)可以完成所有的任務。一個人可以異步地準備早餐,即在前一項任務未完成之前就開始進行下一項任務。每個烹飪任務都會自行推進,而不管是否有人員在實時監督這個過程。比如,你一加熱煎鍋準備煎雞蛋,就可以開始煎牛排了。等牛排開始烹飪之後,就可以把下一杯牛奶放進熱水器裏了。

對於並行算法而言,你需要多個人來負責烹飪(或者多個線程)。一個人負責煮雞蛋,另一個人負責煮薯餅,依此類推。每個人專注於自己特定的任務。每個正在烹飪的人(或者每個線程)都會同步等待當前任務完成:薯餅準備好翻面了,麪包準備好在烤麪包機裏彈起來了,等等。

考慮將上述相同的同步指令以 C# 代碼語句的形式進行表述:

List<LEI奶> NNs = FF泡奶 ( 10 , 2 );
foreach ( LEI奶 n in Nais )
    {
    Console . WriteLine ( $"奶煮好了!{n}" );
    }

List < LEI煎雞蛋 > JJDs = FF煎蛋 ( 3 );
Console . WriteLine ( $"雞蛋煎好了 {JJDs . Count} 個!" );

static List < LEINai > FF泡奶 ( double 奶粉 , int 杯 )
    {
    List < LEI奶 > lbnf = [ ];
    Console . WriteLine ( "泡奶粉中……" );
    double NF每杯 = 咖啡粉 / 杯;
    for ( int i = 0 ; i < 杯 ; i++ )
        {
        Task . Delay ( 5000 ); // 2 秒沖水,3 秒攪拌
        lbnf . Add ( new LEI奶粉 ( NF每杯 ) );
        }
    return lbnf;
    }

static Task < List < LEI煎雞蛋 > > FF煎蛋 ( int 生雞蛋 )
    {
    List < LEI煎雞蛋 > lbjjd = [ ];
    Console . WriteLine ( "煎雞蛋中……" );
    while ( 生雞蛋 > 0 )
        {
        if ( 生雞蛋 == 1 )
            {
            Task . Delay ( 5000 ) ;
            lbjjd . Add ( new LEI煎雞蛋 ( ) );
            生雞蛋 -= 1;
            }
        else // 一鍋最多煎兩個雞蛋
            {
            Task . Delay ( 7000 );
            lbjjd . Add ( new LEI煎雞蛋 ( ) );
            lbjjd . Add ( new LEI煎雞蛋 ( ) );
            生雞蛋 -= 2;
            }
        }
    return lbjjd;
    }

如果按照計算機的處理方式來理解這些指令,準備一頓早餐大約需要 22 秒鐘(這是假想的,你得真的做熟了……)。這個時間是各項任務所需時間的總和。計算機會逐句等待每一條指令完成,直到所有工作都完成之後,才會繼續執行下一條任務指令。這種方法可能會耗費大量時間。在準備早餐的例子中,計算機的方法會做出一份不令人滿意的早餐。在同步列表中的後續任務,比如煎牛排和煎雞蛋,要等到前面的任務完成之後才會開始。有些食物在早餐準備好可以食用之前就已經變涼了。

如果希望計算機以異步方式執行指令,就必須編寫異步代碼。在編寫客户端程序時,您希望用户界面能夠對用户輸入做出響應。您的應用程序不應在從網絡下載數據時凍結所有交互操作。在編寫服務器程序時,您也不希望阻塞那些可能正在處理其他請求的線程。如果存在異步替代方案卻使用同步代碼,會降低您以更低成本進行擴展的能力。因為阻塞的線程會消耗資源。

成功的現代應用程序需要使用異步代碼。若沒有語言支持,編寫異步代碼就需要使用回調函數、完成事件或其他會使代碼原本意圖變得模糊的手段。同步代碼的優勢在於其逐步執行的特性,這使得代碼易於瀏覽和理解。而傳統的異步模型則迫使您關注代碼的異步特性,而非其基本操作。

不要阻塞,而是等待

之前的代碼揭示了一種不良的編程習慣:編寫同步代碼來執行異步操作。這段代碼會使當前線程停止執行任何其他工作。在有運行任務的情況下,代碼不會中斷該線程。這種模式的結果類似於你把雞蛋放進煎鍋後一直盯着它。你忽略任何中斷,並且直到煎蛋好了才開始其他任務。你不會從冰箱裏拿出牛排和饅頭片去煎饅頭片。你可能會錯過看到爐灶起火的情況。你希望既能煎雞蛋又能煎牛排。你的代碼也是如此。

您可以先更新代碼,使線程在任務運行期間不會阻塞。“await” 關鍵字提供了一種非阻塞的方式來啓動任務,然後在任務完成時繼續執行。早餐代碼的一個簡單異步版本如下所示:

List<LEI奶粉> CFs = await FFY泡牛奶 ( 10 , 2 );
foreach ( LEI奶粉 k in CFs )
    {
    Console . WriteLine ( $"牛奶煮好了!{k}" );
    }
List < LEI煎雞蛋 > JJDs = await FFY煎蛋 ( 3 );
Console . WriteLine ( $"雞蛋煎好了 {JJDs . Count} 個!" );

static async Task <List <LEI奶粉> > FFY泡牛奶 ( double 奶粉 , int 杯 )
    {
    List < LEI奶粉 > lbcf = [ ];
    Console . WriteLine ( "泡牛奶中……" );
    double CF每杯 = 奶粉 / 杯;
    for ( int i = 0 ; i < 杯 ; i++ )
        {
        await Task . Delay ( 5000 ); // 2 秒沖水,3 秒攪拌
        lbcf . Add ( new LEI奶粉 ( CF每杯 ) );
        }
    return lbcf;
    }

static async Task <List < LEI煎雞蛋 > > FFY煎蛋 ( int 生雞蛋 )
    {
    List < LEI煎雞蛋 > lbjjd = [ ];
    Console . WriteLine ( "煎雞蛋中……" );
    while ( 生雞蛋 > 0 )
        {
        if ( 生雞蛋 == 1 )
            {
            await Task . Delay ( 5000 ) ;
            lbjjd . Add ( new LEI煎雞蛋 ( ) );
            生雞蛋 -= 1;
            }
        else
            {
            await Task . Delay ( 7000 );
            lbjjd . Add ( new LEI煎雞蛋 ( ) );
            lbjjd . Add ( new LEI煎雞蛋 ( ) );
            生雞蛋 -= 2;
            }
        }
    return lbjjd;
    }

public class LEI牛奶 ( double 每杯含量 )
    {
    string ZFC濃淡 => FF濃淡 ( 每杯含量 );
    string FF濃淡 ( double 每杯含量 )
        {
        switch ( 每杯含量 )
            {
            case <= 0:
                return ( $"白開水" );
            case <= 2:
                return ( $"淡" );
            case <= 6:
                return ( $"濃" );
            default:
                return ( $"泡不開" );
            }
        }

public class LEI煎雞蛋
    {
    }

該代碼會更新 FF煎蛋、FF泡奶粉 這兩個方法的原始主體,使其分別返回類型為 LEI煎雞蛋、LEI牛奶 的任務對象。更新後的方法名稱都帶有 “FFY” 後綴:FFY煎蛋、FFY泡牛奶。主方法返回任務對象,儘管它沒有返回表達式,這是設計上的要求。

注意:更新後的代碼尚未充分利用異步編程的關鍵特性,這可能會縮短完成時間。該代碼處理任務所花費的時間與最初的同步版本大致相同。

讓我們將早餐的例子應用到更新後的代碼中。在雞蛋或牛排正在烹飪的過程中,該線程不會被阻塞,但代碼也不會在當前任務完成之前啓動其他任務。您仍然將饅頭片放入煎鍋中,然後盯着煎鍋直到饅頭片煎好,但現在您能夠應對干擾了。在多個訂單同時進行的餐廳裏,廚師可以在某道菜正在烹飪時開始新的訂單。

在更新後的代碼中,負責準備早餐的線程在等待任何尚未完成的已啓動任務時不會被阻塞。對於某些應用程序而言,這一改動就是您所需要的一切。您可以讓您的應用程序在從網絡下載數據的同時支持用户交互。在其他情況下,您可能希望在等待前一個任務完成的同時啓動其他任務。

同時啓動任務

對於大多數操作而言,您希望立即啓動多個獨立的任務。每當一個任務完成時,您就會啓動其他準備就緒的任務。當您將這種方法應用於早餐的例子中時,您能夠更快地準備早餐。而且所有準備工作都能在相近的時間內完成,這樣您就能享用熱乎乎的早餐了。

System . Threading . Tasks . Task 類及其相關類型是您可以用來將這種思維方式應用於正在進行的任務的類。這種方法使您能夠編寫更貼近實際生活中的早餐製作方式的代碼。您同時開始烹飪雞蛋、土豆絲和饅頭片。由於每種食物都需要進行操作,您會將注意力轉向該任務,處理該操作,然後等待需要您關注的其他事情。

在您的代碼中,您會啓動一個任務,並持有代表該工作的 Task 對象。您會通過調用任務的 await 方法來延遲對工作的處理,直到結果準備好為止。

將這些更改應用到早餐代碼中。第一步是當操作開始時就存儲操作任務,而不是使用 await 表達式:

static ( List < LEI煎饅頭片 > 煎好的 , int 剩餘饅頭片 , int 剩餘雞蛋 ) FF煎饅頭片 ( int 生饅頭片 , int 生雞蛋 , MJ熟度 熟度 )
    {
    List<LEI煎饅頭片> 結果 = [ ];
    int 剩餘饅頭 = 生饅頭片;
    int 剩餘雞蛋 = 生雞蛋;

    int 目標耗時 = 熟度 switch
        {
            MJ熟度 . 熟 => 5,
            MJ熟度 . 過熟 => 6,
            _ => 0 // 生的不用等,直接算沒煎好
            };

    Console . WriteLine ( "開始煎饅頭片……" );

    // 循環煎,直到不符合條件(條件是一鍋可以煎 5 片,或者 4 片,佔用 1 個雞蛋)
    while ( true )
        {
        // 退出條件:雞蛋沒了,或者剩餘饅頭片 < 4
        if ( 剩餘雞蛋 <= 0 || 剩餘饅頭 < 4 )
            {
            break;
            }

        // 每鍋每個雞蛋最多煎 5 片,最少 4 片(剩下的可能 4 片)
        int 本次煎的片數 = Math . Min ( 剩餘饅頭 , 5 ); // 不超過剩餘饅頭,最多 5 片
        bool 煎熟了 = 目標耗時 >= 5; // 不足 5 秒算不熟


        if ( 煎熟了 )
            {
            Console . WriteLine ( $"煎 {本次煎的片數} 片,等待 {目標耗時} 秒……" );
            Thread . Sleep ( 目標耗時 * 1000 ); // 真實等待
            }
        else
            {
            Console . WriteLine ( $"煎 {本次煎的片數} 片,但時間不足 5 秒,沒煎好……" );
            }

        剩餘雞蛋--;
        剩餘饅頭 -= 本次煎的片數;
        結果 . Add ( new LEI煎饅頭片 (
            煎熟了 ? 熟度 : MJ熟度 . 生 ,
            本次煎的片數 ,
            煎熟了 ? 目標耗時 : 0
        ) );
        Console . WriteLine ( $"完成!狀態:{結果 . Last ( ) . 熟度描述}\n" );
        }

    Console . WriteLine ( $"=== 最終剩餘:饅頭 {剩餘饅頭} 片,雞蛋 {剩餘雞蛋} 個 ===" );
    return ( 結果 , 剩餘饅頭 , 剩餘雞蛋 );
    }

public class LEI煎饅頭片
    {
    // 屬性:熟度、實際煎了多少片
    public string 熟度描述
        {
        get;
        }
    public int 實際煎的片數
        {
        get;
        }

    public int 實際耗時
        {
        get;
        }

    // 構造函數:傳入熟度和煎的片數
    public LEI煎饅頭片 ( MJ熟度 熟 , int 片數 , int 耗時 )
        {
        熟度描述 = 熟 switch
            {
                MJ熟度 . 熟 => "正常(5秒)",
                MJ熟度 . 過熟 => "有點糊(6秒)",
                _ => "沒煎好(時間不夠)"
                };
        實際煎的片數 = 片數;
        實際耗時 = 耗時;
        }
    }

public enum MJ熟度
        {
        生 = 255,
        熟 = 0,
        過熟 = 1,
        }

這些修改並不能讓你更快地準備好早餐。一旦任務開始,“await” 表達式就會應用於所有任務。接下來的步驟是將烤饅頭片和煎雞蛋的 “await” 表達式移到方法的末尾,也就是在準備早餐之前進行操作:

DateTime QI = DateTime . Now;
// 1. 調用異步方法,獲取任務(無需 as 轉換)
Task < List < LEI牛奶 >? > 牛奶任務 = LEI牛奶 . FF泡牛奶 ( 10 , 2 );
Task < List < LEI煎饅頭片 . LEI鍋次信息 >? > 煎饅頭片任務 = LEI煎饅頭片 . FF煎饅頭片 ( 14 , 4 , MJ熟度 . 熟 );
Task < List < LEI煎牛排 . LEI鍋次信息 >? > 煎牛排任務 = LEI煎牛排 . FF煎牛排 ( 4 , MJ熟度 . 七成熟 );
Task < List < LEI牛奶 >? > 白開水任務 = LEI牛奶 . FF泡牛奶 ( 0 , 2 );
// 2. 等待任務完成,拿到實際的牛奶集合(關鍵步驟)
List < LEI牛奶 > ? 所有牛奶 = 牛奶任務 . Result ?? [ ]; // 同步等待(控制枱可用)
List < LEI煎饅頭片 . LEI鍋次信息 > ? 所有熟煎饅頭片 = 煎饅頭片任務 . Result ?? [ ];
List < LEI煎牛排 . LEI鍋次信息 > ? 所有熟煎牛排 = 煎牛排任務 . Result ?? [ ];
List < LEI牛奶 > ? 所有白開水 = 白開水任務 . Result ?? [ ]; // 同步等待(控制枱可用)
// 或在 async 方法中用:List < LEI牛奶 > 所有牛奶 = await 牛奶任務;

Console . WriteLine ( "\n我們的早餐:" );
// 3. 遍歷集合,輸出具體信息(建議重寫 ToString,或用屬性)
foreach ( LEI牛奶 單杯牛奶 in 所有牛奶 )
    {
    // 輸出濃淡信息(用已有的 ZFC濃淡 屬性)
    Console . WriteLine ( $"奶泡好了!這杯是 {單杯牛奶}~" );
    }
foreach ( LEI煎饅頭片 . LEI鍋次信息 Guo in 所有熟煎饅頭片 )
    {
    Console . WriteLine ( $"饅頭片煎好了!這鍋是 {Guo}" );
    }
foreach ( LEI煎牛排 . LEI鍋次信息 Guo in 所有熟煎牛排 )
    {
    Console . WriteLine ( $"牛排煎好了!這鍋是 {Guo}" );
    }
foreach ( LEI牛奶 單杯水 in 所有白開水 )
    {
    // 輸出濃淡信息(用已有的 ZFC濃淡 屬性)
    Console . WriteLine ( $"熱水來了!這杯是 {單杯水}~" );
    }
DateTime JS = DateTime . Now;
TimeSpan Shi = JS - QI;
Console . WriteLine ( $"\n共用時 {Shi . Seconds} 秒" );



public class LEI牛奶 ( double 每杯含量 )
    {
    string ZFC濃淡 => FF濃淡 ( 每杯含量 );
    static string FF濃淡 ( double 每杯含量 )
        {
        return 每杯含量 switch
            {
                <= 0 => ( $"清" ),
                <= 2 => ( $"淡" ),
                <= 6 => ( $"濃" ),
                _ => ( $"泡不開" ),
                };
        }

    public static async Task < List < LEI牛奶 >? > FF泡牛奶 ( int 奶粉數 , int 杯 )
        {
            {
            List < LEI牛奶 > 泡好的牛奶 = [ ]; // 用於存儲生成的牛奶實例
            Console . WriteLine ( $"開始{( 奶粉數 <= 2 ? "倒水" : "泡牛奶" )}了:" );
            if ( 杯 <= 0 )
                {
                Console . WriteLine ( "杯數不能為 0 或負數!" );
                return null; // 返回空集合
                }

            double SJD杯量 = (double)奶粉數 / 杯;

            if ( SJD杯量 > 6 )
                {
                Console . WriteLine ( "泡不開!" );
                return null; // 返回空集合
                }

            for ( int i = 0 ; i < 杯 ; i++ )
                {
                int sj;
                if ( SJD杯量 <= 2 )
                    sj = 3000;
                else
                    sj = 5000;
                await Task . Delay ( sj );
                LEI牛奶 新牛奶 = new ( SJD杯量 ); // 每杯牛奶創建一個實例
                泡好的牛奶 . Add ( 新牛奶 ); // 加入集合
                Console . WriteLine ( $"{i + 1} 杯 {( FF濃淡 ( SJD杯量 ) == "清" ? "白開水。" : $"{FF濃淡 ( SJD杯量 )} 牛奶。" )}" );
                }

            return 泡好的牛奶; // 返回所有泡好的牛奶集合
            }
        }
    public override string ToString ( )
        {
        if ( 每杯含量 >= 2 )
            return $"{每杯含量} 單位的 {ZFC濃淡} 牛奶";
        else
            return "白開水";
        }
    }

public enum MJ熟度
    {
    熟 = 1,
    過熟 = 2,
    半熟 = 3, // 煎牛排
    七成熟 = 4, // 煎牛排
    }

public class LEI煎饅頭片 ( MJ熟度 熟度 , int 鍋次 )
    {
    public MJ熟度 當前熟度 { get; set; } = 熟度;
    public int 所在鍋次 { get; set; } = 鍋次;
    public int 片數 { get; set; }

    // 新增:記錄每鍋的信息(鍋次、片數、熟度)
    public class LEI鍋次信息 ( int 鍋次 , int 片數 , MJ熟度 熟度 )
        {
        public int 鍋次 { get; set; } = 鍋次;
        public int 片數 { get; set; } = 片數;
        public MJ熟度 熟度 { get; set; } = 熟度;

        // 重寫ToString,直接輸出整鍋信息
        public override string ToString ( )
            {
            string 熟度描述 = 熟度 switch
                {
                    MJ熟度.熟 => "煎熟",
                    MJ熟度.過熟 => "過熟",
                    _ => "不符合要求"
                };
            return $"第 {鍋次} 鍋的 {片數} 片 {熟度描述} 的饅頭片";
            }
        }

    public static async Task < List < LEI鍋次信息 >? > FF煎饅頭片 ( int 生饅頭片 , int 生雞蛋 , MJ熟度 熟度 )
        {
        int sheng = 生饅頭片 , jd = 生雞蛋 , GC = 1;
        int sj = 熟度 == MJ熟度 . 熟 ? 5000 : 7000;
        List < LEI鍋次信息 > GCs = [ ];
        if ( sheng <= 3 || jd <= 0 ) { ArgumentOutOfRangeException CLYC = new ( "材料不全,生饅頭片必須至少 4 片,生雞蛋至少 1 個" ); throw CLYC; }
        List < LEI煎饅頭片 > LBKM = [ ];
        Console . WriteLine ( $"開始煎饅頭片啦:~ 用了 {生雞蛋} 個雞蛋,共 {生饅頭片} 片" );
        while ( jd > 0 && sheng >= 4 )
            {
            // 決定本鍋煎幾片:夠 5 片就煎 5 片,否則煎 4 片(但不能超過剩餘量)
            int SL = sheng >= 5 ? 5 : 4;

            Console . WriteLine ( $"第 {GC} 鍋開始煎饅頭片({SL} 片),需要 {sj / 1000} 秒~" );
            await Task . Delay ( sj ); // 模擬一鍋的煎制時間

            // 記錄本鍋次信息
            GCs . Add ( new LEI鍋次信息 ( GC , SL , 熟度 ) );

            // 記錄本鍋煎好的饅頭片
            for ( int i = 0 ; i < SL ; i++ )
                {
                LBKM . Add ( new LEI煎饅頭片 ( 熟度 , GC ) );
                }

            // 更新剩餘材料
            jd --;
            sheng -= SL;
            Console . WriteLine ( $"第 {GC} 鍋饅頭片完成!用了 1 個蛋,煎了 {SL} 片,剩餘:{sheng} 片,{jd} 個蛋" );

            GC ++;
            }

        Console . WriteLine ( $"共煎完饅頭片 {GCs . Sum ( g => g . 片數 )} 片,用了 {GCs . Count} 個蛋" );

        return GCs;
        }
    }

public class LEI煎牛排 ( MJ熟度 熟度 , int 鍋次 )
    {
    public MJ熟度 當前熟度 { get; set; } = 熟度;
    public int 所在鍋次 { get; set; } = 鍋次;
    public int 片數 { get; set; }

    // 記錄每鍋的信息(鍋次、片數、熟度)
    public class LEI鍋次信息 ( int 鍋次 , int 片數 , MJ熟度 熟度 )
        {
        public int 鍋次 { get; set; } = 鍋次;
        public int 片數 { get; set; } = 片數;
        public MJ熟度 熟度 { get; set; } = 熟度;

        // 重寫ToString,直接輸出整鍋信息
        public override string ToString ( )
            {
            string 熟度描述 = 熟度 switch
                {
                    MJ熟度 . 熟 => "煎熟",
                    MJ熟度 . 過熟 => "過熟",
                    MJ熟度 . 半熟 => "半熟",
                    MJ熟度 . 七成熟 => "七成熟",
                    _ => "沒煎熟",
                    };
            return $"第 {鍋次} 鍋的 {片數} 片 {熟度描述} 的牛排";
            }
        }

    public static async Task < List < LEI鍋次信息 >? > FF煎牛排 ( int 生牛排 , MJ熟度 熟度 )
        {
        int sheng = 生牛排 , GC = 1;
        int sj = 熟度 switch
            {
                MJ熟度.熟 => 5000,
                MJ熟度.過熟 => 7000,
                MJ熟度.半熟 => 2500,
                MJ熟度.七成熟 => 3500,
                _ => 0 // 生的
            };
        List < LEI鍋次信息 > GCs = [ ];
        List < LEI煎牛排 > LBNP = [ ];
        if ( sheng >= 1 )
            Console . WriteLine ( $"開始煎牛排啦:~ 共 {生牛排} 片" );
        else
            Console . WriteLine ( "沒有生牛排了……" );
            
        while ( sheng >= 1 )
            {
            // 決定本鍋煎幾片:夠 2 片就煎 2 片,否則煎 1 片(但不能超過剩餘量)
            int SL = sheng >= 2 ? 2 : 1;

            Console . WriteLine ( $"第 {GC} 鍋牛排開始煎({SL} 片),需要 {( double ) sj / 1000} 秒~" );
            await Task . Delay ( sj ); // 模擬一鍋的煎制時間

            // 記錄本鍋次信息
            GCs . Add ( new LEI鍋次信息 ( GC , SL , 熟度 ) );

            // 記錄本鍋煎好的牛排
            for ( int i = 0 ; i < SL ; i++ )
                {
                    LBNP . Add ( new LEI煎牛排 ( 熟度 , GC ) );
                }

            // 更新剩餘材料
            sheng -= SL;
            Console . WriteLine ( $"第 {GC} 鍋牛排完成!煎了 {SL} 片,剩餘:{sheng} 片" );

            GC++;
            }

        Console . WriteLine ( $"共煎完牛排 {GCs . Sum ( g => g . 片數 )} 片" );

        return GCs;
        }
    }

現在您享用的是一份異步準備的早餐,其準備時間約為 15 秒。總烹飪時間得以縮短,這是因為所有任務可以同時進行。

步驟 操作 狀態反饋
1 泡牛奶(分濃淡 / 白開水) 開始泡牛奶 / 倒水 → 每杯完成提示 → 全部泡好彙總
2 煎饅頭片(分鍋,每鍋用蛋) 第 1 鍋開始(5 片) → 第 1 鍋完成(用 1 蛋) → 第 2 鍋開始(5 片) → 第 2 鍋完成(用 1 蛋) → 第 3 鍋開始(4 片) → 第 3 鍋完成(用 1 蛋) → 全部煎完彙總
3 煎牛排(分鍋,定熟度) 第 1 鍋開始(2 片,七成熟) → 第 1 鍋完成 → 第 2 鍋開始(2 片,七成熟) → 第 2 鍋完成 → 全部煎完匯

該代碼更新改進了準備工作流程,通過縮短烹飪時間來實現這一目標,但同時也引入了缺陷,導致雞蛋和牛排可能燒焦了。您一次性啓動所有異步任務。只有在需要結果時才等待每個任務。該代碼可能類似於在 Web 應用程序中執行的程序,該程序向不同的微服務發出請求,然後將結果組合成一個頁面。您立即發出所有請求,然後對所有這些任務應用 await 表達式,並構建網頁。

通過任務來支持組合

之前的代碼修訂使得同時準備早餐的所有環節都已就緒,除了吐司。製作吐司的過程是由一個異步操作(烤麪包)與一系列同步操作(在吐司上塗抹黃油和果醬)組合而成的。這個示例説明了異步編程中一個重要的概念:

重要事項:一個先進行異步操作隨後再進行同步操作的過程也是一個異步操作。換句話説,如果操作的任何一部分是異步的,那麼整個操作就是異步的。

在之前的更新內容中,您已經瞭解瞭如何使用 Task 或 Task < TResult > 對象來保存正在執行的任務。在使用每個任務的結果之前,您需要先等待該任務完成。接下來的步驟是創建能夠代表其他工作組合的方法。在準備早餐之前,您需要先等待代表將麪包烤好(即準備好麪包)的任務完成,然後再塗抹黃油和果醬。

您可以使用以下代碼來表示這項工作:

static async Task < LEI吐司 > FF製作吐司 ( int 麪包 )
    {
        var TSs = await FFY吐司麪包 ( 麪包 );
        FF黃油 ( TSs );
        FF果醬 ( TSs );
    }

“FF製作吐司” 方法在其簽名中帶有 “async” 修飾符,這向編譯器表明該方法包含一個 “await” 表達式以及包含異步操作。該方法代表的是將麪包烤好、塗抹黃油和果醬的整個過程。該方法返回一個 “Task < LEI吐司 >” 對象,該對象代表了這三項操作的組合。

修改後的主代碼塊現在看起來是這樣的:

……
    var TSs = await 吐司任務;
    Console . WriteLine ( "吐司準備好了" );
……

這段代碼更改展示了處理異步代碼的一種重要技巧。您通過將操作分離到一個新的方法中來構建任務,該方法會返回一個任務。您可以決定何時等待該任務。您還可以同時啓動其他任務。

處理異步異常

到目前為止,您的代碼默認假定所有任務都能成功完成。異步方法也會拋出異常,就像其同步版本一樣。對於異常和錯誤處理的異步支持,其目標與一般異步支持的目標相同。最佳實踐是編寫看起來像一系列同步語句的代碼。當任務無法成功完成時,任務會拋出異常。當將 await 表達式應用於已啓動的任務時,客户端代碼可以捕獲這些異常。

在早餐示例中,假設在煎牛排時煎鍋起火了。為模擬這一問題,您可以修改 FF煎牛排 方法,使其符合以下代碼:

    public static async Task < List < LEI鍋次信息 >? > FF煎牛排 ( int 生牛排 , MJ熟度 熟度 )
        {
……
        if ( sheng >= 1 )
            {
            Console . WriteLine ( $"開始煎牛排啦:~ 共 {生牛排} 片" );
            await Task . Delay ( 2000 );
            Console . WriteLine ( "起火了!牛排毀了!" );
            throw new InvalidOperationException ( "煎鍋着火了" );
            }
        else
            Console . WriteLine ( "沒有生牛排了……" );
……
        }

List < LEI煎牛排 . LEI鍋次信息 > ? 所有熟煎牛排 = null;
try
    { 所有熟煎牛排 = 煎牛排任務 . Result ?? [ ]; }
catch ( Exception y ) { Console . WriteLine ( y . Message ); }

if ( 所有熟煎牛排 != null )
    {
    foreach ( LEI煎牛排 . LEI鍋次信息 Guo in 所有熟煎牛排 )
        {
        Console . WriteLine ( $"牛排煎好了!這鍋是 {Guo}" );
        }
    }
else
    {
    Console . WriteLine ( "沒有成功煎好的牛排~" );
    }

請注意,在煎牛排鍋起火到系統檢測到異常這段時間內,有相當多的任務已經完成。當一個異步運行的任務拋出異常時,該任務會被標記為故障狀態。Task 對象會將拋出的異常存儲在 Task . Exception 屬性中。當將 await 表達式應用於故障任務時,該任務會拋出異常。

關於這一過程,有兩點重要的機制需要了解:

  • 異常在故障任務中的存儲方式
  • 當代碼在等待(等待操作)故障任務時,如何對異常進行解包並重新拋出

當異步運行的代碼拋出異常時,該異常會存儲在任務對象中。Task . Exception 屬性是一個 System . AggregateException 對象,因為在異步工作過程中可能會拋出多個異常。任何拋出的異常都會添加到 AggregateException . InnerExceptions 集合中。如果 Exception 屬性為空,則會創建一個新的 AggregateException 對象,並且拋出的異常是該集合中的第一個元素。

對於出現故障的任務,最常見的情況是 “異常” 屬性中恰好包含一個異常。當您的代碼等待一個出現故障的任務時,它會重新拋出集合中第一個 “AggregateException” 對象的 “InnerExceptions” 子對象。這就是為什麼示例中的輸出顯示的是 “System . InvalidOperationException” 對象,而不是 “AggregateException” 對象的原因。提取第一個內部異常可以使使用異步方法的操作儘可能與使用其同步版本的操作相似。當您的情況可能會產生多個異常時,您可以在代碼中檢查 “Exception” 屬性。

提示:推薦的做法是,任何參數驗證異常都應從任務返回方法中同步出現。

在繼續進入下一節之前,請在您的 FF煎牛排 方法中將關於起火的代碼註釋掉。您不想再引發新的火災了。

高效地應用 await 表達式到任務中

您可以通過使用 Task 類的方法來改進前面代碼末尾的一系列 await 表達式。其中一個 API 是 WhenAll 方法,它返回一個任務對象,該對象在其參數列表中的所有任務都完成時才完成。以下代碼演示了此方法:

DateTime QI = DateTime . Now;
// 1. 調用異步方法,獲取任務(無需 as 轉換)
Task < List < LEI牛奶 >? > 牛奶任務 = LEI牛奶 . FF泡牛奶 ( 10 , 2 );
Task < List < LEI煎饅頭片 . LEI鍋次信息 >? > 煎饅頭片任務 = LEI煎饅頭片 . FF煎饅頭片 ( 14 , 4 , MJ熟度 . 熟 );
Task < List < LEI煎牛排 . LEI鍋次信息 >? > 煎牛排任務 = LEI煎牛排 . FF煎牛排 ( 4 , MJ熟度 . 七成熟 );
Task < List < LEI牛奶 >? > 白開水任務 = LEI牛奶 . FF泡牛奶 ( 0 , 2 );
// 2. 等待任務完成,拿到實際的牛奶集合(關鍵步驟)
await Task . WhenAll ( 牛奶任務 , 煎饅頭片任務 , 煎牛排任務 , 白開水任務 );
if ( 牛奶任務 . Result != null )
    {
    Console . WriteLine ( $"共泡了 {牛奶任務 . Result . Count} 杯牛奶。" );
    }
else
    {
    Console . WriteLine ( "沒泡牛奶。" );
    }

if ( 煎牛排任務 . Result != null )
    {
    Console . WriteLine ( $"共煎了 {煎牛排任務 . Result . Count} 片牛排。" );
    }
else
    {
    Console . WriteLine ( "沒煎牛排。" );
    }

if ( 煎饅頭片任務 . Result != null )
    {
    Console . WriteLine ( $"共煎了 {煎饅頭片任務 . Result . Count} 片饅頭片。" );
    }
else
    {
    Console . WriteLine ( "沒煎饅頭片。" );
    }

if ( 白開水任務 . Result != null )
    {
    Console . WriteLine ( $"共倒了 {白開水任務 . Result . Count} 杯白開水。" );
    }
else
    {
    Console . WriteLine ( "沒倒白開水。" );
    }

DateTime JS = DateTime . Now;
TimeSpan Shi = JS - QI;
Console . WriteLine ( $"\n共用時 {Shi . TotalSeconds} 秒" );

現在,假設你只有一個煎鍋,先煎完牛排,再煎饅頭片:

DateTime QI = DateTime . Now;
// 1. 調用異步方法,獲取任務(無需 as 轉換)
Task < List < LEI牛奶 >? > 牛奶任務 = LEI牛奶 . FF泡牛奶 ( 10 , 2 );
Task < List < LEI煎牛排 . LEI鍋次信息 >? > 煎牛排任務 = LEI煎牛排 . FF煎牛排 ( 4 , MJ熟度 . 七成熟 );
Task < List < LEI牛奶 >? > 白開水任務 = LEI牛奶 . FF泡牛奶 ( 0 , 2 );
// 2. 等待任務完成,拿到實際的牛奶集合(關鍵步驟)
await Task . WhenAll ( 牛奶任務 , 煎牛排任務 , 白開水任務 );
if ( 牛奶任務 . Result != null )
    {
    Console . WriteLine ( $"共泡了 {牛奶任務 . Result . Count} 杯牛奶。" );
    }
else
    {
    Console . WriteLine ( "沒泡牛奶。" );
    }

if ( 煎牛排任務 . Result != null )
    {
    Console . WriteLine ( $"共煎了 {煎牛排任務 . Result . Count} 片牛排。" );
    }
else
    {
    Console . WriteLine ( "沒煎牛排。" );
    }

if ( 白開水任務 . Result != null )
    {
    Console . WriteLine ( $"共倒了 {白開水任務 . Result . Count} 杯白開水。" );
    }
else
    {
    Console . WriteLine ( "沒倒白開水。" );
    }

Task < List < LEI煎饅頭片 . LEI鍋次信息 >? > 煎饅頭片任務 = LEI煎饅頭片 . FF煎饅頭片 ( 14 , 4 , MJ熟度 . 熟 );
List < LEI煎饅頭片 . LEI鍋次信息 > ? 所有熟煎饅頭片 = 煎饅頭片任務 . Result ?? [ ];
if ( 所有熟煎饅頭片 != null )
    {
    Console . WriteLine ( $"共煎了 {所有熟煎饅頭片 . Count} 鍋饅頭片。" );
    }
else
    {
    Console . WriteLine ( "沒煎饅頭片。" );
    }

DateTime JS = DateTime . Now;
TimeSpan Shi = JS - QI;
Console . WriteLine ( $"\n共用時 {Shi . TotalSeconds} 秒" );

另一種選擇是使用 “WhenAny” 方法,該方法會返回一個 “Task < Task >” 對象,該對象會在其任一參數完成時完成。您可以等待返回的任務,因為您知道該任務已經完成。以下代碼展示瞭如何使用 “WhenAny” 方法等待第一個任務完成,然後處理其結果。在處理完已完成任務的結果後,您應將該已完成任務從傳遞給 “WhenAny” 方法的任務列表中移除。

Task < List < LEI牛奶 >? > 牛奶任務 = LEI牛奶 . FF泡牛奶 ( 10 , 2 );
Task < List < LEI煎牛排 . LEI鍋次信息 >? > 煎牛排任務 = LEI煎牛排 . FF煎牛排 ( 4 , MJ熟度 . 七成熟 );
Task < List < LEI煎饅頭片 . LEI鍋次信息 >? > 煎饅頭片任務 = LEI煎饅頭片 . FF煎饅頭片 ( 4 , 6 , MJ熟度 . 熟 );
Task < List < LEI牛奶 >? > 白開水任務 = LEI牛奶 . FF泡牛奶 ( 0 , 2 );

var RW早餐 = new List < Task > { 牛奶任務 , 煎牛排任務 , 煎饅頭片任務 , 白開水任務 };
while ( RW早餐 . Count > 0 )
    {
    Task RW完成 = await Task . WhenAny ( RW早餐 );
    if ( RW完成 == 牛奶任務 )
        {
        Console . WriteLine ( "牛奶泡好了!" );
        }
    else if ( RW完成 == 煎牛排任務 )
        {
        Console . WriteLine ( "牛排煎好了!" );
        }
    else if ( RW完成 == 煎饅頭片任務 )
        {
        Console . WriteLine ( "饅頭片煎好了!" );
        }
    else if ( RW完成 == 白開水任務 )
        {
        Console . WriteLine ( "熱水倒好了!" );
        }
    await RW完成;
    RW早餐 . Remove ( RW完成 );
    }

在代碼片段的末尾,注意 “await RW完成;” 這一行。這一行非常重要,因為 “Task . WhenAny” 會返回一個 “Task < Task >” - 一個包含已完成任務的包裝任務。當您調用 “await Task . WhenAny” 時,您是在等待包裝任務完成,而結果就是最先完成的實際任務。然而,要獲取該任務的結果或確保任何異常能被正確拋出,您必須等待已完成的任務本身(存儲在 “RW完成” 中)。儘管您知道該任務已經完成,但再次對其進行等待可以讓您訪問其結果或處理可能導致其出錯的任何異常。

審查最終代碼

這段代碼大約在 10 ~ 25 秒內完成異步早餐任務。由於部分任務是併發運行的,總時間有所減少。該代碼會同時監控多項任務,並僅在需要時採取行動。

最終代碼是異步的。它更準確地反映了一個人可能如何做早餐。將最終代碼與文章中的第一個代碼示例進行比較。通過閲讀代碼,核心操作仍然清晰可見。你可以像閲讀文章開頭所示的那份做早餐的指令清單一樣閲讀最終代碼。async 和 await 關鍵字的語言特性實現了每個人在遵循書面指令時所做的轉換:儘可能啓動任務,並且在等待任務完成時不要阻塞。

Async/await 與 ContinueWith

async 和 await 關鍵字相比直接使用 Task . ContinueWith 提供了語法上的簡化。雖然 async/await 和 ContinueWith 在處理異步操作時具有相似的語義,但編譯器不一定會將 await 表達式直接轉換為 ContinueWith 方法調用。相反,編譯器會生成優化的狀態機代碼,以提供相同的邏輯行為。這種轉換帶來了顯著的可讀性和可維護性優勢,尤其是在鏈接多個異步操作時。
試想一個需要執行多個連續異步操作的場景。下面是使用 ContinueWith 與使用 async/await 實現相同邏輯時的對比:

使用 ContinueWith

使用 ContinueWith 時,異步操作序列中的每個步驟都需要嵌套的延續:

// 調用異步方法,獲取任務(無需 as 轉換)
Task < List < LEI牛奶 >? > 牛奶任務 = LEI牛奶 . FF泡牛奶 ( 10 , 2 );
Task < List < LEI煎牛排 . LEI鍋次信息 >? > 煎牛排任務 = LEI煎牛排 . FF煎牛排 ( 4 , MJ熟度 . 七成熟 );
Task < List < LEI煎饅頭片 . LEI鍋次信息 >? > 煎饅頭片任務 = LEI煎饅頭片 . FF煎饅頭片 ( 4 , 6 , MJ熟度 . 熟 );
Task < List < LEI牛奶 >? > 白開水任務 = LEI牛奶 . FF泡牛奶 ( 0 , 2 );

static Task FF使用ContinueWith ( )
    {
    // 第一步:煮牛奶
    return LEI牛奶 . FF泡牛奶 ( 10 , 2 )
        . ContinueWith ( 牛奶任務 =>
        {
            var 牛奶 = 牛奶任務 . Result;
            Console . WriteLine ( "牛奶煮好了,開始煎牛排..." );
            return LEI煎牛排 . FF煎牛排 ( 3 , MJ熟度.七成熟 ); // 第二步:煎牛排(等牛奶做完)
        } )
        . Unwrap ( )
        . ContinueWith ( 牛排任務 =>
        {
            var 牛排 = 牛排任務 . Result;
            Console . WriteLine ( "牛排煎好了,開始煎饅頭片..." );
            return LEI煎饅頭片 .FF煎饅頭片 ( 14 , 6 , MJ熟度.熟 ); // 第三步:煎饅頭片(等牛排做完)
        } )
        . Unwrap ( )
        . ContinueWith ( 饅頭片任務 =>
        {
            var 饅頭片 = 饅頭片任務 . Result;
            Console . WriteLine ( "饅頭片煎好了,開始倒白開水..." );
            return LEI牛奶 . FF泡牛奶 ( 0 , 2 ); // 第四步:倒白開水(等饅頭片做完)
        } )
        . Unwrap ( )
        . ContinueWith ( 白開水任務 =>
        {
            Console . WriteLine ( "所有早餐都準備好了!用 ContinueWith 完成流程~" );
        } );
    }
await FF使用ContinueWith ( );

DateTime JS = DateTime . Now;
TimeSpan Shi = JS - QI;
Console . WriteLine ( $"\n共用時 {Shi . TotalSeconds} 秒" );

使用 async/await

使用 async/await 的相同操作序列讀起來要自然得多:

static async Task FF使用AsyncAwait ( )
    {
    // 第一步:煮牛奶
    var nns = await LEI牛奶 . FF泡牛奶 ( 10 , 2 );
    Console . WriteLine ( "await 牛奶泡好了!" );
    // 第二步:煎牛排(等牛奶做完)
    var nps = await LEI煎牛排 . FF煎牛排 ( 3 , MJ熟度.七成熟 );
    Console . WriteLine ( "await 牛排煎好了!" );
    // 第三步:煎饅頭片(等牛排做完)
    var mtps = await LEI煎饅頭片 . FF煎饅頭片 ( 10 , 2 , MJ熟度.熟 );
    Console . WriteLine ( "await 饅頭片煎好了!" );
    // 第四步:倒白開水(等饅頭片做完)
    var _ = await LEI牛奶 . FF泡牛奶 ( 0 , 2 );
    Console . WriteLine ( "await 熱水倒好了!" );
    }
await FF使用AsyncAwait ( );

為什麼更推薦使用 async/await

async/await 方法有幾個優點:

  • 可讀性:該代碼讀起來就像是同步代碼,這使得理解操作流程變得更加容易。
  • 可維護性:在序列中添加或刪除步驟只需進行少量的代碼修改。
  • 錯誤處理:使用 try/catch 塊進行異常處理的方式自然流暢,而 ContinueWith 則需要對故障任務進行謹慎處理。
  • 調試:使用 async/await 的調用棧和調試器體驗要好得多。
  • 性能:對於 async/await,編譯器的優化比手動的 ContinueWith 鏈更復雜。

隨着鏈式操作數量的增加,這種優勢會變得更加明顯。雖然單個延續使用 ContinueWith 可以處理得較為輕鬆,但 3 個或更多個異步操作的序列很快就會變得難以閲讀和維護。這種模式,在函數式編程中被稱為 “單態 do-語法”,允許您以順序、可讀的方式組合多個異步操作。

異步編程場景

如果您的代碼需要處理與輸入/輸出相關的任務,以支持網絡數據請求、數據庫訪問或文件系統讀寫操作,那麼使用異步編程是最優選擇。對於像複雜計算這類對 CPU 資源消耗較大的場景,您也可以編寫異步代碼。

C# 具有語言級別的異步編程模型,使您能夠輕鬆編寫異步代碼,而無需處理回調函數或遵循支持異步功能的庫的規範。該模型遵循所謂的基於任務的異步模式(TAP)。

探索異步編程模型

“Task” 和 “Task < T >” 對象是異步編程的核心。這些對象通過支持 “async” 和 “await” 關鍵字來用於模擬異步操作。在大多數情況下,無論是針對 I/O 密集型還是 CPU 密集型場景,該模型都相當簡單。在異步方法內部:

  • I/O 密集型代碼會在異步方法中通過 Task 或 Task < T > 對象來啓動一個操作。
  • CPU 密集型代碼則通過 Task . Run 方法在後台線程上啓動一個操作。

在這兩種情況下,活躍的 Task 都代表了一個可能尚未完成的異步操作。

“await” 關鍵字就是實現神奇效果的地方。它將控制權交還給包含 “await” 表達式的那個方法的調用者,從而最終使用户界面能夠保持響應性,或者使服務具有彈性。雖然除了使用 “async” 和 “await” 表達式之外,還有其他方法來處理異步代碼,但本文重點討論的是語言層面的結構。

注意:本文中所列舉的一些示例使用了 System . Net . Http . HttpClient 類來從網絡服務中下載數據。在示例代碼中,名為 s_httpClient 的對象是 Program 類的一個靜態字段,其類型為 Program 類:
private static readonly HttpClient s_httpClient = new ( );

回顧基本概念

在您的 C# 代碼中實現異步編程時,編譯器會將您的程序轉換為一個狀態機。這種結構會跟蹤您代碼中的各種操作和狀態,例如當代碼遇到 await 表達式時暫停執行,以及在後台任務完成時恢復執行。

就計算機科學理論而言,異步編程是異步機制(即 “承諾模型”)的一種實現方式。

在異步編程模型中,有以下幾個關鍵概念需要理解:

  • 您可以將異步代碼用於 I/O 密集型和 CPU 密集型代碼,但實現方式有所不同。
  • 異步代碼使用 Task < T > 和 Task 對象作為結構體來模擬後台運行的工作。
  • async 關鍵字用於將方法聲明為異步方法,這使您能夠在方法體中使用 await 關鍵字。
  • 當您使用 await 關鍵字時,代碼會暫停調用方法並將其控制權返回給調用者,直到任務完成。
  • 您只能在異步方法中使用 await 表達式。

I/O 密集型示例:從網絡服務下載數據

在該示例中,當用户點擊一個按鈕時,應用程序會從網絡服務下載數據。您不想在下載過程中阻塞應用程序的用户界面線程。以下代碼完成了此任務:

HttpClient wlkh = new ( );
var zfc數據 = await wlkh . GetStringAsync ( "https://so.gushiwen.cn/view_10548.aspx" );
WBK內容 . Text = zfc數據 . ToString ( );

這段代碼表達了(異步下載數據的)意圖,而不會陷入與 Task 對象的交互中。

CPU 密集型示例:執行遊戲計算

在接下來的示例中,一款手機遊戲會根據按鈕事件對屏幕上的多個角色造成傷害。進行傷害計算可能會耗費大量資源。在用户界面線程上執行該計算可能會在計算過程中導致顯示和用户界面交互出現問題。

處理此任務的最佳方式是啓動一個後台線程,通過使用 Task . Run 方法來完成工作。該操作通過使用 await 表達式來暫停。當任務完成時,該操作會恢復運行。這種方法能讓用户界面平穩運行,而工作則在後台完成。

using System;
using System . Collections . Generic;
using System . Linq;
using System . Threading . Tasks;

namespace MillionArmySimulator
    {
    // 單位類型枚舉
    public enum UnitType
        {
        Infantry, Archer, Cavalry, Mage
        }

    // 單位狀態類
    public class Unit
        {
        public int Id
            {
            get;
            }
        public UnitType Type
            {
            get;
            }
        public float X
            {
            get; set;
            } // 位置X
        public float Y
            {
            get; set;
            } // 位置Y
        public float Health
            {
            get; set;
            }
        public float Attack
            {
            get;
            }
        public float Speed
            {
            get;
            }
        public float DetectionRange
            {
            get;
            } // 探測範圍
        public bool IsAlive => Health > 0;
        public int TeamId
            {
            get;
            } // 所屬隊伍

        // 戰鬥狀態
        public Unit Target
            {
            get; set;
            }
        public float AttackCooldown
            {
            get; set;
            }

        public Unit ( int id , UnitType type , float x , float y , int teamId )
            {
            Id = id;
            Type = type;
            X = x;
            Y = y;
            TeamId = teamId;

            // 根據單位類型初始化屬性 ( CPU密集點1:屬性計算)
            switch ( type )
                {
                case UnitType . Infantry:
                    Health = 100;
                    Attack = 15;
                    Speed = 2.5f;
                    DetectionRange = 15f;
                    break;
                case UnitType . Archer:
                    Health = 60;
                    Attack = 20;
                    Speed = 2f;
                    DetectionRange = 30f;
                    break;
                case UnitType . Cavalry:
                    Health = 120;
                    Attack = 25;
                    Speed = 4f;
                    DetectionRange = 20f;
                    break;
                case UnitType . Mage:
                    Health = 80;
                    Attack = 30;
                    Speed = 1.5f;
                    DetectionRange = 25f;
                    break;
                }
            }
        }

    // 戰鬥模擬器( 核心 CPU 計算類 )
    public class BattleSimulator
        {
        private readonly List<Unit> _units = new List < Unit > ( );
        private readonly Random _random = new Random ( );
        private const float MapWidth = 1000f;
        private const float MapHeight = 1000f;

        // 初始化百萬單位(CPU密集點2:大規模對象初始化)
        public void Initialize ( int unitCount )
            {
            _units . Clear ( );
            Parallel . For ( 0 , unitCount , i =>
            {
                var type = ( UnitType )_random . Next ( 0 , 4 );
                var teamId = _random . Next ( 0 , 2 ); // 紅藍兩隊
                var x = ( float )_random . NextDouble ( ) * MapWidth;
                var y = ( float )_random . NextDouble ( ) * MapHeight;

                lock ( _units )
                    {
                    _units . Add ( new Unit ( i , type , x , y , teamId ) );
                    }
            } );

            Console . WriteLine ( $"初始化完成:{_units . Count} 個單位" );
            }

        // 幀更新( CPU密集點3:並行處理百萬單位的 AI 和物理)
        public void Update ( float deltaTime )
            {
            // 並行處理所有單位( 利用多核 CPU )
            Parallel . ForEach ( _units . Where ( u => u . IsAlive ) , unit =>
            {
                // 1. 尋找目標(基於視野範圍的距離計算)
                FindTarget ( unit );

                // 2. 移動邏輯(路徑計算簡化版)
                MoveUnit ( unit , deltaTime );

                // 3. 攻擊邏輯(冷卻計算 + 傷害判定)
                AttackLogic ( unit , deltaTime );

                // 4. 羣體行為影響(鄰近單位狀態計算)
                ApplyGroupInfluence ( unit );
            } );

            // 統計戰場狀態
            var aliveCount = _units . Count ( u => u . IsAlive );
            var team1Count = _units . Count ( u => u . IsAlive && u . TeamId == 0);
            var team2Count = aliveCount - team1Count;

            Console . WriteLine ( $"存活: {aliveCount} | 紅隊: {team1Count} | 藍隊: {team2Count}" );
            }

        // 尋找目標(CPU密集:大規模距離計算)
        private void FindTarget ( Unit unit )
            {
            if ( unit . Target != null && unit . Target . IsAlive )
                return;

            Unit bestTarget = null;
            float closestDistance = float . MaxValue;

            // 遍歷視野內的敵對單位(可優化為空間分區查詢)
            foreach ( var other in _units )
                {
                if ( !other . IsAlive || other . TeamId == unit . TeamId )
                    continue;

                var distance = CalculateDistance ( unit , other );
                if ( distance < unit . DetectionRange && distance < closestDistance )
                    {
                    closestDistance = distance;
                    bestTarget = other;
                    }
                }

            unit . Target = bestTarget;
            }

        // 移動邏輯(包含簡單路徑規避)
        private void MoveUnit ( Unit unit , float deltaTime )
            {
            if ( unit . Target != null )
                {
                // 向目標移動
                var directionX = unit . Target . X - unit . X;
                var directionY = unit . Target . Y - unit . Y;
                var distance = ( float ) Math . Sqrt ( directionX * directionX + directionY * directionY );

                // 到達攻擊範圍則停止
                if ( distance > GetAttackRange ( unit ) )
                    {
                    unit . X += ( directionX / distance ) * unit . Speed * deltaTime;
                    unit . Y += ( directionY / distance ) * unit . Speed * deltaTime;
                    }
                }
            else
                {
                // 無目標時隨機移動(羣體漫遊行為)
                unit . X += ( float ) ( _random . NextDouble ( ) - 0.5 ) * unit . Speed * deltaTime;
                unit . Y += ( float ) ( _random . NextDouble ( ) - 0.5 ) * unit . Speed * deltaTime;
                ClampPosition ( unit ); // 限制在地圖內
                }
            }

        // 攻擊邏輯
        private void AttackLogic ( Unit unit , float deltaTime )
            {
            if ( unit . Target == null || !unit . Target . IsAlive )
                return;

            unit . AttackCooldown -= deltaTime;
            if ( unit . AttackCooldown <= 0 )
                {
                // 計算傷害(包含類型剋制)
                float damage = CalculateDamage ( unit , unit . Target );
                unit . Target . Health -= damage;

                // 重置冷卻
                unit . AttackCooldown = GetAttackInterval ( unit );
                }
            }

        // 羣體影響(鄰近單位的狀態加成/減益)
        private void ApplyGroupInfluence ( Unit unit )
            {
            int allyCount = 0;
            int enemyCount = 0;

            // 統計周圍單位(CPU密集:範圍查詢)
            foreach ( var other in _units )
                {
                if ( !other . IsAlive || other . Id == unit . Id )
                    continue;

                if ( CalculateDistance ( unit , other ) < 10f )
                    {
                    if ( other . TeamId == unit . TeamId )
                        allyCount++;
                    else
                        enemyCount++;
                    }
                }

            // 士氣影響(簡單數值計算)
            float moraleFactor = 1 + ( allyCount * 0.05f ) - ( enemyCount * 0.1f );
            moraleFactor = Math . Clamp ( moraleFactor , 0.5f , 1.5f );
            }

        // 輔助計算:距離
        private float CalculateDistance ( Unit a , Unit b )
            {
            float dx = a . X - b . X;
            float dy = a . Y - b . Y;
            return MathF . Sqrt ( dx * dx + dy * dy );
            }

        // 輔助計算:攻擊範圍
        private float GetAttackRange ( Unit unit )
            {
            return unit . Type switch
                {
                    UnitType . Infantry => 2f,
                    UnitType . Archer => 15f,
                    UnitType . Cavalry => 3f,
                    UnitType . Mage => 20f,
                    _ => 2f
                    };
            }

        // 輔助計算:攻擊間隔
        private float GetAttackInterval ( Unit unit )
            {
            return unit . Type switch
                {
                    UnitType . Infantry => 1f,
                    UnitType . Archer => 1.5f,
                    UnitType . Cavalry => 0.8f,
                    UnitType . Mage => 2f,
                    _ => 1f
                    };
            }

        // 輔助計算:傷害(類型剋制)
        private float CalculateDamage ( Unit attacker , Unit target )
            {
            float factor = 1f;

            // 類型剋制邏輯(增加計算複雜度)
            if ( ( attacker . Type == UnitType . Infantry && target . Type == UnitType . Archer ) ||
                ( attacker . Type == UnitType . Archer && target . Type == UnitType . Cavalry ) ||
                ( attacker . Type == UnitType . Cavalry && target . Type == UnitType . Mage ) ||
                ( attacker . Type == UnitType . Mage && target . Type == UnitType . Infantry ) )
                {
                factor = 1.5f; // 剋制
                }
            else if ( ( attacker . Type == UnitType . Infantry && target . Type == UnitType . Cavalry ) ||
                     ( attacker . Type == UnitType . Archer && target . Type == UnitType . Infantry ) ||
                     ( attacker . Type == UnitType . Cavalry && target . Type == UnitType . Archer ) ||
                     ( attacker . Type == UnitType . Mage && target . Type == UnitType . Cavalry ) )
                {
                factor = 0.7f; // 被剋制
                }

            return attacker . Attack * factor;
            }

        // 限制位置在地圖內
        private void ClampPosition ( Unit unit )
            {
            unit . X = Math . Clamp ( unit . X , 0 , MapWidth );
            unit . Y = Math . Clamp ( unit . Y , 0 , MapHeight );
            }
        }

    // 遊戲主程序
    class Program
        {
        static void Main ( string [ ] args )
            {
            var simulator = new BattleSimulator ( );
            simulator . Initialize ( 1000 ); // 初始化 1000 個單位(CPU 壓力測試)

            var watch = System . Diagnostics . Stopwatch . StartNew ( );
            float deltaTime = 0.016f; // 約 60 幀/秒

            // 模擬戰鬥循環
            for ( int i = 0 ; i < 100 ; i++ )
                {
                simulator . Update ( deltaTime );
                Console . WriteLine ( $"第 {i + 1} 幀計算完成,耗時:{watch . ElapsedMilliseconds} 毫秒" );
                watch . Restart ( );
                }

            Console . WriteLine ( "戰鬥模擬結束" );
            Console . ReadKey ( true );

            }
        }
    }

該代碼清晰地表達了按鈕 “點擊” 事件的意圖。它無需手動管理後台線程,並且以非阻塞的方式完成任務。

識別 CPU 密集型和 I/O 密集型場景

前面的示例展示瞭如何使用 async 修飾符和 await 表達式來處理 I/O 密集型和 CPU 密集型工作。每個場景的一個示例都展示了代碼在操作所綁定位置上的不同之處。為了為您的實現做好準備,您需要了解如何識別一個操作是 I/O 密集型還是 CPU 密集型。您的實現選擇會極大地影響代碼的性能,並可能導致對構造函數的不當使用。

在編寫任何代碼之前,您需要先解決兩個主要問題:

問題 場景 實現
如果代碼需要等待某個結果或操作(例如來自數據庫的數據),應如何處理? 阻塞型 I/O 限制型情況下,使用 async 關鍵字和 await 表達式,而無需使用 Task . Run 方法;避免使用任務並行庫
如果代碼要執行一項耗時的計算呢? 屬於 CPU 密集型任務 使用 “async” 修飾符和 “await” 表達式,但通過 “Task . Run” 方法將工作分派到另一個線程上。這種方法解決了與 CPU 響應性相關的問題

如果該工作適合併發和並行處理,那麼也考慮使用任務並行庫。

務必對代碼的執行情況進行測量。您可能會發現,與多線程時上下文切換的開銷相比,您的 CPU 密集型工作成本並不高。每個選擇都有其優缺點。請根據您的具體情況選擇正確的平衡方案。

探索其他示例

本節中的示例展示了在 C# 中編寫異步代碼的幾種方法。它們涵蓋了您可能會遇到的一些場景。

從網絡中提取數據

以下代碼會從給定的 URL 下載 HTML 內容,並統計其中出現字符串 “.NET” 的次數。該代碼使用 ASP.NET 來定義一個 Web API 控制器方法,該方法執行任務並返回計數結果。

注意:如果您打算在生產代碼中進行 HTML 解析,請不要使用正則表達式。而應使用解析庫來實現。

static async Task<int> FF獲取DotNet數量 ( string URL )
    {
        HttpClient wlkh = new ( );
        var html = await wlkh . GetStringAsync ( URL );
        return Regex . Count ( html , @"\.NET" );
    }

您可以為通用 Windows 應用程序編寫類似的代碼,並在按下按鈕後執行計數任務:

private async void CHX獲取網絡數據 ( object sender , EventArgs e )
    {
        FF獲取網絡數據 ( );
        // 用 await 等待異步方法執行完成,拿到實際的 int 結果
        int count = await FF獲取DotNet數量 ( "https://learn.microsoft.com/en-us/dotnet/csharp/asynchronous-programming/async-scenarios" );
        // 再拼接結果
        WBK內容 . Text += $"{Environment . NewLine}DotNet 出現的次數:{count}";
    }

async Task<int> FF獲取DotNet數量 ( string URL )
    {
        using HttpClient wlkh = new ( );
            {
                try
                    {
                        var qingqiu = new HttpRequestMessage ( HttpMethod . Get , URL );
                        var dafu = await wlkh . SendAsync ( qingqiu , HttpCompletionOption . ResponseHeadersRead );
                        dafu . EnsureSuccessStatusCode ( );

                        long CZ總字節數 = 0;
                        if ( dafu . Content . Headers . TryGetValues ( "Content-Length" , out var ZHIs ) )
                            {
                                _ = long . TryParse ( ZHIs . First ( ) , out CZ總字節數 );

                            }

                        long CZ讀取字節s = 0;
                        char [ ] huanchongqu = new char [ 1024 ];
                        int ZHS讀取字節;
                        string html = "";

                        using ( var DQ = new StreamReader ( await dafu . Content . ReadAsStreamAsync ( ) ) )
                            {
                                while ( ( ZHS讀取字節 = await DQ . ReadBlockAsync ( huanchongqu , 0 , huanchongqu . Length ) ) > 0 )
                                    {
                                        html += new string ( huanchongqu , 0 , ZHS讀取字節 );
                                        CZ讀取字節s += ZHS讀取字節;

                                        int bfb = CZ總字節數 > 0 ? ( int ) ( ( CZ讀取字節s * 100 ) / CZ總字節數 ) : 0;
                                        string ZFC説明 = CZ總字節數 > 0 ? $"加載中 {bfb} %" : "加載中…";
                                        PGB進度 . Value = bfb;
                                        BQ進度 . Text = ZFC説明;
                                        await Task . Delay ( 10 );
                                    }
                                }
                            await Task . Delay ( 100 );

                            return Regex . Count ( html , @"\.NET" );
            }
        catch ( Exception YC )
            {
                BQ進度 . Text = $"出錯了。{YC . Message}";
                return -1;
            }
        }
    }

等待多個任務完成

在某些情況下,代碼需要同時獲取多組數據。任務 API 提供了一些方法,使您能夠編寫異步代碼,以對多個後台任務進行非阻塞式的等待操作:

  • Task.WhenAll 方法
  • Task.WhenAny 方法

以下示例展示瞭如何獲取一組 userId 對象對應的用户對象數據。

private static async Task<IEnumerable<LEI用户>> FF獲取用户 ( IEnumerable<int> ids )
    {
        var HQ用户任務 = new List < Task < LEI用户>> ( );
        foreach ( int id in ids )
            {
                HQ用户任務 . Add ( FF獲取用户 ( id ) );
            }
        return await Task . WhenAll ( HQ用户任務 );
    }

private static async Task<LEI用户> FF獲取用户 ( int id )
    {
        return await Task . FromResult ( new LEI用户 ( id ) );
    }

List < LEI用户 > yhlb = [ new LEI用户 ( 001 ) , new LEI用户 ( 002 ) , new LEI用户 ( 003 ) ];

public class LEI用户 ( int ID用户 )
    {
        public int ID
            {
                get
                    {
                        return ID用户;
                    }
                set
                    {
                        ID用户 = value;
                    }
            }
    }

您可以通過使用 LINQ 來更簡潔地編寫這段代碼:

private static async Task<LEI用户 [ ]> FF獲取用户LINQ ( IEnumerable<int> ids )
    {
        var rw用户 = ids . Select ( id => FF獲取用户 ( id ) ) . ToArray ( );
        return await Task . WhenAll ( rw用户 );
    }

雖然使用 LINQ 編寫代碼的量會減少,但在將 LINQ 與異步代碼混合使用時仍需謹慎。LINQ 採用的是延遲(或延遲執行)執行方式,這意味着如果沒有立即進行評估,異步調用不會立即執行,而是要等到序列被枚舉之後才會進行。

前面的示例是正確且安全的,因為它使用了 Enumerable . ToArray 方法來立即執行 LINQ 查詢,並將任務存儲在一個數組中。這種方法確保了 id => FF獲取用户 ( id ) 的調用能夠立即執行,並且所有任務會同時開始執行,就像 foreach 循環方法那樣。在使用 LINQ 創建任務時,始終使用 Enumerable . ToArray 或 Enumerable . ToList 以確保任務能夠立即執行並實現併發執行。下面是一個示例,展示瞭如何使用 ToList ( ) 與 Task . WhenAny 結合來處理已完成的任務:

private static async Task FF完成任務 ( IEnumerable<int> ids )
    {
        var HQ用户任務s = ids . Select ( id => FF獲取用户 ( id ) ) . ToList ( );
        while ( HQ用户任務s . Count > 0 )
            {
                Task <LEI用户> rw結束 = await Task . WhenAny ( HQ用户任務s );
                HQ用户任務s . Remove ( rw結束 );

                LEI用户 YH = await rw結束;
                Console . WriteLine ( $"結束用户 {YH . ID}" );
            }
    }

在該示例中,ToList ( ) 方法會創建一個支持 Remove ( ) 操作的列表,使您能夠動態地移除已完成的任務。這種模式在您希望在結果可用時立即處理它們,而非等待所有任務完成的情況下特別有用。

雖然使用 LINQ 編寫代碼的量會減少,但在將 LINQ 與異步代碼混合使用時仍需謹慎。LINQ 採用的是延遲(或延遲執行)方式。異步調用不會像在 foreach 循環中那樣立即執行,除非您通過調用 . ToList ( ) 或 . ToArray ( ) 方法強制生成的序列進行迭代。

您可以根據具體情況選擇使用 Enumerable . ToArray 或 Enumerable . ToList:

  • 當您打算一次性處理所有任務(例如使用 Task . WhenAll)時,請使用 ToArray ( ) 方法。數組在處理固定大小集合的場景中效率更高。
  • 當您需要動態管理任務(例如在 Task . WhenAny 中,當任務完成時可以從集合中移除已完成的任務)時,請使用 ToList ( ) 方法。

回顧異步編程的相關注意事項

在異步編程中,有一些細節需要牢記,否則可能會導致意外行為。

在 async ( ) 方法體中使用 await

當您使用 async 關鍵字時,應在方法體中包含一個或多個 await 表達式。如果編譯器未遇到 await 表達式,該方法將無法產生結果。儘管編譯器會發出警告,但代碼仍能編譯,並且編譯器會運行該方法。C# 編譯器為異步方法生成的狀態機沒有任何作用,因此整個過程效率極低。

給異步方法名稱添加 “Async” 後綴

在.NET 的規範中,會將 “Async” 後綴添加到所有異步方法的名稱中。這種方法有助於更清晰地區分同步方法和異步方法。某些並非由您的代碼直接調用的方法(例如事件處理程序或 Web 控制器方法)在這種情況下不一定適用。由於這些項目並非由您的代碼直接調用,因此使用明確的命名方式並不是那麼重要。

只從事件處理程序中返回 “async void” 類型

事件處理程序必須聲明為 void 類型的返回值,並且不能像其他方法那樣使用或返回 Task 和 Task < T > 對象。當您編寫異步事件處理程序時,需要在處理程序中使用 async 關鍵字來修飾一個返回 void 的方法。其他實現返回 async void 類型的方法的實現並不遵循 TAP 模型,可能會帶來一些挑戰:
在異步 void 方法中拋出的異常無法在該方法之外被捕獲

  • 異步 void 方法難以進行測試
  • 如果調用方沒有預期這些方法是異步的,那麼異步 void 方法可能會產生負面的副作用
  • 在 LINQ 中謹慎使用異步 lambda 表達式

在 LINQ 表達式中實現異步 lambda 表達式時,務必謹慎行事。LINQ 中的 lambda 表達式採用延遲執行的方式,這意味着代碼可能會在意外的時間執行。如果在這個場景中引入阻塞任務,如果代碼編寫不當,很容易導致死鎖。此外,異步代碼的嵌套也會使代碼的執行過程難以理解。異步和 LINQ 非常強大,但這些技術應該儘可能謹慎且清晰地一起使用。

以非阻塞方式處理任務

如果您的程序需要某個任務的結果,請編寫代碼以非阻塞的方式實現 “await” 表達式。通過阻塞當前線程來同步等待任務項完成的方式可能會導致死鎖和阻塞的上下文線程。這種編程方法可能需要更復雜的錯誤處理。以下表格提供了以非阻塞方式訪問任務結果的指導:

任務場景 當前代碼 替換為 'await'
獲取後台任務的結果 Task . Wait 或 Task . Result await
在任何任務完成時繼續執行 Task . WaitAny await Task . WhenAny
在所有任務完成時繼續執行 Task . WaitAll await Task . WhenAll
在一段時間後繼續執行 Thread . Sleep await Task . Delay

考慮使用 ValueTask 類型

當一個異步方法返回一個 Task 對象時,在某些路徑中可能會出現性能瓶頸。因為 Task 是一個引用類型,所以一個 Task 對象是從堆中分配的。如果一個帶有 async 關鍵字聲明的方法返回一個緩存結果或以同步方式完成,那麼在性能關鍵代碼段中額外的分配操作可能會帶來顯著的時間成本。當這些分配操作出現在緊密循環中時,這種情況可能會變得非常昂貴。

瞭解何時設置 ConfigureAwait ( false )

開發人員經常會詢問何時使用 Task . ConfigureAwait ( Boolean ) 這個布爾值。此 API 允許 Task 實例為實現任何 await 表達式的狀態機配置上下文。如果布爾值設置不正確,性能可能會下降或者會出現死鎖。

編寫少狀態化的代碼

避免編寫依賴於全局對象狀態或某些方法執行情況的代碼。而應僅依賴於方法的返回值。編寫少狀態化的代碼有許多好處:

  • 更易於理解代碼
  • 更易於測試代碼
  • 更易於將異步代碼與同步代碼混合使用
  • 能夠避免代碼中的競爭條件
  • 簡單地協調依賴返回值的異步代碼
  • (額外優點)與代碼中的依賴注入配合使用效果良好

推薦的目標是在您的代碼中實現完全或近乎完全的引用透明性。這種方法會生成一個可預測、可測試且易於維護的代碼庫。

異步操作的同步訪問

在某些場景中,如果在你的調用棧中無法使用 “await” 關鍵字,那麼您可能需要在異步操作上進行阻塞操作。這種情況常見於遺留代碼庫中,或者是在將異步方法集成到無法更改的同步 API 中時出現。

警告:對於異步操作的同步阻塞可能會導致死鎖,因此應儘可能避免這種做法。更理想的解決方案是在整個調用棧中使用 async/await。

當您必須對任務進行同步阻塞操作時,以下是可供選擇的方法,按優先級從高到低排列:

  • 使用 GetAwaiter ( ) . GetResult ( )
  • 對於複雜場景,使用 Task . Run
  • 使用 Wait ( ) 和 Result

使用 GetAwaiter ( ) . GetResult ( )

“GetAwaiter ( ) . GetResult ( )” 模式通常是在必須同步阻塞的情況下首選的方法:

// 當你不能使用 await
Task<string> task = GetDataAsync ( );
string result = task . GetAwaiter ( ) . GetResult ( );

這種方法:

  • 保留了原始的異常,未將其封裝在聚合異常中。
  • 使當前線程阻塞,直至任務完成。
  • 如果不謹慎使用,仍存在死鎖風險。

對於複雜的場景,請使用 Task.Run

對於需要隔離異步工作的複雜場景:

// 將任務卸載到線程池中,以避免上下文死鎖的情況
string result = Task . Run ( async ( ) => await GetDataAsync ( ) ) . GetAwaiter ( ) . GetResult ( );

這種模式:

  • 在線程池線程上執行異步方法。
  • 有助於避免一些死鎖情況。
  • 通過將工作調度到線程池會增加開銷。

使用 Wait ( ) 和 Result

您可以採用阻塞方式,通過調用 Wait ( ) 和 Result 來操作。然而,這種方法不被推薦,因為這會將異常封裝在 AggregateException 中。

Task<string> task = GetDataAsync ( );
task . Wait ( );
string result = task . Result;

“Wait ( )” 和 “Result” 存在的問題:

  • 異常會被封裝在 AggregateException 中,這使得錯誤處理變得更加複雜。
  • 更高的死鎖風險。
  • 代碼意圖不那麼清晰。

其他需要考慮的因素

  • 預防死鎖:在 UI 應用程序中或在使用同步上下文時要格外小心。
  • 性能影響:阻塞線程會降低可擴展性。
  • 異常處理:仔細測試錯誤場景,因為不同模式下的異常行為有所差異。

查看完整示例

以下代碼即為完整的示例。

using Microsoft . AspNetCore . Mvc;
using System . Text . RegularExpressions;

class AnNiu
    {
    public Func<object , object , Task>? DanJi
        {
        get;
        internal set;
        }
    }

class ShangHaijieguo
    {
    public int ShangHai
        {
        get { return 0; }
        }
    }

class YongHu
    {
    public bool Ber有效  {  get; set; }
    public int ID { get; set; }
    }

public class ChengXu
    {
    private static readonly AnNiu _下載按鈕 = new ( );
    private static readonly AnNiu _計算按鈕 = new ( );

    private static readonly HttpClient WLKH = new ( );

    private static readonly IEnumerable <string> _LBurl =
        [
            "https://learn.microsoft.com",
            "https://learn.microsoft.com/aspnet/core",
            "https://learn.microsoft.com/azure",
            "https://learn.microsoft.com/azure/devops",
            "https://learn.microsoft.com/dotnet",
            "https://learn.microsoft.com/dotnet/desktop/wpf/get-started/create-app-visual-studio",
            "https://learn.microsoft.com/education",
            "https://learn.microsoft.com/shows/net-core-101/what-is-net",
            "https://learn.microsoft.com/enterprise-mobility-security",
            "https://learn.microsoft.com/gaming",
            "https://learn.microsoft.com/graph",
            "https://learn.microsoft.com/microsoft-365",
            "https://learn.microsoft.com/office",
            "https://learn.microsoft.com/powershell",
            "https://learn.microsoft.com/sql",
            "https://learn.microsoft.com/surface",
            "https://dotnetfoundation.org",
            "https://learn.microsoft.com/visualstudio",
            "https://learn.microsoft.com/windows",
            "https://learn.microsoft.com/maui"
        ];

    private static void FF計算 ( )
        {
        static ShangHaijieguo FF計算傷害結果 ( )
            {
            return new ( );
                {
                // 代碼省略:
                // 執行一項耗時的計算,並返回該計算的結果
                };
            }

        _計算按鈕 . DanJi += async ( o , e ) =>
            {
            // 在 “FF計算傷害結果 ( )” 執行其任務期間,此行將讓控制權交還給用户界面。而用户界面線程則可以繼續執行其他工作
            var shjg = await Task . Run ( ( ) => FF計算傷害結果 ( ) );
            FF顯示傷害結果 ( shjg );
            };
        }

    private static void FF顯示傷害結果 ( ShangHaijieguo 傷害 )
        {
        Console . WriteLine ( 傷害 . ShangHai );
        }

    private static void FF下載 ( string URL )
        {
        _下載按鈕 . DanJi += async ( o , e ) =>
            {
                // 在此行代碼中,當從網絡服務接收到請求時,控制權將交還給用户界面
                // 現在用户界面線程可以自由地執行其他工作了
                var zfc數據 = await WLKH . GetStringAsync ( URL );
                FF對數據做點什麼 ( zfc數據 );
            };
        }

    private static void FF對數據做點什麼 ( object 數據字符串 )
        {
        Console . WriteLine ( $"顯示數據:{數據字符串}" );
        }

    private static async Task <YongHu> FF獲取用户Async ( int ID )
        {
        // 程序代碼省略:
        // 給定一個用户 ID {userId},將檢索出與數據庫中具有 {userId} 作為其 ID 的條目相對應的用户對象
        return await Task . FromResult ( new YongHu ( ) { ID = ID } );
        }

    private static async Task<IEnumerable<YongHu>> FF獲取用户Async ( IEnumerable<int> IDs )
        {
        var HQ用户任務s = new List<Task<YongHu>> ( );
        foreach ( int id in IDs )
            {
            HQ用户任務s . Add ( FF獲取用户Async ( id ) );
            }
        return await Task . WhenAll ( HQ用户任務s );
        }

    private static async Task<YongHu [ ]> FF獲取用户LINQAsync ( IEnumerable<int> IDs )
        {
        var HQ用户任務s = IDs . Select ( id => FF獲取用户Async ( id ) ) . ToArray ( );
        return await Task . WhenAll ( HQ用户任務s );
        }

    private static async Task FF在任務完成時異步處理任務 ( IEnumerable<int> IDs )
        {
        var RW獲取用户 = IDs . Select ( id => FF獲取用户Async ( id ) ) . ToList ( );

        while ( RW獲取用户 . Count > 0 )
            {
            Task<YongHu> RW結束 = await Task . WhenAny ( RW獲取用户 );
            RW獲取用户 . Remove ( RW結束 );

            YongHu user = await RW結束;
            Console . WriteLine ( $"處理用户 {user . ID}" );
            }
        }

    static public async Task<int> FF獲取DotNet計數 ( string URL )
        {
            try
            {
            // 中止調用 FF獲取DotNetCountAsync ( ) 方法,以便讓調用方(即網絡服務器)能夠處理另一個請求,而無需在此請求上進行阻塞
            var html = await WLKH . GetStringAsync ( URL );
            return Regex . Matches ( html , @"\.NET" ) . Count;
            }
            catch (Exception ych )
            {
            Console . WriteLine ( ych . Message );
            return -1;
            }
        }

    static async Task Main ( )
        {
        Console . WriteLine ( "應用程序開始。" );

        Console . WriteLine ( "正在統計網站中 “.NET” 這一短語的出現次數……" );
        int total = 0;
        foreach ( string url in _LBurl )
            {
            var result = await FF獲取DotNet計數 ( url );
            Console . WriteLine ( $"{url}:{result}" );
            total += result;
            }
        Console . WriteLine ( "所有:" + total );

        Console . WriteLine ( "處理 IDs 中的用户對象……" );
        IEnumerable<int> ids = new int[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 0 };
        var users = await FF獲取用户Async  ( ids );
        foreach ( YongHu? yh in users )
            {
            Console . WriteLine ( $"{yh . ID}:isEnabled = {yh . Ber有效}" );
            }

        Console . WriteLine ( "在它們結束後處理任務……" );
        await FF在任務完成時異步處理任務 ( ids );

        _下載按鈕 . DanJi? . Invoke ( arg1: null , arg2: null );
        _計算按鈕 . DanJi? . Invoke ( arg1: null , arg2: null );
        Console . WriteLine ( "應用程序結束!" );
        }
    }

任務異步編程模型

通過使用異步編程,您可以避免性能瓶頸並提高應用程序的整體響應能力。然而,編寫異步應用程序的傳統方法可能會很複雜,這使得它們難以編寫、調試和維護。

C# 支持一種簡化的方法 - 異步編程,它利用了 .NET 運行時中的異步支持功能。編譯器承擔了過去由開發人員完成的複雜工作,而您的應用程序仍能保持類似於同步代碼的邏輯結構。因此,您能夠以極小的代價獲得異步編程的所有優勢。

本文概述了何時以及如何使用異步編程,並提供了指向其他包含詳細信息和示例文章的鏈接。

異步提高了響應速度

異步處理對於那些可能造成阻塞的操作(例如網絡訪問)至關重要。訪問網絡資源時有時會很慢或者會有延遲。如果這種操作在同步流程中被阻塞,整個應用程序就必須等待。而在異步流程中,應用程序可以繼續進行其他不依賴於網絡資源的工作,直到可能造成阻塞的任務完成。

以下表格展示了異步編程能夠提升響應速度的典型應用場景。列出的來自.NET 和 Windows 運行時的 API 包含支持異步編程的方法。

應用領域 有異步方法的 .NET 類型 有異步方法的 Windows 運行時類型
網絡訪問 HttpClient Windows . Web . Http . HttpClient、SyndicationgClient
文件處理 JsonSerializer、StreamReader、StreamWriter、XmlReader、XmlWriter StorageFile
圖像處理 MediaCapture、BitmapEncoder、BitmapDecoder
WCF 編程 同步與異步操作符

異步處理對於那些需要訪問用户界面線程的應用程序來説尤其有用,因為所有與用户界面相關的活動通常都共用一個線程。在同步應用程序中,如果任何進程被阻塞,那麼所有進程都會被阻塞。您的應用程序就會停止響應,而您可能會認為它失敗了,但實際上它只是在等待。

當您使用異步方法時,應用程序仍會繼續響應用户界面。例如,您可以調整窗口大小或將其最小化,或者如果您不想等待其完成,也可以關閉應用程序。

基於異步的方法為在設計異步操作時可供選擇的選項列表中增添了相當於自動變速器的功能。也就是説,您能夠享受到傳統異步編程的所有優勢,而開發人員的工作量則會大大減少。

異步方法易於編寫

C# 中的 “async” 和 “await” 關鍵字是異步編程的核心。通過使用這兩個關鍵字,您能夠像創建同步方法那樣輕鬆地利用 .NET 框架、.NET 核心或 Windows 運行時中的資源來創建異步方法。通過使用“async”關鍵字定義的異步方法被稱為異步方法。

下面這個示例展示了一個異步方法。代碼中的幾乎所有內容對您來説應該都很熟悉。

您可以在《C# 中的異步編程 - 使用 async 和 await》一文中找到一個完整的 Windows 表示基礎架構(WPF)示例,該示例可免費下載。

public static async Task<int> FF獲取URL主體長度Async ( )
    {
        using var KHD = new HttpClient();

        Task<string> FF獲取字符串任務 = KHD . GetStringAsync ( "https://learn.microsoft.com/dotnet" );

        FF自己的工作 ( );

        string 主體 = await FF獲取字符串任務;

        return 主體 . Length;
    }

static void FF自己的工作 ( )
    {
        Console . WriteLine ( "工作中……" );
    }

您可以從前面的示例中學習幾種做法。首先來看方法簽名。它包含 “async” 修飾符。返回類型為 “Task < int >”。方法名以 “Async” 結尾。在方法體中,“FF獲取字符串任務” 返回一個 “Task < string >”。這意味着當您等待該任務時,您會得到一個字符串(內容)。在等待任務之前,您可以進行不需要依賴於 “FF獲取字符串任務” 返回的字符串的操作。

請注意 “await” 操作符。它會暫停 “FF獲取URL主體長度Async” 操作:

  • “FF獲取URL主體長度Async” 在 “FF獲取字符串任務” 完成之前無法繼續執行。
  • 與此同時,控制權會返回給 “FF獲取URL主體長度Async” 的調用者。
  • 當 “FF獲取字符串任務” 完成時,控制權會在此處恢復。
  • 然後,“await” 運算符會從 “FF獲取字符串任務” 中獲取字符串結果。

返回語句指定了一個整數結果。任何正在等待 “FF獲取URL主體長度Async” 的方法都會獲取長度值。

如果 FF獲取URL主體長度Async 在調用 FF獲取字符串任務 之後以及等待其完成之前沒有任何可以執行的工作,那麼您可以通過在以下單個語句中調用並等待來簡化您的代碼。
string ZFC主體s = await WLKH . GetStringAsync ( "https://learn.microsoft.com/dotnet" );
以下這些特點概括了為何上述示例屬於異步方法:

  • 該方法簽名包含了一個“異步”修飾符。
  • 按照慣例,異步方法的名稱會在其末尾加上“Async”後綴。
  • 返回類型屬於以下其中一種類型:

    • 如果您的方法中有帶有 TResult 類型操作數的返回語句,則使用 Task < TResult >。
    • 如果您的方法沒有返回語句或者有無操作數的返回語句,則使用 Task。
    • 如果您正在編寫異步事件處理程序,則使用 void。
    • 如果您的類型具有 GetAwaiter 方法,則使用該類型。
  • 該方法通常至少包含一個 “await” 表達式,該表達式標誌着在等待的異步操作完成之前,方法無法繼續執行的點。在此期間,方法會被暫停,控制權會返回給方法的調用者。本文的下一節將説明在暫停點會發生什麼情況。

在異步方法中,您只需使用所提供的關鍵字和類型來表明您想要執行的操作,編譯器會完成其餘工作,包括跟蹤當控制返回到掛起方法中的等待點時必須發生的事情。一些常規流程,如循環和異常處理,在傳統的異步代碼中可能難以處理。而在異步方法中,您就像在同步解決方案中那樣編寫這些元素,問題就迎刃而解了。

異步方法中的操作

在異步編程中,需要理解的最重要的一點是控制流如何從一個方法轉移到另一個方法。下面的圖表將引導您瞭解整個過程:

圖表中的數字對應着以下步驟,這些步驟是在調用方法調用異步方法時開始執行的。

  1. 一個調用方法會調用並等待 “GetUrlContentLengthAsync” 這個異步方法。
  2. GetUrlContentLengthAsync 方法會創建一個 HttpClient 實例,並調用 GetStringAsync 異步方法來將網站的內容以字符串的形式下載下來。
  3. 在 GetStringAsync 方法中發生了一些情況,導致其執行進程暫停。這可能是因為它必須等待網站下載完成,或者進行其他阻塞性操作。為了避免佔用資源,GetStringAsync 會將控制權交給其調用者 GetUrlContentLengthAsync。
    GetStringAsync 方法返回一個 Task < TResult > 類型的對象,其中 TResult 表示字符串類型。GetUrlContentLengthAsync 方法將該任務賦值給 getStringTask 變量。該任務代表了調用 GetStringAsync 方法的正在進行的操作,一旦完成工作,它將承諾生成一個實際的字符串值。
  4. 由於 getStringTask 仍未被等待,所以 GetUrlContentLengthAsync 可以繼續執行那些不依賴於 GetStringAsync 最終結果的其他工作。這些工作通過調用同步方法 DoIndependentWork 來實現。
  5. “DoIndependentWork” 是一種同步方法,它完成工作後會返回給調用者。
  6. GetUrlContentLengthAsync 方法在沒有 getStringTask 方法返回結果的情況下無法執行任何操作。接下來,GetUrlContentLengthAsync 方法想要計算並返回下載字符串的長度,但該方法必須在獲取到字符串之後才能計算出這個值。
    因此,GetUrlContentLengthAsync 使用一個 await 關鍵字來暫停其執行進度,並將控制權交給調用該方法的其他代碼段。GetUrlContentLengthAsync 返回一個 Task < int > 給調用者。該任務表示將產生一個整數值(即下載字符串的長度)的承諾。
    注意:如果調用 GetStringAsync(從而導致 getStringTask)的操作先於 GetUrlContentLengthAsync 的等待操作完成,那麼控制權就會留在 GetUrlContentLengthAsync 中。如果被調用的異步操作 getStringTask 已經完成,而 GetUrlContentLengthAsync 無需等待最終結果,那麼暫停和返回到 GetUrlContentLengthAsync 所產生的開銷就會被浪費掉。
    在調用方法內部,處理流程繼續進行。調用者在等待獲取 URL 內容長度異步操作的結果之前,可能會先執行一些不依賴於該結果的工作,或者調用者可能會立即進行等待。調用方法正在等待獲取 URL 內容長度異步操作的結果,而獲取 URL 內容長度異步操作則在等待獲取字符串異步操作的結果。
  7. GetStringAsync 方法完成操作並生成一個字符串結果。這個字符串結果並非像您所期望的那樣通過調用 GetStringAsync 方法來返回(請記住,在步驟 3 中該方法已經返回了一個任務)。相反,該字符串結果被存儲在代表該方法完成的任務 getStringTask 中。await 操作符從 getStringTask 中獲取結果。賦值語句將獲取的結果賦給 contents 變量。
  8. 當 GetUrlContentLengthAsync 方法得到字符串結果時,該方法可以計算該字符串的長度。然後,GetUrlContentLengthAsync 的工作也就完成了,等待的事件處理程序就可以繼續執行了。在本文末尾的完整示例中,您可以確認事件處理程序會獲取並打印長度結果的值。如果您是異步編程的新手,請花點時間思考一下同步和異步行為之間的區別。同步方法在工作完成時返回(步驟 5),而異步方法在工作暫停時返回任務值(步驟 3 和 6)。當異步方法最終完成其工作時,該任務會被標記為已完成,並且如果有結果的話,結果會存儲在任務中。

API 異步方法

您可能會想知道如何才能找到像 GetStringAsync 這樣支持異步編程的方法。.NET Framework 4.5 或更高版本以及 .NET Core 包含了許多與異步和等待相關的成員。可以通過成員名稱後附加的 “Async” 後綴以及它們的返回類型為 Task 或 Task < TResult > 來識別這些方法。例如,System . IO . Stream 類除了同步方法 CopyTo、Read 和 Write 之外,還包含諸如 CopyToAsync、ReadAsync 和 WriteAsync 這樣的異步方法。

Windows 運行時還包含許多可在 Windows 應用程序中與 async 和 await 結合使用的方法。

線程

異步方法旨在實現非阻塞操作。在異步方法中使用 “await” 表達式時,在等待的任務運行期間不會阻塞當前線程。相反,該表達式會將方法的剩餘部分註冊為一個延續,並將控制權返回給異步方法的調用者。
“async” 和 “await” 這兩個關鍵字並不會創建額外的線程。異步方法不需要多線程,因為異步方法並非在自己的線程上運行。該方法會在當前的同步上下文中運行,並且僅在方法處於活動狀態時才在該線程上使用時間。您可以使用 “Task . Run” 將 CPU 密集型工作轉移到後台線程,但後台線程對於僅僅在等待結果可用的過程並無幫助。

基於異步編程的這種方法在幾乎所有情況下都優於現有的方法。特別是對於 I/O 密集型操作,這種方法比 BackgroundWorker 類更優,因為其代碼更簡潔,而且無需防範競爭條件。與 Task . Run 方法結合使用時,異步編程在 CPU 密集型操作中優於 BackgroundWorker,因為異步編程將運行代碼的協調細節與 Task . Run 轉移到線程池的工作分離開來。

async 與 await

如果您通過使用 “async” 修飾符來指定一個方法為異步方法,那麼您就啓用了以下兩種功能。

  • 帶有標記的異步方法可以使用 “await” 來指定暫停點。“await” 操作符告知編譯器,在異步操作完成之前,該異步方法不能繼續執行到該點之後的部分。在此期間,控制權會返回給異步方法的調用者。
    在 “await” 表達式處對異步方法的暫停並不意味着方法的結束,而且最終的阻塞操作也不會執行。
  • 帶有 “async” 標記的方法本身可以被調用該方法的其他方法進行等待操作。

異步方法通常會包含一個或多個 “await” 運算符的使用,但缺少 “await” 表達式並不會導致編譯錯誤。如果一個異步方法沒有使用 “await” 運算符來標記暫停點,那麼該方法的執行方式就如同同步方法一樣,儘管有 “async” 修飾符的存在。對於這類方法,編譯器會發出警告。

“async” 和 “await” 是具有特定含義的關鍵詞。

返回類型和參數

異步方法通常會返回一個 Task 或者是一個 Task < TResult >。在異步方法內部,會將一個 await 運算符應用於從調用另一個異步方法所返回的任務上。

如果方法中包含一個返回語句,並且該語句所指定的運算符類型為 TResult,則您應將 Task < TResult > 作為返回類型進行指定。

如果方法沒有返回語句,或者其返回語句所返回的值並非操作數,則您應將 “Task” 作為返回類型。

您還可以指定任何其他返回類型,前提是該類型包含一個 “GetAwaiter” 方法。例如 “ValueTask < TResult >” 就是這種類型的示例。它可在 “System . Threading . Tasks . Extension” 這個 NuGet 包中找到。

以下示例展示瞭如何聲明並調用一個返回類型為 Task < TResult > 或 Task 的方法:

async Task<int> GetTaskOfTResultAsync ( )
{
    int hours = 0;
    await Task . Delay ( 0 );

    return hours;
}

Task<int> returnedTaskTResult = GetTaskOfTResultAsync ( );
int intResult = await returnedTaskTResult;
// 單行
// int intResult = await GetTaskOfTResultAsync ( );

async Task GetTaskAsync ( )
    {
        await Task . Delay ( 0 );
        // 無需 return 語句
    }

Task returnedTask = GetTaskAsync ( );
await returnedTask;
// 單行
await GetTaskAsync ( );

每個返回的任務都代表着正在進行的工作。任務封裝了異步進程狀態的信息,並且最終要麼包含該進程的最終結果,要麼包含進程未成功時引發的異常。

異步方法也可以具有 void 返回類型。這種返回類型主要用於定義事件處理程序,在這種情況下需要 void 返回類型。異步事件處理程序通常是異步程序的起點。

具有 void 返回類型的異步方法不能被等待,而且調用 void 類型返回的方法的調用者無法捕獲該方法拋出的任何異常。

異步方法不能聲明 in、ref 或 out 參數,但該方法可以調用具有此類參數的方法。同樣,異步方法不能通過引用返回值,儘管它可以調用具有 ref 返回值的方法。

在 Windows 運行時編程中,異步 API 具有以下返回類型之一,這些類型類似於任務:

  • IAsyncOperation < TResult >,對應於 Task < TResult >
  • IAsyncAction,對應於 Task
  • IAsyncActionWithProgress < TProgress >
  • IAsyncOperationWithProgress < TResult , TProgress >

命名約定

按照慣例,返回常見可等待類型(例如 Task、Task < T >、ValueTask、ValueTask < T >)的方法,其名稱應以 “Async”(我習慣 YB) 結尾。啓動異步操作但不返回可等待類型的方法不應以 “Async” 結尾,但可以以 “Begin”、“Start” (我習慣 QS)或其他動詞開頭,以表明此方法不會返回或拋出操作的結果。

您可以忽略事件、基類或接口契約所暗示的不同名稱的約定。例如,不應重命名常見的事件處理程序,如 OnButtonClick。

Async 返回類型

異步方法可以有以下返回類型:

  • Task,用於執行操作但不返回值的異步方法。
  • Task < TResult >,適用於返回值的異步方法。
  • void,用於事件處理程序。
  • 任何具有可訪問的 GetAwaiter 方法的類型。GetAwaiter 方法返回的對象必須實現System . Runtime . CompilerServices . ICriticalNotifyCompletion 接口。
  • IAsyncEnumerable < T >,用於返回異步流的異步方法。

還存在其他幾種特定於Windows工作負載的類型:

  • DispatcherOperation,用於僅限 Windows 的異步操作。
  • IAsyncAction,用於通用 Windows平台(UWP)應用中不返回值的異步操作。
  • IAsyncActionWithProgress < TProgress >,用於通用 Windows 平台(UWP)應用中報告進度但不返回值的異步操作。
  • IAsyncOperation < TResult >,用於通用 Windows 平台(UWP)應用中返回值的異步操作。
  • IAsyncOperationWithProgress < TResult , TProgress >,用於在 UWP 應用中報告進度並返回值的異步操作。

Task 返回類型

不包含 return 語句或包含不返回操作數的 return 語句的異步方法,其返回類型通常為 Task。如果這些方法同步運行,則返回 void。如果為異步方法使用 Task 返回類型,調用方法可以使用 await 運算符暫停調用方的完成,直到被調用的異步方法完成。

在下面的示例中,WaitAndApologizeAsync 方法不包含 return 語句,因此該方法返回一個 Task 對象。返回 Task 使 WaitAndApologizeAsync 能夠被等待。Task 類型不包含 Result 屬性,因為它沒有返回值。

await FF等待並道歉YB ( );

Console . WriteLine ( $"今天是:{DateTime . Now:D}" );
Console . WriteLine ( $"當前時間:{DateTime . Now:t}" );
Console . WriteLine ( "當前氣温:10 ℃。" );

static async Task FF等待並道歉YB ( )
    {
        await Task . Delay ( 2000 );
        Console . WriteLine ( "對不起,讓你久等了……\n" );
    }

FF等待並道歉 通過使用 await 語句而非 await 表達式來等待,這與調用同步 void 返回方法的語句類似。在這種情況下,應用 await 運算符不會產生值。當 await 的右操作數是 Task < TResult > 時,await 表達式會產生 T 類型的結果。當 await 的右操作數是 Task 時,await 及其操作數構成一個語句。

您可以將對 FF等待並道歉 的調用與 await 運算符的應用分開,如下列代碼所示。但請記住,Task 沒有 Result 屬性,並且當 await 運算符應用於 Task 時,不會產生任何值。

以下代碼將調用 FF等待並道歉 方法與等待該方法返回的任務分離開來。

Task RW等待並道歉 = FF等待並道歉YB ( );

string ShuChu = $"今天是:{DateTime . Now:D}\n當前時間:{DateTime . Now:t}\n當前氣温:10 ℃。";

await RW等待並道歉;
Console . WriteLine ( ShuChu );

static async Task FF等待並道歉YB ( )
    {
        await Task . Delay ( 2000 );
        Console . WriteLine ( "對不起,讓你久等了……\n" );
    }

Task < TResult > 返回類型

Task < TResult > 返回類型用於異步方法,該方法包含 return 語句,且該語句的操作數為 TResult。

在下面的示例中,FF獲取空閒時間YB 方法包含一個 return 語句,該語句返回一個整數。方法聲明必須指定返回類型為 Task < int >。FromResult 異步方法是一個佔位符,用於返回 DayOfWeek 的操作。

string xinxi = $"今天是 {DateTime . Now:dddd},今天的空閒時間:\n    {await FF獲取空閒時間YB ( )} 小時。";
Console . WriteLine ( xinxi );

static async Task<int> FF獲取空閒時間YB ( )
    {
        DayOfWeek jt = await Task . FromResult ( DateTime . Now . DayOfWeek );
        int Z空閒小時 =
            jt is DayOfWeek.Sunday || jt is DayOfWeek.Saturday ? 16 : 5;
        return Z空閒小時;
    }

當在 Main ( ) 方法的 await 表達式中調用 FF獲取空閒時間YB 時,該 await 表達式會檢索由 FF獲取空閒時間YB 方法返回的任務中存儲的整數值(即 Z空閒小時 的值)。

通過將對 FF獲取空閒時間YB 的調用與 await 的應用分開,你可以更好地理解 await 如何從 Task < T > 中檢索結果,如下列代碼所示。正如從方法聲明中所預期的那樣,調用未立即等待的 FF獲取空閒時間YB 方法會返回一個 Task < int >。在示例中,該任務被分配給 rw獲取空閒時間 變量。由於 rw獲取空閒時間 是 Task < TResult>,它包含一個類型為 TResult 的 Result 屬性。在這種情況下,TResult 表示整數類型。當將 await 應用於 rw獲取空閒時間 時,await 表達式的計算結果為 rw獲取空閒時間 的 Result 屬性的內容。該值被分配給 ret 變量。

重要提示:Result 屬性是一個阻塞屬性。如果在其任務完成之前嘗試訪問它,當前活動的線程會被阻塞,直到任務完成且值可用。在大多數情況下,你應該使用 await 來訪問該值,而不是直接訪問該屬性。前面的示例檢索了 Result 屬性的值以阻塞主線程,這樣 Main 方法就能在應用程序結束前將 xinxi 打印到控制枱。

var rw獲取空閒時間 = FF獲取空閒時間YB ( );
string xinxi = $"今天是 {DateTime . Now:dddd},今天的空閒時間:\n    {await rw獲取空閒時間} 小時。";
Console . WriteLine ( xinxi );

void 返回類型

在異步事件處理程序中,你會使用 void 返回類型,這類處理程序要求使用 void 返回類型。對於非事件處理程序且不返回值的方法,你應該返回 Task,因為返回 void 的異步方法無法被等待。此類方法的任何調用方都必須在不等待被調用的異步方法完成的情況下繼續執行直至結束。調用方必須不受該異步方法生成的任何值或異常的影響。

返回 void 的異步方法的調用者無法捕獲該方法拋出的異常。此類未處理的異常可能會導致應用程序失敗。如果返回 Task 或 Task < TResult > 的方法拋出異常,該異常會存儲在返回的任務中。等待任務時,異常會被重新拋出。請確保任何可能產生異常的異步方法都具有 Task 或 Task < TResult > 的返回類型,並且對該方法的調用是被等待的。

以下示例展示了異步事件處理程序的行為。在示例代碼中,異步事件處理程序必須在完成時通知主線程。這樣,主線程可以在退出程序前等待異步事件處理程序完成。

await LEIAsyncVoid示例 . JuBings多事件YB ( );

public class ANN單純
    {
        public event EventHandler? DanJiLe;

        public void DanJi ( )
            {
                Console . WriteLine ( "有人按下了按鈕。讓我們啓動這個事件吧……" );
                DanJiLe? . Invoke ( this , EventArgs . Empty );
                Console . WriteLine ( "所有監聽者均已收到通知。" );
            }
    }

public class LEIAsyncVoid示例
    {
        static readonly TaskCompletionSource<bool> TCS任務完成源 = new ( );

        public static async Task JuBings多事件YB ( )
            {
                Task<bool> RW第二個句柄結束 = TCS任務完成源 . Task;

                var AnNiu = new ANN單純 ( );

                AnNiu . DanJiLe += OnAnnDanJi1;
                AnNiu . DanJiLe += OnAnnDanJi2YB;
                AnNiu . DanJiLe += OnAnnDanJi3;

                Console . WriteLine ( "在 AnNiu . DanJiLe ( ) 被調用之前……" );
                AnNiu . DanJi ( );
                Console . WriteLine ( "在 AnNiu . DanJiLe ( ) 被調用之後……" );

                await RW第二個句柄結束;
        }

private static void OnAnnDanJi1 ( object? sender , EventArgs e )
    {
        Console . WriteLine ( "……句柄 1 啓動……" );
        Task . Delay ( 100 ) . Wait ( );
        Console . WriteLine ( "……句柄 1 結束。" );
    }

private static async void OnAnnDanJi2YB ( object? sender , EventArgs e )
    {
        Console . WriteLine ( "……句柄 2 啓動……" );
        Task . Delay ( 100 ) . Wait ( );
        Console . WriteLine ( "……句柄 2 即將轉為異步模式……" );
        await Task . Delay ( 500 );
        Console . WriteLine ( "……句柄 2 結束。" );
        TCS任務完成源 . SetResult ( true );
    }

private static void OnAnnDanJi3 ( object? sender , EventArgs e )
    {
        Console . WriteLine ( "……句柄 3 啓動……" );
        Task . Delay ( 100 ) . Wait ( );
        Console . WriteLine ( "……句柄 3 結束。" );
    }
}

泛化異步返回類型和 ValueTask < TResult >

異步方法可以返回任何類型,只要該類型具有可訪問的 GetAwaiter 方法,且該方法返回等待器類型的實例。此外,返回的類型必須與 SetResult 的參數類型以及由 System . Runtime . CompilerServices . AsyncMethodBuilderAttribute 特性指定的類型上的 Task 屬性的返回類型相匹配。

此功能是對 awaitable experssions(可等待表達式)的補充,後者描述了 await 操作數的要求。泛化異步返回類型使編譯器能夠生成返回不同類型的 async 方法。泛化異步返回類型為 .NET 庫帶來了性能提升。由於 Task 和 Task < TResult > 是引用類型,在性能關鍵路徑中的內存分配(尤其是在緊湊循環中發生的分配)可能會對性能產生不利影響。對泛化返回類型的支持意味着你可以返回輕量級值類型而非引用類型,以避免更多的內存分配。

.NET 提供了 System . Threading . Tasks . ValueTask < TResult > 結構,作為廣義任務返回值的輕量級實現。以下示例使用 ValueTask < TResult > 結構來獲取兩次擲骰子的結果值。

await FF擲色子結果YB ( );

static async Task FF擲色子結果YB ( )
    {
    Console . WriteLine ( $"你擲出了 {await FF搖色子YB ( )}" );
    }

static async ValueTask<int> FF搖色子YB ( )
    {
    Console . WriteLine ( "搖色子中……" );

    int SZ1 = await FF搖YB ( );
    int SZ2 = await FF搖YB ( );

    return SZ1 + SZ2;
    }

static async ValueTask<int> FF搖YB ( )
    {
    await Task . Delay ( 500 );
    Random SJS = new ( );
    int Z色子數 = SJS . Next ( 1 , 7 );
    return Z色子數;
    }

編寫通用的異步返回類型是一種高級場景,適用於特定環境。可以考慮改用 < Task >、< Task < T > > 和 < ValueTask < T > > 類型,它們涵蓋了大多數異步代碼場景。

你可以將 AsyncMethodBuilder 特性應用於異步方法(而非異步返回類型聲明),以覆蓋該類型的生成器。通常,你會應用此特性來使用 .NET 運行時中提供的不同生成器。

使用 IAsyncEnumerable < T > 的異步流

異步方法可能會返回一個異步流,由 IAsyncEnumerable < T > 表示。當通過重複的異步調用以塊的形式生成元素時,異步流提供了一種枚舉從流中讀取的項的方式。以下示例展示了一個生成異步流的異步方法:

{
    static async Task Main ( string [ ] args )
        {
            await foreach ( string ci in ReadWordsAsync ( ) )
                Console.Write ( $"{ci}    " );
        }

    public static async IAsyncEnumerable<string> FF讀詞YB ( )
        {
            string zfc = @"This is a line of text.
              Here is the second line of text.
              And there is one more for good measure.
              Wait, that was the penultimate line.";

            using var LD = new StringReader ( zfc );

            string? zfc行 = await LD . ReadLineAsync ( );
            while ( zfc行 != null )
                {
                    foreach ( string ci in zfc行 . Split ( ' ' , StringSplitOptions . RemoveEmptyEntries ) )
                        {
                            yield return ci;
                        }

                    zfc行 = await reader . ReadLineAsync ( );
                }
        }
    }

前面的示例異步讀取字符串中的行。每讀取一行後,代碼就會枚舉該字符串中的每個單詞。調用方會使用 await foreach 語句來枚舉每個單詞。當需要從源字符串異步讀取下一行時,該方法會進行等待。

在任務完成時處理異步任務

通過使用 Task . WhenAny,你可以同時啓動多個任務,並在它們完成時逐個處理,而不是按照啓動順序來處理。

以下示例使用查詢創建一個任務集合。每個任務都會下載指定網站的內容。在 while 循環的每次迭代中,對 WhenAny 的等待調用會返回任務集合中最先完成下載的任務。該任務會從集合中移除並進行處理。循環會重複執行,直到集合中不再有任務為止。

創建示例應用程序

創建一個 .NET Core Console 應用程序。在其首部添加一個 using 語句:
using System . Diagnostics;

添加字段

在 Program 類定義中,添加以下兩個字段:

static readonly HttpClient wlkh = new ( )
    {
        MaxResponseContentBufferSize = 1_000_000
    };

static readonly IEnumerable<string> zfcURLs = 
    [
            "https://learn.microsoft.com",
            "https://learn.microsoft.com/aspnet/core",
            "https://learn.microsoft.com/azure",
            "https://learn.microsoft.com/azure/devops",
            "https://learn.microsoft.com/dotnet",
            "https://learn.microsoft.com/dynamics365",
            "https://learn.microsoft.com/education",
            "https://learn.microsoft.com/enterprise-mobility-security",
            "https://learn.microsoft.com/gaming",
            "https://learn.microsoft.com/graph",
            "https://learn.microsoft.com/microsoft-365",
            "https://learn.microsoft.com/office",
            "https://learn.microsoft.com/powershell",
            "https://learn.microsoft.com/sql",
            "https://learn.microsoft.com/surface",
            "https://learn.microsoft.com/system-center",
            "https://learn.microsoft.com/visualstudio",
            "https://learn.microsoft.com/windows",
            "https://learn.microsoft.com/maui"
    ];

HttpClient 提供發送 HTTP 請求和接收 HTTP 響應的功能。zfcURLs 存儲應用程序計劃處理的所有 URL。

添加處理方法

在 FF頁面求和YB 方法前面添加以下 FF處理URLYB 方法:

static async Task <int> FF處理URLYB ( string url , HttpClient 客户端 )
    {
        try
            {
                byte [ ] ZhuTi = await 客户端 . GetByteArrayAsync ( url );
                Console . WriteLine ( $"{url,-60} {ZhuTi . Length,10:#,#}" );

                return ZhuTi . Length;
            }
        catch ( Exception ex )
            {
                Console . WriteLine ( $"{url} - {ex . Message}" );
                return 0;
            }
    }

對於任何給定的 URL,該方法將使用提供的 客户端 實例以 byte [ ] 的形式獲取響應。在 URL 和長度寫入控制枱後,會返回該長度。

多次運行該程序,以驗證下載的長度並非總是按相同順序出現。

注意:如示例中所述,你可以在循環中使用 WhenAny 來解決涉及少量任務的問題。但是,如果你有大量任務需要處理,其他方法會更高效。

創建異步求和頁面大小的方法

在 Main 方法下方,添加 FF頁面求和YB 方法:

static async Task FF頁面求和YB ( )
    {
        var biao = Stopwatch . StartNew ( );
        IEnumerable <Task<int>> CHX下載任務 =
            from url in zfcURLs
            select FF處理URLYB ( url , wlkh );
    }

while 循環在每次迭代中移除一個任務。所有任務完成後,循環結束。該方法首先實例化並啓動一個 Stopwatch。然後它包含一個查詢,執行該查詢時會創建一個任務集合。以下代碼中對 FF處理URLYB 的每次調用都會返回一個 Task < TResult >,其中 TResult 是一個整數:

IEnumerable<Task<int>> CHX下載任務int =
    from url in zfcURLs
    select FF處理URLYB ( url , wlkh );

由於 LINQ 採用延遲執行,因此需要調用 Enumerable . ToList 來啓動每個任務。
while 循環會為集合中的每個任務執行以下步驟:

  1. 等待對 WhenAny 的調用,以確定集合中第一個完成下載的任務。
    Task < int > rw完成 = await Task . WhenAny ( rw下載 );
  2. 將該任務從集合中移除。
    rw下載 . Remove ( rw完成 );
  3. 等待 rw完成,它由調用 FF處理URLYB 返回。rw完成 變量是一個 Task < TResult >,其中 TResult 是整數。該任務已經完成,但你可以等待它來獲取所下載網站的長度,如下例所示。如果任務出錯,await 將拋出存儲在 AggregateException 中的第一個子異常,這與讀取 Task < TResult > . Result 屬性不同,後者會拋出 AggregateException。
    Z總數 += await rw完成;

    使用 Task . WhenEach 簡化該方法

    在 FF頁面求和YB 方法中實現的 while 循環可以通過在 await foreach 循環中調用 .NET 9 中引入的新 Task . WhenEach 方法來簡化。

替換之前實現的 while 循環:

while ( rw下載 . Count != 0 )
    {
        Task<int> rw完成 = await Task . WhenAny ( rw下載 );
        rw下載 . Remove ( rw完成 );
        Z總數 += await rw完成;
    }

使用簡化的 await foreach:

await foreach ( Task <int> rw in Task . WhenEach ( rw下載 ) )
    {
        Z總數 += await rw;
    }

這種新方法不再需要反覆調用 Task . WhenAny 來手動調用任務並移除已完成的任務,因為 Task . WhenEach 會按照任務完成的順序對其進行迭代。

更新應用程序入口點

控制枱應用程序的主要入口點是 Main 方法。將現有方法替換為以下內容:
static Task Main ( ) => FF頁面求和YB ( );
更新後的 Main 方法現在被視為異步主方法,它允許以異步方式進入可執行文件。它表現為對 FF頁面求和YB 的調用。

完整代碼

internal class Program
    {

        static readonly HttpClient wlkh = new ( )
            {
            MaxResponseContentBufferSize = 1_000_000
            };

        static readonly IEnumerable<string> zfcURLs = 
            [
                "https://learn.microsoft.com",
                "https://learn.microsoft.com/aspnet/core",
                "https://learn.microsoft.com/azure",
                "https://learn.microsoft.com/azure/devops",
                "https://learn.microsoft.com/dotnet",
                "https://learn.microsoft.com/dynamics365",
                "https://learn.microsoft.com/education",
                "https://learn.microsoft.com/enterprise-mobility-security",
                "https://learn.microsoft.com/gaming",
                "https://learn.microsoft.com/graph",
                "https://learn.microsoft.com/microsoft-365",
                "https://learn.microsoft.com/office",
                "https://learn.microsoft.com/powershell",
                "https://learn.microsoft.com/sql",
                "https://learn.microsoft.com/surface",
                "https://learn.microsoft.com/system-center",
                "https://learn.microsoft.com/visualstudio",
                "https://learn.microsoft.com/windows",
                "https://learn.microsoft.com/maui"
            ];

    static Task Main ( ) => FF頁面求和YB ( );

    static async Task FF頁面求和YB ( )
        {
            var biao = Stopwatch . StartNew ( );

            IEnumerable <Task<int>> CHX下載任務 =
                from url in zfcURLs
                select FF處理URLYB ( url , wlkh );

            List <Task<int>> rw下載 = [ .. CHX下載任務 ];
            int Z總數 = 0;
            await foreach ( Task <int> rw in Task . WhenEach ( rw下載 ) )
                {
                Z總數 += await rw;
                }

            biao . Stop ( );
            Console . WriteLine ( $"\n返回總字節數:{Z總數:#,#}" );
            Console . WriteLine ( $"經歷時間:          {biao . Elapsed}\n" );
    }

    static async Task <int> FF處理URLYB ( string url , HttpClient 客户端 )
        {
            try
                {
                byte [ ] ZhuTi = await 客户端 . GetByteArrayAsync ( url );
                Console . WriteLine ( $"{url,-60} {ZhuTi . Length,10:#,#}" );

                return ZhuTi . Length;
            }
        catch ( Exception ex )
            {
                Console . WriteLine ( $"{url} - {ex . Message}" );
                return 0;
            }
        }
    }

異步文件訪問

你可以使用異步功能來訪問文件。通過使用異步功能,你可以調用異步方法,而無需使用回調或將代碼拆分到多個方法或 lambda 表達式中。要將同步代碼變為異步,只需調用異步方法而非同步方法,並在代碼中添加幾個關鍵字即可。

您可能會考慮為文件訪問調用添加異步性的以下原因:

  • 異步處理讓 UI 應用程序的響應性更強,因為啓動操作的 UI 線程可以執行其他工作。如果 UI 線程必須執行耗時較長的代碼(例如,超過 50 毫秒),UI可能會凍結,直到 I/O 操作完成,UI 線程才能再次處理鍵盤和鼠標輸入以及其他事件。
  • 異步通過減少對線程的需求,提高了 ASP . NET 和其他基於服務器的應用程序的可擴展性。如果應用程序為每個響應使用一個專用線程,並且同時處理 1,000 個請求,那麼就需要 1,000 個線程。異步操作在等待期間通常不需要使用線程,它們在結束時會短暫使用現有的 I/O 完成線程。
  • 在當前條件下,文件訪問操作的延遲可能非常低,但未來延遲可能會大幅增加。例如,某個文件可能會被轉移到位於世界另一端的服務器上。
  • 使用異步功能所增加的開銷很小。
  • 異步任務可以輕鬆並行運行。

使用適當的類

本主題中的簡單示例展示了 File . WriteAllTextAsync 和 File . ReadAllTextAsync。要對文件 I/O 操作進行精細控制,請使用 FileStream 類,該類提供了一個選項,可使異步 I/O 在操作系統級別發生。通過使用此選項,在許多情況下可以避免阻塞線程池線程。要啓用此選項,請在構造函數調用中指定 useAsync = true 或 options = FileOptions . Asynchronous 參數。

如果通過指定文件路徑直接打開 StreamReader 和 StreamWriter,則不能使用此選項。但是,如果為它們提供 FileStream 類打開的 Stream,則可以使用此選項。在 UI 應用程序中,即使線程池線程被阻塞,異步調用也會更快,因為等待期間 UI 線程不會被阻塞。

撰寫文本

以下示例向文件寫入文本。在每個 await 語句處,方法會立即退出。當文件 I/O 完成後,方法會從 await 語句後的那條語句繼續執行。async 修飾符用於定義使用 await 語句的方法。

static async Task FF寫入點什麼YB ( )
    {
    string zfc路徑 = @"F:\測試文件夾\異步寫入.txt";
    string zfc = "隨便寫點什麼";

    await File . WriteAllTextAsync ( zfc路徑 , zfc );
    }

有限控制示例

static async Task FF寫點複雜的YB ( string 路徑 , string 文本 )
    {
    try
        {
        if ( Directory . Exists ( 路徑 ) )
            {
            try
                {
                byte [ ] bianmawenben = Encoding . UTF8 . GetBytes ( 文本 );
                using var liu =
                    new FileStream (
                        Path .Combine ( 路徑 , "複雜文本.txt" ),
                        FileMode . Create , FileAccess . Write , FileShare . None,
                        bufferSize: 4096,
                        useAsync: true);
                await liu . WriteAsync ( bianmawenben , 0 , bianmawenben . Length );
                }
            catch ( Exception ex )
                {
                Console . WriteLine ( ex . Message );
                }
            }
        else throw new DirectoryNotFoundException ( "文件路徑不存在……" );
        }
    catch ( Exception ex ) { Console . WriteLine ( ex . Message ); }
    }

原始示例中有語句 await liu . WriteAsync ( bianmawenben , 0 , bianmawenben . Length );,它是以下兩個語句的縮寫:

Task rw = liu . WriteAsync ( bianmawenben , 0 , bianmawenben . Length );
await rw;

第一個語句返回一個任務,並啓動文件處理。帶有 await 的第二個語句會導致該方法立即退出並返回一個不同的任務。當文件處理稍後完成時,執行會返回到 await 後面的語句。

閲讀文本

以下示例從文件中讀取文本。

簡單示例

static async Task FF讀取YB ( )
    {
    string zfc路徑 = @"F:\測試文件夾\異步寫入.txt";
    string zfc = await File . ReadAllTextAsync ( zfc路徑 );
    Console . WriteLine ( zfc );
    }

有限控制示例

文本會被緩衝,在這種情況下,會被放入一個 StringBuilder 中。與前面的示例不同,await 的求值會產生一個值。ReadAsync 方法返回一個 Task < Int32 >,因此在操作完成後,await 的求值會產生一個 Int32 值 Z讀取。

static async Task <string> FF讀文本YB ( string 文件路徑 )
    {
    using var liu =
        new FileStream (
            文件路徑,
            FileMode . Open , FileAccess . Read , FileShare.Read,
            4096 , true );

    StringBuilder zc = new ( );
    byte [ ] huanchongqu = new byte [ 0x1000 ];
    int Z讀取;

    while ( ( Z讀取 = await liu . ReadAsync ( huanchongqu , 0 , huanchongqu . Length ) ) != 0 )
        {
        string zfc = Encoding . UTF8 . GetString ( huanchongqu , 0 , Z讀取 );
        zc . Append ( zfc );
        }

    return zc . ToString ( );
    }

static async Task FF複雜讀取YB ( string 路徑 , string 文件名 )
    {
    try
        {
        if ( Directory . Exists ( 路徑 ) == false )
            { throw new DirectoryNotFoundException ( $"{路徑} 不存在,請檢查" ); }

        string zfc完整路徑 = Path . Combine ( 路徑 , 文件名 );

        if ( File . Exists ( zfc完整路徑 ) == false )
            { throw new FileNotFoundException ( $"{zfc完整路徑} 不存在,請檢查" ); }

        string wb = await FF讀文本YB ( zfc完整路徑 );
        Console . WriteLine ( wb );
        }
    catch ( DirectoryNotFoundException ljyc )
        { Console . WriteLine ( ljyc . ToString ( ) );}
    catch ( FileNotFoundException wjyc )
        { Console . WriteLine ( wjyc . ToString ( ) ); }
    catch ( Exception yc )
    { Console . WriteLine ( yc . ToString ( ) ); }
    }

並行異步 I/O

以下示例通過寫入 10 個文本文件來演示並行處理。

簡單示例

static async Task FF簡單的並行寫入YB ( )
    {
    string zfc文件夾= Directory . CreateDirectory ( @"F:\測試文件夾\並行寫入" ) . FullName;
    IList<Task> lb寫任務 = [ ];

    for ( int sy = 11 ; sy <=20 ; ++ sy )
        {
        string zfc文件名 = $"{sy} - 文本.txt";
        string zfc文件 = Path . Combine ( zfc文件夾 , zfc文件名 );
        string wb = $"在 {sy} 文件中……{Environment . NewLine}";

        lb寫任務 . Add ( File . WriteAllTextAsync ( zfc文件 , wb ) );
        }
    await Task . WhenAll ( lb寫任務 );
    }

有限控制示例

對於每個文件,WriteAsync 方法會返回一個任務,該任務隨後會被添加到任務列表中。await Task . WhenAll ( lb寫任務 ); 語句會退出該方法,並在所有任務的文件處理完成後在該方法內恢復執行。

該示例在任務完成後,在 finally 塊中關閉了所有 FileStream 實例。如果每個 FileStream 都是在 using 語句中創建的,那麼 FileStream 可能會在任務完成前就被釋放。

任何性能提升幾乎完全來自並行處理,而非異步處理。異步的優勢在於它不會佔用多個線程,也不會佔用用户界面線程。

static async Task FF複雜並行寫入YB ( )
    {
    IList <FileStream> LIUs = [ ];

    try
        {
        string zfc文件夾 = Directory . CreateDirectory ( @"F:\測試文件夾\複雜並行寫入" ) . FullName;
        IList <Task> lb寫任務 = [ ];
        for ( int sy = 100 ; sy <= 110 ; ++sy )
            {
            try
                {
                string zfc文件名 = $"{sy} 號文件.txt";
                string zfc文件 = Path . Combine ( zfc文件夾 , zfc文件名 );
                string wb = $"在第 {sy} 號文件中……";
                byte [ ] wb編碼 = Encoding . UTF8 . GetBytes ( wb );
                var Liu =
                    new FileStream (
                        zfc文件,
                        FileMode . Create , FileAccess . Write , FileShare.None ,
                        4096 , true );
                Task rw寫 = Liu . WriteAsync ( wb編碼 , 0 , wb編碼 . Length );
                LIUs . Add ( Liu );
                lb寫任務 . Add ( rw寫 );
                }
            catch ( Exception ex ) { Console . WriteLine ( ex . Message ); }
            }
        await Task . WhenAll ( lb寫任務 );
        }
    finally
        {
        foreach ( var L in LIUs )
            {
            L . Close ( );
            }
        }
    }

使用 WriteAsync 和 ReadAsync 方法時,你可以指定一個 CancellationToken,通過它可以在操作進行中取消該操作。

取消任務列表

如果不想等待異步控制枱應用程序完成,可以將其取消。按照本主題中的示例,你可以為一個下載網站列表內容的應用程序添加取消功能。通過將 CancellationTokenSource 實例與每個任務相關聯,你可以取消多個任務。如果按下 Enter 鍵,所有尚未完成的任務都會被取消。

替換 using 指令

新建一個 Console 應用程序,並添加下列 using 語句:

using System;
using System . Collections . Generic;
using System . Diagnostics;
using System . Net . Http;
using System . Threading;
using System . Threading . Tasks;

添加字段

在 Program 類定義中,添加這三個字段:

CancellationTokenSource quxiaoyuan = new ( );

HttpClient wlkh = new ( )
    {
    MaxResponseContentBufferSize = 1_000_000
    };

IEnumerable<string> LBURLs =
[
    "https://learn.microsoft.com",
    "https://learn.microsoft.com/aspnet/core",
    "https://learn.microsoft.com/azure",
    "https://learn.microsoft.com/azure/devops",
    "https://learn.microsoft.com/dotnet",
    "https://learn.microsoft.com/dynamics365",
    "https://learn.microsoft.com/education",
    "https://learn.microsoft.com/enterprise-mobility-security",
    "https://learn.microsoft.com/gaming",
    "https://learn.microsoft.com/graph",
    "https://learn.microsoft.com/microsoft-365",
    "https://learn.microsoft.com/office",
    "https://learn.microsoft.com/powershell",
    "https://learn.microsoft.com/sql",
    "https://learn.microsoft.com/surface",
    "https://learn.microsoft.com/system-center",
    "https://learn.microsoft.com/visualstudio",
    "https://learn.microsoft.com/windows",
    "https://learn.microsoft.com/maui"
];

CancellationTokenSource 用於向 CancellationToken 發出請求取消的信號。HttpClient 提供發送 HTTP 請求和接收 HTTP 響應的功能。LBURL 存儲應用程序計劃處理的所有 URL。

更新應用程序入口點

控制枱應用程序的主要入口點是 Main 方法。將現有方法替換為以下內容:

// 程序入口點(支持異步操作)
static async Task Main(string[] args)
    {
        // 初始化取消令牌源(用於觸發取消操作)
        var quxiaoyuan = new CancellationTokenSource();

        // 輸出啓動信息
        Console . WriteLine ( "應用程序啓動:" );
        Console . WriteLine ( "按下 Enter 鍵取消……\n");

        // 啓動用户輸入監聽任務(等待Enter鍵取消)
        var rw取消 = Task . Run ( ( ) =>
            {
                // 循環監聽按鍵,直到按下 Enter
                while ( Console . ReadKey ( true ) . Key != ConsoleKey . Enter ) // true 表示不顯示按鍵
                    {
                        Console . WriteLine ( "按下 Enter 鍵取消……\n" );
                    }
                Console . WriteLine ( "正在取消下載……\n" );
                quxiaoyuan . Cancel ( ); // 觸發取消信號
        });

        try
            {
                // 啓動核心任務
                Task rw頁面合計任務 = FF頁面求和YB ( LBURLs , wlkh , LP , quxiaoyuan . Token );

                // 等待兩個任務中的任意一個完成(核心任務完成 或 取消任務觸發)
                await Task . WhenAny ( rw頁面合計任務 , rw取消 );

                // 判斷哪個任務先完成
                if ( rw頁面合計任務 . IsCompleted )
                    {
                        await rw頁面合計任務; // 確保等待核心任務完成(捕獲可能的異常)
                        Console . WriteLine ( "下載完成!" );
                    }
                else
                    {
                        Console . WriteLine ( "下載已取消!" );
                    }
            }
        catch ( OperationCanceledException )
            {
                Console . WriteLine ( "操作已取消" );
            }
        catch ( Exception ex )
            {
                Console . WriteLine ( $"發生錯誤:{ex . Message}" );
            }
        }

更新後的 Main 方法現在被視為異步主方法,它允許可執行文件擁有異步入口點。該方法向控制枱輸出幾條説明信息,然後聲明一個名為 rw取消 的 Task 實例,該實例將讀取控制枱按鍵。如果按下回車鍵,就會調用 quxiaoyuan . Cancel ( ),這將發出取消信號。接下來,rw頁面合計任務 變量由 FF頁面求和YB 方法賦值。然後,這兩個任務都被傳遞給 Task . WhenAny ( Task [ ] ),當這兩個任務中的任何一個完成時,該方法就會繼續執行。

下一段代碼確保應用程序在取消操作處理完成前不會退出。如果第一個完成的任務是 rw取消,則會等待 FF頁面求和YB。如果該任務已被取消,在等待時會拋出 System . Threading . Tasks . TaskCanceledException。這段代碼會捕獲該異常並打印一條消息。

創建頁面求和異步方法

在 Main 方法下,添加 FF頁面求和YB 方法:

static async Task<int> FF頁面求和YB ( IEnumerable<string> Urls , HttpClient 客户端 , CancellationToken 令牌 )
    {
        var biao = Stopwatch . StartNew ( );

        int Z總數 = 0;
        foreach ( string u in Urls )
            {
                int ZCD = await FF處理URLYB ( u , 客户端 , 令牌 );
                Z總數 += ZCD;
            }

        biao . Stop ( );
        Console . WriteLine ( $"\n返回總字節數:{Z總數:#,#}" );
        Console . WriteLine ( $"經歷時間:          {biao . Elapsed}\n" );

        return Z總數;
    }

該方法首先實例化並啓動一個秒錶。然後,它遍歷 Urls 中的每個 URL,並調用 FF處理URLYB。在每次迭代中,令牌 被傳入 FF處理URLYB 方法,代碼返回一個 Task < TResult >,其中 TResult 是一個整數。

添加處理 URL 方法

在 FF頁面求和YB 方法下方添加以下 FF處理URLYB 方法:

static async Task <int> FF處理URLYB ( string Url , HttpClient 客户端 , CancellationToken 令牌 )
    {
        HttpResponseMessage xiangying = await 客户端 . GetAsync ( Url , 令牌 );
        byte [ ] ZhuTi = await xiangying . Content . ReadAsByteArrayAsync ( 令牌 );
        Console . WriteLine ( $"{Url,-60}{ZhuTi . Length,10:#,#}" );
        return ZhuTi . Length;
    }

對於任何給定的 Url,該方法將使用提供的 客户端 實例以 byte [ ] 的形式獲取響應。CancellationToken 實例會被傳入 HttpClient . GetAsync ( String , CancellationToken ) 和 HttpContent . ReadAsByteArrayAsync ( ) 方法。令牌 用於註冊請求的取消操作。在 Url 和長度被寫入控制枱後,將返回該長度。

一段時間後取消異步任務

如果不想等待異步操作完成,可以使用 CancellationTokenSource . CancelAfter 方法在一段時間後取消該操作。此方法會安排取消所有在 CancelAfter 表達式指定的時間內未完成的關聯任務。

此示例在《取消任務列表(C#)》中開發的代碼基礎上進行了補充,用於下載網站列表並顯示每個網站內容的長度。

更新應用程序入口點

用以下內容替換現有的 Main 方法:

try
    {
    quxiaoyuan . CancelAfter ( 3500 );
    await FF頁面求和YB ( LBURLs , wlkh , LP );
    }
catch ( OperationCanceledException ) { Console . WriteLine ( "\n任務取消:超時。\n" ); }
finally { quxiaoyuan . Dispose ( );  }

Console . WriteLine ( "應用程序結束。" );

更新後的 Main 方法向控制枱寫入了一些説明信息。在 try……catch 塊中,對 CancellationTokenSource . CancelAfter ( Int32 ) 的調用會安排一次取消操作。這將在一段時間後發出取消信號。

接下來,等待 FF頁面求和YB 方法。如果處理所有 URL 的速度快於預定的取消操作,應用程序就會結束。但是,如果在所有 URL 處理完成之前觸發了預定的取消操作,就會拋出 OperationCanceledException 異常。

教程:使用 C# 和 .NET 生成並使用異步流

異步流為數據流提供了模型。數據流通常會異步檢索或生成元素。它們為異步流式數據源提供了一種自然的編程模型。

運行入門應用程序

該初始應用程序是一個控制枱應用程序,它使用 GitHub GraphQL 接口檢索在 dotnet/docs 存儲庫中編寫的最新問題。首先查看初始應用程序 Main 方法的以下代碼:

// 按照以下步驟創建 GitHub 訪問令牌
//(參考鏈接:https://help.github.com/articles/creating-a-personal-access-token-for-the-command-line/#creating-a-token)
// 為你的 GitHub 訪問令牌選擇以下權限:
//- repo:status(倉庫狀態權限)
//- public_repo(公共倉庫權限)
// 將以下代碼中的第 3 個參數替換為你的 GitHub 訪問令牌
    var key = GetEnvVariable( "GitHubKey", "你必須將你的 GitHub 密鑰存儲在名為 GitHubKey 的環境變量中。", "" );

    var client = new GitHubClient ( new Octokit . ProductHeaderValue ( "IssueQueryDemo" ) )
    {
        Credentials = new Octokit . Credentials ( key )
    };

    var progressReporter = new progressStatus ( ( num ) =>
    {
        Console . WriteLine ( $"總共獲得 {num} 個議題" );
    } );
    CancellationTokenSource cancellationSource = new CancellationTokenSource ( );

    try
    {
        var results = await RunPagedQueryAsync ( client , PagedIssueQuery , "docs" , cancellationSource . Token , progressReporter );
        foreach ( var issue in results )
            Console . WriteLine ( issue );
    }
    catch ( OperationCanceledException )
    {
        Console . WriteLine ( "作業被取消。");
    }

你可以將 GitHubKey 環境變量設置為你的個人訪問令牌,也可以將調用 GetEnvVariable 時的最後一個參數替換為你的個人訪問令牌。如果你要與他人共享源代碼,請勿將訪問代碼放入源代碼中。絕對不要將訪問代碼上傳到共享的源代碼倉庫。

創建 GitHub 客户端後,Main 中的代碼會創建一個進度報告對象和一個取消令牌。這些對象創建完成後,Main 會調用 RunPagedQueryAsync 來檢索最近創建的 250 個問題。該任務完成後,結果將被顯示出來。

運行啓動應用程序時,你可以對該應用程序的運行方式得出一些重要觀察結果。你會看到從 GitHub 返回的每個頁面的進度報告。你會注意到,在 GitHub 返回每個新的問題頁面之前,會有一個明顯的停頓。最後,只有在從 GitHub 檢索完所有 10 個頁面後,才會顯示這些問題。

檢查實現

這一實現揭示了為什麼你會觀察到上一節中討論的行為。查看 RunPagedQueryAsync 的代碼:

private static async Task<JArray> RunPagedQueryAsync ( GitHubClient client , string queryText , string repoName , CancellationToken cancel , IProgress<int> progress )
{
    var issueAndPRQuery = new GraphQLRequest
    {
        Query = queryText
    };
    issueAndPRQuery . Variables [ "repo_name" ] = repoName;

    JArray finalResults = new JArray ( );
    bool hasMorePages = true;
    int pagesReturned = 0;
    int issuesReturned = 0;

    // 最多獲取 10 批議題即停止,因為這些倉庫規模較大:
    while ( hasMorePages && ( pagesReturned++ < 10 ) )
    {
        var postBody = issueAndPRQuery . ToJsonText ( );
        var response = await client . Connection . Post <string> ( new Uri ( "https://api.github.com/graphql" ) , postBody , "application/json" , "application/json" );

        JObject results = JObject . Parse ( response . HttpResponse . Body . ToString ( )! );

        int totalCount = ( int ) issues ( results ) [ "totalCount" ]!;
        hasMorePages = ( bool ) pageInfo ( results ) [ "hasPreviousPage" ]!;
        issueAndPRQuery . Variables [ "start_cursor" ] = pageInfo ( results ) [ "startCursor" ]! . ToString ( );
        issuesReturned += issues ( results ) [ "nodes" ]! . Count ( );
        finalResults . Merge ( issues ( results ) [ "nodes" ]! );
        progress? . Report ( issuesReturned );
        cancel . ThrowIfCancellationRequested ( );
    }
    return finalResults;

    JObject issues ( JObject result ) => ( JObject ) result [ "data" ]! [ "repository" ]! [ "issues" ]!;
    JObject pageInfo ( JObject result ) => ( JObject ) issues ( result ) [ "pageInfo" ]!;
}

該方法首先要做的就是使用 GraphQLRequest 類創建 POST 對象:

public class GraphQLRequest
{
    [JsonProperty("query")]
    public string? Query { get; set; }

    [JsonProperty("variables")]
    public IDictionary<string , object> Variables { get; } = new Dictionary<string , object> ( );

    public string ToJsonText() =>
        JsonConvert . SerializeObject ( this );
}

這有助於形成 POST 對象主體,並通過 ToJsonText 方法將其正確轉換為呈現為單個字符串的 JSON,該方法會從請求主體中移除所有換行符,並使用 \(反斜槓)轉義字符對其進行標記。

讓我們專注於前面代碼的分頁算法和異步結構。RunPagedQueryAsync 方法按從最新到最舊的順序枚舉問題。它每頁請求 25 個問題,並檢查響應的 pageInfo 結構以繼續處理上一頁。這遵循了 GraphQL 對多頁響應的標準分頁支持。響應包含一個 pageInfo 對象,該對象包含一個 hasPreviousPages 值和一個用於請求上一頁的 startCursor 值。問題位於 nodes 數組中。RunPagedQueryAsync 方法將這些節點附加到一個包含所有頁面結果的數組中。

在檢索並恢復一頁結果後,RunPagedQueryAsync 會報告進度並檢查是否有取消請求。如果已請求取消,RunPagedQueryAsync 會拋出 OperationCanceledException。

這段代碼中有幾個可以改進的地方。最重要的是,RunPagedQueryAsync 必須為返回的所有問題分配存儲空間。本示例在 250 個問題處停止,因為檢索所有未解決的問題需要更多內存來存儲所有檢索到的問題。支持進度報告和取消操作的協議使得該算法在初次閲讀時難以理解,還涉及了更多的類型和 API。你必須通過 CancellationTokenSource 及其關聯的 CancellationToken 追蹤通信過程,才能理解取消請求的發出位置和批准位置。

異步流提供了一種更好的方式

異步流及其相關的語言支持解決了所有這些問題。生成序列的代碼現在可以在使用 async 修飾符聲明的方法中,使用 yield return 來返回元素。你可以使用 await foreach 循環來消費異步流,就像使用 foreach 循環消費任何序列一樣。

這些新的語言特性依賴於添加到 .NET Standard 2 . 1 並在 .NET Core 3 . 0 中實現的三個新接口:

  • System . Collections . Generic . IAsyncEnumerable < T >
  • System . Collections . Generic . IAsyncEnumerator < T >
  • System . IAsyncDisposable

這三個接口對大多數 C# 開發者來説應該很熟悉。它們的行為方式與其同步對應接口類似:

  • System . Collections . Generic . IEnumerable < T >
  • System . Collections . Generic . IEnumerator < T >
  • System . IDisposable

轉換為異步流

接下來,將 RunPagedQueryAsync 方法轉換為生成異步流。首先,將 RunPagedQueryAsync 的簽名更改為返回 IAsyncEnumerable < JToken >,並從參數列表中移除取消令牌和進度對象,如下列代碼所示:
private static async IAsyncEnumerable<JToken> RunPagedQueryAsync ( GitHubClient client , string queryText , string repoName )
啓動代碼會在檢索到每個頁面時對其進行處理,如下代碼所示:

finalResults . Merge ( issues ( results ) [ "nodes" ]! );
progress? . Report ( issuesReturned );
cancel . ThrowIfCancellationRequested ( );

用以下代碼替換那三行:

foreach ( JObject issue in issues ( results ) [ "nodes" ]! )
    yield return issue;

你也可以刪除此方法中前面的 finalResults 聲明,以及你修改過的循環後面的 return 語句。

你已經完成了生成異步流的修改。完成的方法應類似於以下代碼:

private static async IAsyncEnumerable<JToken> RunPagedQueryAsync ( GitHubClient client , string queryText , string repoName )
{
    var issueAndPRQuery = new GraphQLRequest
    {
        Query = queryText
    };
    issueAndPRQuery . Variables [ "repo_name" ] = repoName;

    bool hasMorePages = true;
    int pagesReturned = 0;
    int issuesReturned = 0;

    // Stop with 10 pages, because these are large repos:
    while ( hasMorePages && ( pagesReturned++ < 10 ) )
    {
        var postBody = issueAndPRQuery . ToJsonText ( );
        var response = await client . Connection . Post < string > ( new Uri ( "https://api.github.com/graphql" ) , postBody , "application/json" , "application/json" );

        JObject results = JObject . Parse ( response . HttpResponse . Body . ToString ( )! );

        int totalCount = ( int ) issues ( results ) [ "totalCount" ]!;
        hasMorePages = ( bool ) pageInfo ( results )[ "hasPreviousPage" ]!;
        issueAndPRQuery . Variables [ "start_cursor" ] = pageInfo ( results ) [ "startCursor" ]! . ToString ( );
        issuesReturned += issues ( results )[ "nodes" ]! . Count ( );

        foreach ( JObject issue in issues ( results )[ "nodes" ]! )
            yield return issue;
    }

    JObject issues ( JObject result ) => ( JObject ) result [ "data" ]! [ "repository" ]! [ "issues" ]!;
    JObject pageInfo ( JObject result ) => ( JObject ) issues ( result ) [ "pageInfo" ]!;
}

接下來,你要修改使用集合的代碼,使其使用異步流。在 Main 中找到以下處理問題集合的代碼:

var progressReporter = new progressStatus ( ( num ) =>
{
    Console . WriteLine ( $"Received {num} issues in total" );
} );
CancellationTokenSource cancellationSource = new ( );

try
{
    var results = await RunPagedQueryAsync ( client , PagedIssueQuery , "docs" , cancellationSource . Token , progressReporter );
    foreach ( var issue in results )
        Console . WriteLine ( issue );
}
catch ( OperationCanceledException )
{
    Console . WriteLine ( "Work has been cancelled" );
}

用以下 await foreach 循環替換該代碼:

int num = 0;
await foreach ( var issue in RunPagedQueryAsync ( client , PagedIssueQuery , "docs" ) )
{
    Console . WriteLine ( issue );
    Console . WriteLine ( $"Received {++num} issues in total" );
}

新接口 IAsyncEnumerator < T > 繼承自 IAsyncDisposable。這意味着上述循環在結束時會異步釋放流。你可以想象該循環類似於以下代碼:

int num = 0;
var enumerator = RunPagedQueryAsync ( client , PagedIssueQuery , "docs" ) . GetAsyncEnumerator ( );
try
{
    while ( await enumerator . MoveNextAsync ( ) )
    {
        var issue = enumerator . Current;
        Console . WriteLine ( issue );
        Console . WriteLine ( $"Received {++num} issues in total" );
    }
} finally
{
    if ( enumerator != null )
        await enumerator . DisposeAsync ( );
}

默認情況下,流元素在捕獲的上下文中進行處理。如果要禁用上下文捕獲,請使用 TaskAsyncEnumerableExtensions . ConfigureAwait 擴展方法。

異步流使用與其他 async 方法相同的協議來支持取消操作。你可以按如下方式修改異步迭代器方法的簽名以支持取消:

private static async IAsyncEnumerable<JToken> RunPagedQueryAsync ( GitHubClient client, string queryText, string repoName, [ EnumeratorCancellation ] CancellationToken cancellationToken = default )
{
    var issueAndPRQuery = new GraphQLRequest
    {
        Query = queryText
    };
    issueAndPRQuery.Variables["repo_name"] = repoName;

    bool hasMorePages = true;
    int pagesReturned = 0;
    int issuesReturned = 0;

    // Stop with 10 pages, because these are large repos:
    while ( hasMorePages && ( pagesReturned++ < 10 ) )
    {
        var postBody = issueAndPRQuery . ToJsonText ( );
        var response = await client . Connection . Post < string > ( new Uri ( "https://api.github.com/graphql" ) , postBody , "application/json" , application/json" );

        JObject results = JObject . Parse ( response . HttpResponse . Body . ToString ( )! );

        int totalCount = ( int ) issues ( results ) [ "totalCount" ]!;
        hasMorePages = ( bool ) pageInfo ( results ) [ "hasPreviousPage" ]!;
        issueAndPRQuery . Variables [ "start_cursor" ] = pageInfo ( results ) [ "startCursor" ]! . ToString ( );
        issuesReturned += issues ( results ) [ "nodes" ]! . Count ( );

        foreach ( JObject issue in issues ( results ) [ "nodes" ]! )
            yield return issue;
    }

    JObject issues ( JObject result ) => ( JObject ) result [ "data" ]! [ "repository" ]! [ "issues" ]!;
    JObject pageInfo ( JObject result ) => ( JObject ) issues ( result ) [ "pageInfo" ]!;
}

System . Runtime . CompilerServices . EnumeratorCancellationAttribute 特性會促使編譯器為 IAsyncEnumerator < T > 生成代碼,使傳遞給 GetAsyncEnumerator 的令牌作為該參數在異步迭代器的主體中可見。在 runQueryAsync 內部,你可以檢查令牌的狀態,並在收到請求時取消後續工作。

你可以使用另一個擴展方法 WithCancellation,將取消令牌傳遞給異步流。你可以按如下方式修改枚舉問題的循環:

await foreach (var number in AsyncStreamGenerator . GenerateNumbersAsync (
    count: 5, 
    delayMs: 1000 ) . WithCancellation ( cancellationToken ) ) // 關鍵:用擴展方法綁定取消令牌
    {
        Console . WriteLine ( $"收到數據:{number}" );
    }

你可以從 dotnet/docs 代碼庫的 asynchronous-programming/snippets 文件夾中獲取完成的教程代碼。

運行完成的應用程序

再次運行應用程序。將其行為與初始應用程序的行為進行對比。結果的第一頁一準備好就會被枚舉出來。在請求和檢索每個新頁面時,會有一個明顯的停頓,然後下一頁的結果會被快速枚舉。不需要使用 try……catch 塊來處理取消操作:調用方可以停止枚舉集合。進度會被清晰地報告,因為異步流會在每個頁面下載時生成結果。每個返回的問題的狀態會無縫地包含在 await foreach 循環中。你不需要回調對象來跟蹤進度。

通過檢查代碼,你可以發現內存使用方面的改進。在枚舉所有結果之前,你不再需要分配一個集合來存儲它們。調用者可以決定如何使用這些結果,以及是否需要一個存儲集合。

運行初始應用程序和完成後的應用程序,你可以親自觀察兩種實現之間的差異。完成本教程後,你可以刪除在開始時創建的 GitHub 訪問令牌。如果攻擊者獲取了該令牌,他們就可以使用你的憑據訪問 GitHub 的 API。

在本教程中,你使用了異步流從返回分頁數據的網絡 API 中讀取單個項目。異步流還可以從 “永不終止的流” (如股票行情或傳感器設備)中讀取數據。對 MoveNextAsync 的調用會在有下一個項目可用時立即返回該項目。

using System;
using System . Collections . Generic;
using System . Threading;
using System . Threading . Tasks;
// 注意:需要引用 System.Linq.Async 包(NuGet命令:Install-Package System.Linq.Async)
using System . Linq;

// 假設這是一個已有的異步流生成器(可能未直接支持取消令牌)
public static class AsyncStreamGenerator
    {
    // 生成器未顯式接收取消令牌(模擬第三方庫或遺留代碼)
    public static async IAsyncEnumerable<int> GenerateNumbersAsync ( int 數量 , int 延遲毫秒數 )
        {
        for ( int i = 1 ; i <= 數量 ; i++ )
            {
            await Task . Delay ( 延遲毫秒數 ); // 模擬異步操作(無取消檢查)
            yield return i;
            }
        }
    }

class AsyncStreamConsumer
    {
    public static async Task 帶取消功能的消費方法Async ( )
        {
        try
            {
            // 創建取消令牌源(例如:3秒後自動取消)
            using var 取消源 = new CancellationTokenSource(3000);
            var 取消令牌 = 取消源.Token;

            // 消費異步流時,通過 WithCancellation 傳遞取消令牌
            // 即使生成器本身不支持取消,枚舉過程(MoveNextAsync)會響應取消
            await foreach ( var 數字 in AsyncStreamGenerator . GenerateNumbersAsync (
                數量: 5 ,
                延遲毫秒數: 1000 )
                . WithCancellation ( 取消令牌 ) ) // 關鍵:用擴展方法綁定取消令牌
                {
                Console . WriteLine ( $"收到數據:{數字}" );
                }

            Console . WriteLine ( "異步流消費完成!" );
            }
        catch ( OperationCanceledException )
            {
            Console . WriteLine ( "異步流被取消(消費端觸發)!" );
            }
        catch ( Exception ex )
            {
            Console . WriteLine ( $"錯誤:{ex . Message}" );
            }
        }

    static async Task Main ( )
        {
        await 帶取消功能的消費方法Async ( );
        }
    }
user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.