C# Task async/await CancellationToken

C# Task / async/await / CancellationToken

一、Task

1.Task 理解

Task类似于我们去点餐,餐厅给你一张小票,这个小票就是Task。他表示现在还没有饭,以后会有,你可以去逛街,等饭做好,可以凭借小票(Task)取餐

而在C#

Task=小票

做饭=一个异步操作(可能是读文件、请求网站、查数据库)

逛街=不阻塞当前运行程序,代码可以干其他事。

需要注意的的是Task 不是线程。这是一个最核心也最容易误解的概念。Task 表示的是一项尚未完成的工作一个未来的结果,它更像一个异步操作的句柄,而不是线程本身

cs 复制代码
Task<int> task = GetUserCountAsync();

这里的 task 表示用户数量这个结果以后会出来,但完全不等于已经为它开了一个新线程。

Task 的本质有三层含义

  1. 异步操作的统一抽象: 无论底层是线程池执行计算、操作系统完成异步 I/O、定时器触发,还是回调被包装,最后都可以统一表现为一个Task 或 Task。

  2. 带有状态: 一个 Task 会经历等待调度、运行中、成功完成、失败或被取消等状态,它还负责承载完成信号、异常、取消状态和 continuation。

  3. 可组合: Task 可以被组合使用,这是相比传统回调最重要的优势之一。

2. Task 的生命周期

Created 任务已创建,但尚未启动

Running 任务正在执行

RanToCompletion 任务成功完成,没有异常或取消

Faulted 任务因未处理的异常而失败

Canceled 任务被取消

视觉开发重点关心:Faulted和 Canceled

3. 常用 Task 组合器

方法 作用 视觉案例
Task.WhenAll 并行执行多个任务 同时处理多台相机的采集
Task.WhenAny 谁先完成就用谁的结果 多个算法模型并行推理,取最快结果
Task.Delay 非阻塞等待 定时轮询设备状态,不影响 UI

二、async/await 写同步代码一样写异步逻辑

很多人认为 async/await 是某种运行时黑魔法------一个函数加上 async 就能自动变成非阻塞,加上 await 就能让线程"休息"而不会阻塞。实际上,async/await 完全是由编译器在编译期完成的状态机重写。运行时并不知道 async 关键字的存在,它看到的是经过转换的代码。

换句话说,async/await 是 C# 编译器提供的高级语法糖,它自动帮你将异步逻辑切分成多个片段,并在每个 await 点保存/恢复状态。

1. await 的"等待"到底等什么

await 的本质:不阻塞,只是"挂起并返回"

很多人误以为 await task; 会让当前线程阻塞等待 task 完成,这种理解是完全错误的。

await 的行为是:

  1. 检查 task 是否已经完成(IsCompleted == true)如果是,同步继续,不需要任何额外调度。如果否,进入第 2 步。
  2. 挂起当前方法:保存方法的状态(局部变量、执行位置等),并立即返回一个未完成的 Task 给调用者。
  3. 注册一个回调:当 task 完成时,该回调会恢复当前方法的执行(在某个线程上,通常是完成 task 的线程或捕获的同步上下文)。

因此,await 不会阻塞任何线程。这正是异步编程能提高吞吐量的核心:线程可以被释放去做其他工作,而不是白白等待 I/O。

对比阻塞等待

方式 线程行为 适用场景
task.Wait() 或 task.Result 当前线程阻塞,直到 task 完成 控制台 Main 方法(有限场景)
await task 当前方法挂起,线程返回调用者,不阻塞 几乎所有异步场景

如果在 UI 线程(WPF/WinForms)或 ASP.NET Core 请求线程上使用 .Result,轻则降低响应性,重则导致死锁。

何时真的需要阻塞

极少数情况,比如控制台应用的 Main 方法(C# 7.1 之前不支持 async Main),或者某些无法改造为异步的遗留代码。即便如此,更好的做法是使用 await 并让调用链一直异步到入口点。

2. async 关键字的作用

async 关键字本身不创建异步,它只做两件事

  1. 允许在方法内使用 await(没有 async 就不能用 await)。
  2. 强制编译器将该方法转换为状态机,并将返回值包装为 Task/Task。

所以 async 更像是一个"标记",告诉编译器:这个方法体内有异步操作,请帮我生成状态机代码。

避免"异步 void"

public async void Start() → 异常无法捕获,调用方无法等待

public async Task Start(),UI 事件处理程序可以 _ = Start() 或改用 async void 但内部 try-catch

3. 异步方法的返回类型

返回类型 适用场景 说明
Task 没有返回值的异步操作 类似 void,但可被 await 和捕获异常
Task 有返回值的异步操作 返回 T 类型的结果
void 仅限 UI 事件处理程序 异步无返回值,但调用方无法等待、无法捕获异常(危险)
ValueTask / ValueTask 高频调用、多数情况同步完成的场景 减少堆内存分配,但使用限制较多

小Tips:大多数普通应用不需要 ValueTask,使用 Task 更安全。

4. 异常处理

  1. 在 async 方法内部抛出异常
cs 复制代码
public async Task<int> DivideAsync(int a, int b){
    if (b == 0) throw new DivideByZeroException();
    return await Task.FromResult(a / b);
}

调用者用 try/catch 包裹 await 即可捕获异常

cs 复制代码
try{
    int result = await DivideAsync(10, 0);
}catch (DivideByZeroException ex){
    // 捕获成功
}

关键点: 异常在 await 处传播,而不是在调用 DivideAsync 时。因为 DivideAsync 返回的 Task 进入 Faulted 状态,await 检测到后会重新抛出异常。

5. async/await 与同步上下文的交互

SynchronizationContext 决定 await 后代码跑在哪个线程

UI 线程(WPF/WinForms):await 后自动回到 UI 线程 → 可直接更新控件

类库 / 后台服务:建议使用 ConfigureAwait(false) 避免不必要的上下文切换

cs 复制代码
var data = await File.ReadAllTextAsync(path).ConfigureAwait(false);

6. await使用场景

场景 说明 典型API
网络请求 从远程 API 获取数据,如 REST 调用、下载文件。 HttpClient.GetStringAsync / PostAsync
文件 I/O 读写大文件,避免阻塞 UI 或线程池。 File.ReadAllTextAsync / WriteAsync
数据库操作 查询、插入、更新数据(尤其是 ORM 异步方法)。 SqlCommand.ExecuteReaderAsync DbContext.SaveChangesAsync
延迟或定时 模拟等待或实现超时。 Task.Delay
异步流处理 使用 IAsyncEnumerable 逐条消费数据。 await foreach
并行任务组合 同时等待多个异步操作完成。 Task.WhenAll / WhenAny
跨服务调用 微服务间 HTTP/gRPC 调用。 GrpcClient.XXXAsync
消息队列消费 异步接收和处理消息(如 RabbitMQ、Kafka 的异步客户端)。 BasicConsumeAsync

三、CancellationToken 取消异步任务

CancellationToken 其实就是一张"可以随时喊停"的凭证。就好比我们的小票,在饭还没开始做的时候可以喊停。(任务在合适的地方取消请求并退出)

CancellationToken 的基本用法

角色 组件 作用
遥控器 CancellationTokenSource 产生令牌,并控制取消
信号线 CancellationToken 传递给异步方法,供其监听
检测开关 ThrowIfCancellationRequested() 检测到取消时抛出 OperationCanceledException
检测开关 IsCancellationRequested 非侵入式检查,可自己退出循环

1. 创建一个CancellationToken 取消程序

cs 复制代码
//创建"遥控器"和"信号线"
// 1. 创建一个遥控器
CancellationTokenSource cts = new CancellationTokenSource();
// 2. 把信号线交给任务
CancellationToken token = cts.Token;

//写一个可以取消的任务
Task.Run(() =>{
    for (int i = 0; i < 100; i++){
        // 每次循环都看一眼:有人按取消了吗?
        if (token.IsCancellationRequested){
            Console.WriteLine("收到取消信号,退出!");
            return; 
        }
        // 否则继续干活
        Thread.Sleep(100);
    }
}, token);  // 把 token 传给 Task.Run,让它可以响应取消

// 用户点了取消按钮
cts.Cancel();

另一种更"暴力"的写法

cs 复制代码
Task.Run(() =>{
    for (int i = 0; i < 100; i++){
        // 如果取消了,直接抛异常(会被 Task 捕获,任务状态变成 Canceled)
        token.ThrowIfCancellationRequested();
        // 干活...
    }
}, token);

两者区别:

IsCancellationRequested 是你自己判断、自己退出(可以做一些清理工作再 return)。

ThrowIfCancellationRequested 是直接抛异常,让上层 catch 处理,适合不想写一堆 if 的情况。

2. 带超时的取消

cs 复制代码
// 方法1:使用 CancelAfter
CancellationTokenSource cts = new CancellationTokenSource();
cts.CancelAfter(200);  // 200ms 后自动取消

// 方法2:直接传超时时间
await someTask.WaitAsync(TimeSpan.FromMilliseconds(200));

3. 多个取消信号合并

需要同时响应"用户取消"和"超时自动取消"。可以用 CreateLinkedTokenSource 把两个信号合并。

cs 复制代码
var userCts = new CancellationTokenSource();      // 用户手动取消
var timeoutCts = new CancellationTokenSource(5000); // 5秒超时

// 把两个遥控器的信号合并成一个
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
    userCts.Token, timeoutCts.Token);
var token = linkedCts.Token;

// 把这个 token 传给任务,无论是用户点取消还是超时,任务都会收到取消信号
await LongRunningTaskAsync(token);

4. 总结

我想做什么 写什么代码
创建遥控器 var cts = new CancellationTokenSource();
取出信号线 var token = cts.Token;
把信号线交给任务 Task.Run(action, token) 或 await xxxAsync(token)
任务内部检查 if (token.IsCancellationRequested) break;
任务内部抛异常 token.ThrowIfCancellationRequested();
触发取消 c ts.Cancel();
自动取消(超时) cts.CancelAfter(1000);
用完释放 cts.Dispose(); 或 using var cts = new ...
合并多个信号 CancellationTokenSource.CreateLinkedTokenSource(t1, t2)

练习样例

控制台程序:异步下载 10 张图片,支持中途取消

cs 复制代码
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;

//控制台程序:异步下载 10 张图片,支持中途取消
namespace Console_program_Task_async_await{
    class Program{

        // 图片URL列表Lorem Picsum 提供的随机图片
        private static readonly List<string> ImageUrls = new List<string>{
            "https://picsum.photos/id/1/200/300",
            "https://picsum.photos/id/2/200/300",
            "https://picsum.photos/id/3/200/300",
            "https://picsum.photos/id/4/200/300",
            "https://picsum.photos/id/5/200/300",
            "https://picsum.photos/id/6/200/300",
            "https://picsum.photos/id/7/200/300",
            "https://picsum.photos/id/8/200/300",
            "https://picsum.photos/id/9/200/300",
            "https://picsum.photos/id/10/200/300"
        };
        // 复用 HttpClient
        private static readonly HttpClient httpClient = new HttpClient();

        static async Task Main(string[] args)
        {

            Console.WriteLine("开始下载10张图片...");
            Console.WriteLine("提示:按 C 键或按 Ctrl+C 可中途取消下载。\n");

            // 创建 CancellationTokenSource 用于取消操作
            using (var cts = new CancellationTokenSource())
            {
                // 注册 Ctrl + C 事件,实现取消
                Console.CancelKeyPress += (sender, e) =>
                {
                    Console.WriteLine("\n检测到 Ctrl+C,正在取消下载...");
                    e.Cancel = true;          // 阻止进程立即终止
                    cts.Cancel();             // 触发取消令牌
                };
                // 创建一个任务来监听键盘按键(按 C 键取消)
                var keyListenerTask = Task.Run(() =>{
                    while (!cts.Token.IsCancellationRequested){
                        if (Console.KeyAvailable){
                            if (Console.KeyAvailable && char.ToUpper(Console.ReadKey(true).KeyChar) == 'C'){
                                Console.WriteLine("\n用户按下了 C 键,正在取消下载...");
                                cts.Cancel();
                                break;
                            }
                            Thread.Sleep(100);
                        }
                    }
                });
                // 创建下载目录
                string downloadPath = Path.Combine(Directory.GetCurrentDirectory(), "DownloadedImages");
                Directory.CreateDirectory(downloadPath);

                try{
                    //开始下载所有图片
                    await DownloadAllImagesAsync(ImageUrls, downloadPath, cts.Token);
                    Console.WriteLine("\n所有图片下载完成!");
                }catch (OperationCanceledException){
                    Console.WriteLine("\n下载已被用户取消。");
                } catch (Exception ex){
                    Console.WriteLine($"\n下载过程中发生错误: {ex.Message}");
                }finally{
                    // 确保键盘监听任务结束
                    cts.Cancel();
                    await keyListenerTask;
                }
                // cts.Dispose() 自动调用
            }
            Console.WriteLine("按任意键退出...");
            Console.ReadKey();
        }
        private static async Task DownloadAllImagesAsync(List<string> urls, string downloadPath, CancellationToken cancellationToken){
            var downloadTasks = new List<Task>();
            int index = 1;
            foreach (string url in urls){
                string filePath = Path.Combine(downloadPath, $"image_{index++}.jpg");
                downloadTasks.Add(DownloadImageAsync(url, filePath, index, cancellationToken));
            }
            await Task.WhenAll(downloadTasks);
        }
        // 下载单张图片并保存到本地
        private static async Task DownloadImageAsync(string url, string filePath, int imageIndex, CancellationToken cancellationToken){
            try{
                Console.WriteLine($"[任务 {imageIndex}] 开始下载:{url}");

                using (HttpResponseMessage response = await httpClient.GetAsync(url, cancellationToken)){
                    response.EnsureSuccessStatusCode(); // 确保请求成功
                    byte[] imageBytes = await response.Content.ReadAsByteArrayAsync();

                    // 异步写入文件(支持取消)
                    using (var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, useAsync: true))
                    {
                        await fileStream.WriteAsync(imageBytes, 0, imageBytes.Length, cancellationToken);
                    }
                }

                Console.WriteLine($"[任务 {imageIndex}] 下载完成并保存至:{filePath}");
            }catch (OperationCanceledException){
                Console.WriteLine($"[任务 {imageIndex}] 下载已取消。");
                throw;
            }catch (Exception ex){
                Console.WriteLine($"[任务 {imageIndex}] 下载失败:{ex.Message}");
                throw;
            }
        }
    }
}

本文参考:
C# 官方文档https://learn.microsoft.com/en-us/dotnet/standard/parallel-programming/how-to-create-pre-computed-tasks
博客园 https://www.cnblogs.com/yilezhu/p/10555849.html
Deepseek https://chat.deepseek.com/

相关推荐
zhangrelay5 小时前
蓝桥云课五分钟-通关自动控制-octave
笔记·学习
lhbian5 小时前
AI编程革命:Codex让脚本开发提速10倍
开发语言·汇编·jvm·c#
_李小白6 小时前
【AI大模型学习笔记之平台篇】第六篇:安卓开发AI工具介绍(Android CLI、Android Skill和Android Knowledge Base)
人工智能·笔记·学习
YaBingSec6 小时前
玄机靶场:供应链安全-供应链应急-Part2 通关笔记
java·笔记·安全
qeen877 小时前
【算法笔记】双指针及其经典例题解析
c++·笔记·算法·双指针
LF男男7 小时前
TouchManager
unity·c#
China_Yanhy8 小时前
生产笔记:AI 集群的极致成本与数据保命指南
人工智能·笔记
xiaoshuaishuai88 小时前
C# Submodule 避坑指南
服务器·数据库·windows·c#
云起SAAS8 小时前
小智笔记APP源码 | 8大广告联盟聚合(穿山甲/优量汇/快手/百度) | 应用市场过审极速版 | uni-app全栈商用项目
笔记·uni-app·广告联盟·笔记app