C#异步编程入门概念及核心概念

常见概念

已经有多线程了,为什么还要异步

多线程与异步是不同的概念,异步并不意味着多线程,单线程同样可以异步,异步默认借助线程池,多线程经常阻塞,而异步要求不阻塞

多线程与异步的适用场景不同

多线程适合-适合CPU密集型操作,适合长期运行的任务,线程的创建与销毁开销较大,提供更底层的控制,操作线程,锁.,信号量等,线程不易于传参及返回,线程的代码书写较为繁琐

异步适合-适合IO密集型操作,适合短暂的小任务,避免线程阻塞,提高系统响应能力

什么是异步任务(Task)

Task是包含了异步任务的各种状态的一个引用类型,比如是否正在运行 完成 结果 报错等,另有ValueTask值类型版本

对于异步任务的抽象

开始异步任务后,当前线程并不会阻塞,而是看去做其他的事情

异步任务(默认)会结组线程池在其他线程上运行

获取结果后回到之前的状态

任务的结果

返回值为Task的方法表示异步任务没有返回值

返回值为Task<T>则表示有类型为T的返回值

异步方法(async Task)

将方法标记async后,可以在方法中使用await关键字

await关键字会等待异步任务的结束,并且获得结果

async+await会将方法包装成状态机,await类似于检查点-MoveNext方法会被底层调用,从而切换状态

async Task

返回值一定是Task类型,但是在其中可以使用await关键字

在其中写返回值可以直接写Task<T>中的T类型,不用包装成Task<T>

async Task的核心含义

async Task是 C# 中定义无返回值异步方法标准方式,两个关键字 / 类型各司其职:

部分 核心作用
async 标记方法为 "异步方法",告诉编译器将方法编译为状态机 (支持await暂停 / 恢复执行),仅作为 "标记",不直接执行异步逻辑
Task 表示一个 "无返回值的异步操作",是可等待对象(awaitable):调用方通过await等待该异步操作完成,且能捕获方法内的异常

通俗比喻:

  • async Task<T>就像 "点一份带小票的外卖":你(调用方)下单后拿到小票(Task<T>),不用等(非阻塞),可以做其他事;外卖做好后,凭小票(await)取到具体的外卖(T类型返回值)。
  • 对比async Task(无返回值):相当于 "点一份无需小票的外卖",只需要知道 "送没送到",不需要拿到具体东西。
  • async = 给方法贴个 "可暂停" 标签,告诉编译器 "这个方法里有需要等待的操作,别一次性执行完";
  • Task = 给调用方的 "任务凭证",调用方拿着这个凭证可以 "等任务做完",但任务本身没有具体结果要返回(比如 "异步写日志" 只需要知道写没写完,不需要返回值)。
async Task<T>

async Task<T>是定义有返回值异步方法标准方式,是日常开发中最常用的异步方法类型

部分 核心作用
async 标记方法为异步方法,编译器将其编译为状态机,支持await暂停 / 恢复执行;
Task<T> 表示 "有返回值的异步操作",T是返回值的类型(如Task<string>返回字符串、Task<int>返回整数);调用方通过await等待操作完成,并获取T类型的返回值。

定义规则 :① 方法必须用async修饰,返回类型为Task<T>T是实际返回值类型);② 方法内部必须有await(否则是 "伪异步",编译器警告);③ 方法内部用return返回T类型的值(而非Task<T>,编译器会自动包装为Task<T>);④ 命名规范:后缀加Async(如GetDataAsync)。

调用规则 :① 必须用await调用,才能获取T类型的返回值;② 异常可通过try-catch捕获(异常会封装到Task<T>中,await时抛出)。

基础用法:

cs 复制代码
using System;
using System.Threading.Tasks;

class AsyncTaskTDemo
{
    // 主线程:async Task Main是入口方法
    static async Task Main()
    {
        Console.WriteLine("开始异步获取用户信息...");
        
        // 调用async Task<T>方法,await获取返回值
        UserInfo user = await GetUserInfoAsync(1001);
        
        Console.WriteLine($"获取到用户信息:ID={user.Id},Name={user.Name}");
    }

    // 定义async Task<T>方法:返回UserInfo类型的异步方法
    static async Task<UserInfo> GetUserInfoAsync(int userId)
    {
        Console.WriteLine("模拟数据库查询(异步IO操作)...");
        // 模拟异步IO操作(数据库查询/网络请求)
        await Task.Delay(1500); // 替代实际的异步查询
        
        // 直接返回T类型(UserInfo),编译器自动包装为Task<UserInfo>
        return new UserInfo
        {
            Id = userId,
            Name = "张三",
            Age = 25
        };
    }

    // 自定义返回类型
    class UserInfo
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public int Age { get; set; }
    }
}

执行结果:

cs 复制代码
开始异步获取用户信息...
模拟数据库查询(异步IO操作)...
(主线程不阻塞,1.5秒后)
获取到用户信息:ID=1001,Name=张三
async void

async void特殊的无返回值异步方法 ,仅用于事件处理程序(如按钮点击、定时器触发),是异步方法中 "最危险" 的类型:

核心规则(必须严格遵守)

  • 仅用于事件处理程序 :如 WinForm/WPF 的按钮点击、ASP.NET Core 的事件回调,普通方法绝对不能用
  • 无法 await :调用async void方法后,调用方无法等待其完成,也无法获取执行状态;
  • 异常处理特殊 :方法内的异常不会封装到Task中,会直接抛到当前同步上下文(如 UI 线程 / 线程池),导致程序崩溃(无法通过try-catch捕获);
  • 生命周期不可控:调用方无法知道方法何时执行完成,可能引发资源未释放、数据不一致等问题。
部分 核心作用
async 标记方法为异步方法,支持内部await
void 无返回值,且不可被 await(调用方无法等待其完成);异常会直接抛到线程池,导致程序崩溃。

定义规则 :① 方法必须用async修饰,返回类型为Task<T>T是实际返回值类型);② 方法内部必须有await(否则是 "伪异步",编译器警告);③ 方法内部用return返回T类型的值(而非Task<T>,编译器会自动包装为Task<T>);④ 命名规范:后缀加Async(如GetDataAsync)。

调用规则 :① 必须用await调用,才能获取T类型的返回值;② 异常可通过try-catch捕获(异常会封装到Task<T>中,await时抛出)。

示例:

cs 复制代码
using System;
using System.Threading.Tasks;

class AsyncTaskTDemo
{
    // 主线程:async Task Main是入口方法
    static async Task Main()
    {
        Console.WriteLine("开始异步获取用户信息...");
        
        // 调用async Task<T>方法,await获取返回值
        UserInfo user = await GetUserInfoAsync(1001);
        
        Console.WriteLine($"获取到用户信息:ID={user.Id},Name={user.Name}");
    }

    // 定义async Task<T>方法:返回UserInfo类型的异步方法
    static async Task<UserInfo> GetUserInfoAsync(int userId)
    {
        Console.WriteLine("模拟数据库查询(异步IO操作)...");
        // 模拟异步IO操作(数据库查询/网络请求)
        await Task.Delay(1500); // 替代实际的异步查询
        
        // 直接返回T类型(UserInfo),编译器自动包装为Task<UserInfo>
        return new UserInfo
        {
            Id = userId,
            Name = "张三",
            Age = 25
        };
    }

    // 自定义返回类型
    class UserInfo
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public int Age { get; set; }
    }
}

关键:async void事件处理程序中,必须内部捕获所有异常,否则一旦抛出未处理异常,程序会直接崩溃。

async Task vs async Task<T> vs async void(核心对比)

新手最容易混淆这三种异步方法类型,用表格清晰区分:

方法类型 返回值 核心特点 适用场景 异常处理方式
async Task 无(仅 Task) 可 await、可捕获异常、无返回值 无返回值的异步操作(写日志、发邮件) await 时捕获异常
async Task<T> 有(T 类型) 可 await、可捕获异常、有返回值 有返回值的异步操作(查数据库、读文件) await 时捕获异常,同时获取返回值
async void 无(void) 不可 await、异常直接崩溃程序 仅事件处理程序(如按钮点击事件) 无法通过常规方式捕获,极易崩溃

异步编程具有传染性

一处async,处处async,几乎所有的自带方法都提供了异步的版本

重要思想:不阻塞

await会暂时释放当前线程,使得该线程可以执行其他工作,而不必阻塞线程直到异步操作完成

不要在异步方法里用任何方式阻塞当前线程

常见的阻塞情形

Task.Wait()&Task.Result:如果任务没有完成,则会阻塞当前线程,燃油导致死锁--Task.GetAwaiter().GerTesult()

Task.Delay() vs Thread.Sleep() : 后者会阻塞当前的线程,这与异步编程的理念不符合,前者是一个异步任务,会立即释放当前的线程

IO等操作的同步方法

其他繁重且耗时的任务

同步上下文

同步上下文是一种管理和协调线程的机制,允许开发者将代码的执行切换到特定的线程.

WinForms与WPF拥有同步上下文(UI线程),而控制台程序默认没有

ConfigureAwait(false) : 配置任务通过await方法结束后是否会回到原来的线程,默认为true,一般只有UI线程会采取这种策略

TaskScheduler

你可以把TaskScheduler理解为任务调度员

  • .NET 中所有Task的执行都由TaskScheduler接管(除非用Task.RunSynchronously强制同步执行)。
  • 它的核心职责是:接收待执行的 Task,根据自身规则分配线程(比如线程池线程、UI 线程、自定义线程),并管理 Task 的执行顺序。
  • 不同的TaskScheduler实现对应不同的调度规则,比如 UI 程序中要把更新 UI 的任务调度到主线程,后台计算任务调度到线程池。

代码示例:

cs 复制代码
// 1. 引入基础系统类库,包含Console(控制台输出)、Environment(获取线程信息)等核心类
using System;
// 2. 引入异步编程相关类库,包含Task、TaskScheduler等核心类型
using System.Threading.Tasks;

// 3. 定义程序的主类(C#程序的代码必须放在类中)
class Program
{
    // 4. Main方法是C#控制台程序的入口点,这段代码的执行从这里开始,Main方法运行在**主线程**中
    static void Main()
    {
        // 5. 创建并立即启动一个异步Task
        // Task.Run() 是创建后台任务的快捷方式,默认使用ThreadPoolTaskScheduler
        // 括号内的Lambda表达式 (() => { ... }) 是Task要执行的具体逻辑
        Task task1 = Task.Run(() => 
        {
            // 6. Task的执行逻辑:输出当前执行线程的ID,并标注"非主线程"
            // Environment.CurrentManagedThreadId:获取当前运行代码的线程唯一ID(整数)
            Console.WriteLine($"Task1 线程ID: {Environment.CurrentManagedThreadId}(非主线程)");
        });
        
        // 7. 主线程阻塞等待task1执行完成(同步等待)
        // 如果没有这行,主线程可能会直接退出,导致task1还没执行就结束程序,看不到Task1的输出
        task1.Wait();
        
        // 8. 主线程输出自己的线程ID,和Task1的线程ID做对比
        Console.WriteLine($"主线程ID: {Environment.CurrentManagedThreadId}");
    }
}

控制Task的调度方式和运行线程---

线程池线程 Default

当前线程 CurrentThread

单线程上下文STAThread

长时间运行线程 LongRunning

优先级、上下文、执行状态等

一发即忘(Fire-and-forget)

调用一个异步方法,但是并不使用await或阻塞的方式去等待他的结束

无法观察任务的状态

简单任务

如何创建异步任务
Task.Run()

你可以把Task.Run()理解为:给.NET 线程池 "派一个活" ------ 它不会创建全新的线程,而是复用线程池中的空闲工作线程来执行你的代码,避免了频繁创建 / 销毁线程的性能开销,同时让代码脱离调用线程(比如主线程)异步执行,不阻塞调用方。

cs 复制代码
using WinFormsApp1;

class Program
{
    static async Task Main()
    {
        Helper.PrintThreadId("之前");
        var res = await Task.Run(HeavyJob);
        Helper.PrintThreadId("完成");
        Console.WriteLine(res);
    }

    static async Task<int> HeavyJob()
    {
        Helper.PrintThreadId("使用");
        Thread.Sleep(1000);
        return 54;
    }
}
Task.Factory.StartNew()

Task.Factory.StartNew()是.NET 中创建并启动Task底层 API ,核心功能是:接收要执行的代码逻辑,通过配置参数自定义任务的创建规则、取消策略、调度方式,然后立即启动任务(无需手动调用Start())。相当于Task.Run的进化版

如何开启多个异步任务
1.并行开启多个异步任务(Task.WhenAll)
cs 复制代码
var inputs = Enumerable.Range(1, 10).ToArray();

var tasks=new List<Task<int>>();
foreach (var input in inputs)
{
    tasks.Add(HeavyJob(input));
}
await Task.WhenAll(tasks);
var outputs = tasks.Select(s=>s.Result).ToArray();
Console.WriteLine($"outputs数组内容:{string.Join(", ", outputs)}");
async Task<int> HeavyJob(int input)
{
    await Task.Delay(10);
    return input * input;
}
2.按序开启多个异步任务(foreach + await)

如果任务之间有依赖(如任务 B 需要任务 A 的结果),或需要逐个执行,用这种方式(总耗时 = 所有任务耗时之和):

cs 复制代码
static async Task Main()
{
    var inputs = Enumerable.Range(1, 10).ToArray();
    var outputs = new List<int>();

    // 逐个开启任务,await等待当前任务完成后再开下一个
    foreach (var input in inputs)
    {
        int result = await HeavyJob(input); // 按序执行,每10ms完成一个
        outputs.Add(result);
        Console.WriteLine($"按序执行:输入{input} → 输出{result}");
    }

    Console.WriteLine($"按序执行总结果:{string.Join(", ", outputs)}");
}
3.进阶:带并发限制的多异步任务(避免资源耗尽)

如果开启成百上千个异步任务(如批量调用接口),直接用Task.WhenAll会导致线程池 / 网络连接耗尽,需限制同时运行的任务数(用SemaphoreSlim):

cs 复制代码
static async Task Main()
{
    var inputs = Enumerable.Range(1, 20).ToArray();
    var outputs = new List<int>();
    
    // 限制同时最多运行3个异步任务
    var semaphore = new SemaphoreSlim(3);

    // 生成所有任务,但执行时受信号量限制
    var tasks = inputs.Select(async input =>
    {
        await semaphore.WaitAsync(); // 申请信号量,满了则等待
        try
        {
            return await HeavyJob(input); // 执行任务
        }
        finally
        {
            semaphore.Release(); // 释放信号量,让下一个任务执行
        }
    }).ToArray();

    await Task.WhenAll(tasks);
    outputs = tasks.Select(t => t.Result).ToList();
    
    Console.WriteLine($"带限制的并行结果:{string.Join(", ", outputs)}");
}

常见误区

异步一定是多线程?

异步编程不必需要多线程来实现,可以用时间片轮转调度来实现

比如可以在单个线程上使用异步I/O或事件驱动的编程模型(EAp)

单线程异步:自动定好计时器,到时间之前先去做别的事情

多线程异步:将任务交给不同的线程,并由自己来进行指挥调度

异步方法一定要写成async Task?

async关键字知识用来配合await使用,从而将方法包装成状态机,本质上依然是Task,只不过提供了语法糖,并且函数体中可以直接return Task的泛型类型,接口中无法声明async Task

await一定会切换同步上下文?

在使用await关键字调用并等待一个异步任务时,异步方法不一定会立刻来到新的线程上,如果await了一个已经完成的任务,会直接获得结果

异步可以全面取代多线程?

异步编程与多线程有一定光系,但二者并不是可以完全互相代替

Task.Result一定会阻塞当前线程?

如果任务已经完成,那么可以直接得到结果

开启的异步任务一定不会阻塞当前线程?

await关键字不一定会立即释放当前线程,所以如果调用的异步方法中存在阻塞(如Thread.Sleep(0)),那么依旧会阻塞上下文对应的进程

异步编程中的同步机制
传统方法

Monitor(lock)、Mutex、Semaphore、EventWaitHandle

轻量型

SemaphoreSlim、ManualResetEventSlim

并发集合
第三方库
相关推荐
a程序小傲2 小时前
得物Java面试被问:流批一体架构的实现和状态管理
java·开发语言·数据库·redis·缓存·面试·架构
黎雁·泠崖2 小时前
Java继承:成员变量访问(就近原则+this/super用法)
java·开发语言
ShineWinsu2 小时前
对于C++:模版初阶的解析
开发语言·c++·面试·笔试·函数··模版
Max_uuc2 小时前
【C++ 硬核】告别 Excel 生成数组:利用 constexpr 实现编译期计算查找表 (LUT)
开发语言·c++·excel
墨雨晨曦882 小时前
leedcode刷题总结
java·开发语言
嫂子开门我是_我哥2 小时前
第十六节:异常处理:让程序在报错中稳定运行
开发语言·python
a努力。2 小时前
中国邮政Java面试被问:MySQL的ICP(索引条件下推)优化原理
java·开发语言·数据仓库·面试·职场和发展·重构·maven
青槿吖2 小时前
【趣味图解】线程同步与通讯:从抢奶茶看透synchronized、ReentrantLock和wait/notify
java·开发语言·jvm·算法
2401_838472513 小时前
C++20概念(Concepts)入门指南
开发语言·c++·算法