c#: 异步代码是如何解决高并发问题的?async/await、Task、IOCP/epoll_c#高并发三种解决方法-CSDN博客

C#编程高并发的几种处理方法 - zock - 博客园 (cnblogs.com)

关于异步与多线程,笔者在刚接触的时候一直存在诸多疑惑,甚至一度以为这俩概念是用来描述同一种技术在不同场景下的应用,进而导致对很多与它们相关的概念都一知半解,代码中的async/await关键词也是莫名其妙地在用。

但是在不断地接触这类概念(因为现在的场景中异步与多线程几乎无处不在)的过程中,还是不断地修正了这种思维。代码写起来也顺手多了。

所以这篇文章也是有感而发,在去年的时间里因为多线程和异步踩了不少雷,希望能够给大家做一点简单的解释和区分把。

TL, DR: 请参照文章最后的例子 :)

多线程是什么
多线程 技术有时又称 并行 技术,就是同时做多件事情。这十分好理解,也很直观。

现在的CPU都不止有一个核,每个核都至少具备一个线程,某些CPU具备超线程能力,一个核可以具备多个线程:打开Windows自带的任务管理器,切到性能一栏,选中CPU,线程总数显示在“逻辑处理器”部分。可以看到,笔者这颗 i9-14900HX具备32个线程。

###########################################################################

32 逻辑处理器调度 6990 线程的完整机制解析

我用最通俗、工程化的方式,把CPU 核心、线程、进程的调度原理,结合你当前的系统状态(32 逻辑核、312 进程、6990 线程)讲透,同时解释为什么 32 核能跑数千线程。


一、核心概念先理清(避免混淆)

表格

术语 定义 你当前系统的状态
逻辑处理器(Logical Processor) CPU 可调度的最小硬件执行单元,对应超线程后的线程数 32 个(i9-14900HX:8P 核 + 16E 核,P 核超线程→16 线程,E 核无超线程→16 线程,合计 32)
线程(Thread) 操作系统调度的最小执行单元,是代码执行的载体 6990 个(所有进程的线程总和)
进程(Process) 资源分配的最小单位,包含 1 个或多个线程,共享进程内存空间 312 个(系统 + 应用的独立运行程序)

二、核心机制:32 核如何跑数千线程?

1. 本质:时间片轮转调度(Time-Slicing)

这是操作系统调度的核心原理,也是 32 核能跑数千线程的根本原因:

  • 硬件限制:同一时刻,32 个逻辑核最多只能同时执行 32 个线程(1 核 1 线程)
  • 软件调度:操作系统(Windows)会把 CPU 时间切成极短的「时间片」(通常 1~10ms),快速在数千个线程之间切换
  • 用户感知:切换速度极快(毫秒级),人眼完全无法察觉,看起来就像所有线程在「同时运行」

2. 调度流程(Windows 内核调度器)

plaintext

线程就绪队列(6990个线程排队)
    ↓
调度器按优先级分配时间片
    ↓
32个逻辑核同时执行32个线程(时间片内)
    ↓
时间片用完 → 线程回到就绪队列
    ↓
调度下一批32个线程执行
    ↓
循环往复,无限轮转

三、关键调度细节(Windows 专属机制)

1. 混合架构调度(i9-14900HX 专属)

你的 CPU 是性能核 (P 核)+ 能效核 (E 核) 混合架构,Windows 会做智能调度:

  • P 核(8 核 16 线程):优先分配给高优先级、计算密集型任务(如工业视觉算法、编译、游戏)
  • E 核(16 核 16 线程):分配给后台任务、低优先级线程(如系统服务、后台更新、UI 线程)
  • 调度器:Intel Thread Director + Windows 内核协同,自动把线程分配到最合适的核心,避免 P 核被后台任务占用

2. 进程与线程的关系

  • 进程是「容器」,线程是「执行体」:312 个进程里,每个进程至少有 1 个主线程,多数进程有多个工作线程(如浏览器、IDE、工业软件)
  • 线程共享进程资源:同一进程的线程共享内存、句柄,切换成本远低于进程切换
  • 你当前的 6990 线程:平均每个进程约 22.4 个线程,属于 Windows 正常范围(系统服务、后台应用会创建大量线程)

3. 线程优先级调度

Windows 给线程分配 0~31 的优先级,调度规则:

  • 高优先级线程:优先抢占 CPU 时间片,先执行
  • 低优先级线程:在高优先级线程空闲时执行
  • 饥饿预防:调度器会动态提升长期未执行线程的优先级,避免永远得不到执行

四、为什么 32 核跑 6990 线程不会卡死?

1. 绝大多数线程处于「等待状态」

  • 线程不是一直需要 CPU:比如等待 IO(磁盘、网络、硬件响应)、等待事件、睡眠的线程,会进入阻塞状态,不占用 CPU 时间片
  • 你当前 CPU 利用率仅 7%:说明 93% 的时间里,32 个核都在空闲,数千线程大部分时间在等待,只有少量线程在执行
  • 真正需要 CPU 的线程:通常只有几十到几百个,远低于 32 核的并行能力

2. 线程切换成本极低

  • 线程上下文切换(保存 / 恢复寄存器、栈)的耗时仅纳秒级,对系统性能影响极小
  • 只有当线程数远超 CPU 核数(如数万线程),且全部为 CPU 密集型时,才会出现「切换开销 > 执行开销」的 thrashing(颠簸)现象

五、工程实践:如何优化多线程调度(针对你的工业视觉场景)

1. 线程数配置最佳实践

表格

场景 推荐线程数 原因
CPU 密集型(如对比度计算、光谱分析) 等于逻辑核数(32) 避免切换开销,最大化并行效率
IO 密集型(如相机采图、网络通信) 2~4 倍逻辑核数(64~128) 线程等待 IO 时,其他线程可执行
混合负载(工业视觉软件) 1.5~2 倍逻辑核数(48~64) 平衡计算与 IO,避免调度拥堵

2. 针对你当前系统的优化建议

  1. 工业视觉算法线程绑定:把计算密集型线程(如ProcessQueue里的对比度计算)绑定到 P 核,避免被 E 核调度拖慢

    csharp

    运行

    // C# 线程绑定到P核(示例,需根据核心编号调整)
    Process.GetCurrentProcess().ProcessorAffinity = new IntPtr(0xFFFF0000); // 绑定到高16位(P核)
    
  2. 限制后台线程数:关闭不必要的后台服务,减少无效线程对调度器的占用
  3. 异步 IO 优化:用async/await替代同步阻塞线程,减少线程等待开销
  4. 线程池配置:自定义线程池大小,避免.NET 默认线程池无限制创建线程

六、常见误区纠正

❌ 误区 1:线程数越多,性能越高

  • 真相:线程数超过 CPU 核数后,性能会先升后降,超过阈值后切换开销会抵消并行收益
  • 你的系统:6990 线程是系统 + 应用的总和,不是同时执行的线程数,绝大多数在等待

❌ 误区 2:32 核只能跑 32 个线程

  • 真相:32 核是同时执行的最大线程数,操作系统通过时间片轮转,支持数千甚至数万个线程并发

❌ 误区 3:进程数 = 线程数

  • 真相:进程是资源容器,线程是执行单元,1 个进程可以有 N 个线程,两者无直接对应关系

七、总结

  • 32 逻辑处理器调度 6990 线程的核心是时间片轮转 + 优先级调度 + 混合架构智能分配
  • 绝大多数线程处于等待状态,真正执行的线程数远低于 32,因此系统负载极低(7%)
  • 工业视觉场景下,需针对性优化线程数和核心绑定,最大化算法执行效率

###########################################################################

每个线程可以看作是一个流水线,有多个流水线就可以同时运行多段代码,对于某些计算量巨大、同时计算任务又可以拆分的代码,可以将计算任务分配到各个流水线上去,这样就能够更高效地完成指定任务。

总而言之,多线程即 “同时做多件事情”。

下面的代码是一个简单的多线程例子。运行这段代码发现,最后打印的总耗时比每段加起来的耗时要少,这就是并行计算的结果。读者感兴趣可以自行把foreach循环中的有关Task类和lambda函数封包去掉,直接每段运行再进行总耗时求和。

List<Task> tasks = new List<Task>();
Stopwatch sw = Stopwatch.StartNew();
foreach (var item in Enumerable.Range(0,3))
{
    tasks.Add(Task.Run(
    // lambda函数体
    () => {
        Stopwatch sw = Stopwatch.StartNew();
        Thread.Sleep(500);
        Console.WriteLine($"{sw.ElapsedMilliseconds}ms cost");
    }
    ));
}
// 等待所有线程退出
Task.WaitAll(tasks.ToArray());
// 打印计算总耗时
Console.WriteLine(string.Format("Total cost: {0}ms", sw.ElapsedMilliseconds));

异步是什么

异步是一种任务执行的机制或者说方式,它的目的在于解决I/O等待会阻塞线程这个问题(最常见的就是GUI线程阻塞造成画面卡顿),它的实现依托于硬件底层的IRP(I/O Request Packet),它的本质其实是回调。

我可以使用比如 ReadAsync + Task.ContinueWith 的组合,来实现一个异步实践

而更简单的方式是通过微软后续推出 .net4.5 的 async-await 这套关键词来实践。

“异步” 这个概念是对应于 “同步” 概念而言的。“同步”的意思是,所有代码从头至尾按顺序逐条执行,在一行代码执行完之前,不能执行后面的所有代码。下面的例子的中,当Sum()函数被调用的时候,for循环之后的打印 sum 和 Hello World 一定需要等到这个循环结束之后才能被执行。

int Sum(int target)
{
    int sum = 0;
    for (int ind = 0; ind < target; ++ind)
    {
        sum += ind;
    }
    Console.WriteLine(sum.ToString()); // 求和结果打印
    Console.WriteLine("Hello World!"); // Hello World打印
    return sum;
}

而“异步”相对应的,就是代码不按从头至尾的顺序执行,比如我们如果以某种方式让上面代码示例中的打印 Hello World 在打印 sum 求和结果之前执行,那就是异步。

实现异步一般是有两种方式,其一是通过 多线程 (Multithreading),其二是通过 协程 (Coroutine)。

我们平时提到“异步”时,更多地是指 “协程异步”。

线程异步
通过 多线程 来实现异步十分简单直观:把要延后执行的部分扔个一个子程序即可。上述例子中,把for循环封包在一个lambda函数中,然后指派至一个Task类的实例,使用这个实例来进行任务管理即可:

int Sum(int target)
{
    int sum = 0;
    Task<int> task = Task.Run(() =>
    {
        for (int ind = 0; ind < target; ++ind)
        {
            sum += ind;
        }
        Console.WriteLine(sum.ToString()); // 求和结果打印
        return sum;
    });
    Console.WriteLine("Hello World!"); // Hello World打印
    return task.Result;
}

由于函数封装和线程的指派十分灵活,以这种方式实现的异步逻辑在流程控制管理上需要格外小心,并且在处理线程返回值、线程之间的通信上需要更加谨慎,不留意时很容易造成程序死锁。

协程异步
协程异步的提出就是为了解决线程异步时需要格外小心程序死锁这个问题。但要提到协程异步,不得不说到什么是 “协程” (Coroutine)。

协程是什么
协程的全程应该被叫做“协程子程序”,是“协作式多任务子程序”的另一个名字。“Coroutine” 一词是由Melvin Conway于1958年提出汇编语言新架构时提出的,指代“能够随时暂停、恢复的子程序”。

在我们学习编程时,子程序给我们的初印象一般都是“可被复用的代码片段”,它有十分明显的特征:

仅有一个返回值,且仅能返回一次
从头至尾执行
一旦使用return关键词返回,其剩余代码均不再执行
两次执行之间的状态无关,执行结果仅决定于参数
但协程子程序不一样,它可以返回多次而不停止执行,也可以在返回点恢复执行(不从第一行开始执行),两次执行之间的状态会互相关联(虽给定参数一样但执行结果不一样)。

如果大家对Python稍有了解的话,那一定知道生成器的概念,而生成器就是一种协程的架构的实现:

可以返回多次
能够在返回点开始执行,而非代码片段头部开始执行
可以在代码片段中间通过yield关键词返回,其剩余代码会在下次调用时执行
两次执行直接的状态有关,执行结果不单单仅取决于参数
通过协程,可以实现许多十分有意思的功能,且 所有代码均由一个线程执行。

协程如何实现异步
协程天然具备“不从头到位按顺序执行”的特性,所以可以实现“异步”。下述代码即是通过C#中的生成器来实现“生产者-消费者”、并由主线程作为线程调度者的一个简单异步代码示例:

生产者和消费者共享一个队列 q
消费者每次消费 1 个 q 队列中的对象
生产者每次随机生成 0~2 个对象添加至队列 q 中
每个调度循环中,消费者消费2次,生产者生产1次
由于使用了随机数生成器,每次运行的结果会不一样
在Producer和Consumer子程序中,每次程序执行时都是从上一次yield关键词后开始执行,而非从头开始执行。

static IEnumerable<object> Producer(Queue<int> q) // 生产者
{
    while (true)
    {
        if (q.Count < 100)
        {
            RandomNumberGenerator rng = RandomNumberGenerator.Create();
            byte[] num = new byte[1];
            rng.GetBytes(num);
            int n = (int)Math.Round((num[0] / 255.0) * 2);
            byte[] buff = new byte[n];
            rng.GetBytes(buff);
            Console.WriteLine(buff.Aggregate("    Produced:[", (s1, s2) => s1 + $" {s2},") + " ]");
            foreach (int item in buff) q.Enqueue(item);
            Console.WriteLine(q.Aggregate("    Queue:[", (s1, s2) => s1 + $" {s2},") + " ]");
            yield return null; // yield返回,下次进入时,会从此处继续执行
        }
        else if (q == null) yield break;
        else yield return null;
    }
}
static IEnumerable<object> Consumer(Queue<int> q) // 消费者
{
    while (true)
    {
        if (q.Count > 0)
        {
            Console.WriteLine(string.Format("    Consumed: {0}", q.Dequeue()));
            Console.WriteLine(q.Aggregate("    Queue:[", (s1, s2) => s1 + $" {s2},") + " ]");
            yield return null; // yield返回,下次进入时,会从此处继续执行
        }
        else yield break;
    }
}
static void Main(string[] args) // 主线程作为调度者 (Dispatcher)
{
    Queue<int> q = new Queue<int>();                // 共享队列 q
    Console.WriteLine($"Initialization:");
    Producer(q).GetEnumerator().MoveNext();         // 使用生产者生成初始对象
    int maxRunCount = 0;
    while (q.Count > 0 && maxRunCount++ < 500)      // 控制循环
    {
        Console.WriteLine($"Loop {maxRunCount}:");
        Consumer(q).GetEnumerator().MoveNext();     // 消费
        Producer(q).GetEnumerator().MoveNext();     // 生产
    }
}

为什么需要异步
这个场景我们是常常遇到的:

我们想要在一个十分耗时的操作结束后,更新某UI元素。

一般UI是由程序的主线程来维护的,在需要执行这个十分耗时的操作时,我们可以开启一个子线程去做这件事情,并且在子线程结束时对UI进行更新。

但正是因为各个对象是由主线程维护的,一般不允许子线程直接访问UI元素,那么我们在子线程里 无法对UI元素进行更新。

于是聪明的我们直接在子线程开启的同时让主线程等待子线程完成,这样做的结果就是导致这个主线程等待子线程完成的过程中,UI元素会因为主线程在等待而失去对鼠标、键盘事件的响应 —— 窗口处于冻结状态。

那么问题就来了:如何在进行一个耗时操作时,保证主线程不冻结,且耗时操作完成后能更新属于主线程的UI元素。

此时“异步”一个很重要的概念在这个环境下就十分有用了 —— 乱序执行。

下面就是一个使用异步实现读取一个超大文件的一个代码,主程序Main()的执行并没有因ReadHeavy()函数的执行而冻结,“Read finished” 的打印在子函数ReadHeavy()中,子函数被调用的代码是在 “Read file started” 被打印之前,但其真正被执行则在其之后,且在编写这段程序的程序员手里,这段代码仅有一个线程,因此这是一个协程异步程序。

static async Task Main(string[] args)
{
    var task = ReadHeavy();                 // 开始文件读取
    Console.WriteLine("Read file started.");
    await task;
}
async static Task ReadHeavy()
{
    await System.IO.File.ReadAllBytesAsync(@"E:\Downloads\6_26_2018__2_02_17_PM.tdms");
    Console.WriteLine("Read finished.");
}

“6_26_2018__2_02_17_PM.tdms” 是笔者某传感器采集的数据,大约有600MB左右的大小,算得上一个比较大的文件了,而且存储在非SSD磁盘中,所以读取时花费的时间会比较多。

异步大多数情况下是使用多线程实现的
看到这里,相信大家心里已经一万个问号了,前面大费周章介绍了半天异步不是多线程、异步大多数时候指的“协程异步”,怎么到头来又来一个“异步大多数时候是用多线程实现”?这难道不是自相矛盾?

当然不是。这里需要弄清楚的一个很重要的概念 —— 程序员手里的代码与操作系统对处理器硬件的调度执行代码并不是一回事。

异步的“协程”是针对于程序员手里的代码而言,而目前的编程语言对异步的支持大部分时候是通过多线程来实现的。

对于程序员来说,代码仅仅执行在一个线程上 —— 这是代码协程构架。
对于操作系统/代码编译器而言,异步的执行是通过将子程序放入新线程中执行,在执行完毕后,通知主线程,再由主线程来继续执行剩余代码
很难理解对不对?还是上面文件读取的例子,直接上代码

async static Task ReadHeavy()
{
    Console.WriteLine($"In sub, thread id: {Thread.CurrentThread.ManagedThreadId}");
    await System.IO.File.ReadAllBytesAsync(@"E:\Downloads\6_26_2018__2_02_17_PM.tdms");
    Console.WriteLine("Read finished.");
    Console.WriteLine($"In sub, after read, id: {Thread.CurrentThread.ManagedThreadId}");
}

static async Task Main(string[] args)
{
    Console.WriteLine(string.Format("Current Thread: {0}",
        Thread.CurrentThread.ManagedThreadId));
    var dummy = ReadHeavy();
    Console.WriteLine("Read file started.");
    Console.WriteLine($"In main: {Thread.CurrentThread.ManagedThreadId}");
    await dummy;
    Console.WriteLine($"After await, in main: {Thread.CurrentThread.ManagedThreadId}");
    Console.ReadKey();
}

为了能够看清楚到底是哪个线程执行了代码,笔者在之前的代码里加入了大量的打印当前线程的操作。执行结果如下

可见,在 “Read finished.” 打印结束之后,线程编号变了,即便是最后在Main()中的打印也跟着变了。

情况是这样的:

主线程进入Main()
由于遇到var dummy = ReadyHeavy(),主线程进入ReadHeavy()函数
主线程执行打印函数,打印 “In sub, …” 至控制台
【关键点来了】 主线程遇到 await 关键词,主线程直接返回,并将 ReadyAllBytesAsync() 函数交给后台某子线程执行。
主线程由ReadHeavy()返回后,继续按顺序执行打印 “Read file started.”,以及 “In main: …”
主线程遇到 await 关键词,由于这已经是最顶层函数Main(),因此无法返回,此时主线程进入等待
此时由子线程执行的 ReadyAllBytesAsync() 完成,子线程继续执行后续打印 “Read finished.” 以及 “In sub, after read, …”
子线程遇到 ReadHeavy() 函数的尾部,结束执行函数,并通知一直在等待的主线程
【关键点又来】主线程收到子线程发来的贺电,直接退出,将Main()及其所有资源交由子线程处理,此时这个子线程“升级”为新的主线程,负责执行后续代码
相信看到这里大家已经明白了,为什么整个程序员代码中,仅仅只有一个线程,因为除了主线程之外,代码编写者根本无需关心其他线程,整体对于代码编写者而言,其仅仅“感知”到一个线程的存在,这是标准的协程异步。

而在底层的实现中,操作系统的的确确是调用了另一个线程去执行程序中“异步”部分的代码。但是很巧妙的是,在异步执行结束时,原来的主线程直接被子线程取而代之,给人的感觉上是仅有一个线程在做所有的事情,且主线程也一直都可以响应事件。这也是为什么上文中一直在使用“一个线程”而非“同一个线程”措辞的缘由。

async/await关键词的配对出现就是用来告诉编译器这种异步的情况,通过async来表明这个函数是可以从中间返回,也可以从中间开始继续执行,而await关键词来表明这是一个函数的“暂停”点。

一个现实生活中稍微有点形象的例子
作为总结,笔者举个现实生活中一个例子 —— 银行的工作窗口,来说明这一切的一切的区别。

假设我现在去银行柜台窗口办业务,一个柜员接待了我,这个柜员就可以看作是主线程(UI),在负责跟我(用户)进行互动。我提出了一个需要取20万现金的请求,由于数额比较大,需要有人清点现钞。

【单线程】:此时柜员直接自己去清点,花了15分钟,然后把钱给我,中间这15分钟我被晾在了一边,我对着一个空的窗口,十分尴尬。
花费15分钟拿到现钞。

【多线程,死锁】:此时柜员喊了3位同事,四个人一起清点,花了5分钟,然后他们四个同时把自己点好的那一部分试图递给我,但是因为窗口太小,他们四个为了争着第一个给我而产生了争执,并且一直都没有吵出来个结果,我一直被晾在一边。
一直没能拿到现钞。

【多线程,合理管理】:此时柜员喊了3位同事,四个人一起清点,花了5分钟,由于提前商量好了,他们把点完的钱给其中之前接待我的那位柜员,然后这位柜员把钱递到了我手中。但这个过程中,我仍然对着一个空窗口尴尬了5分钟。
花费了5分钟拿到了现钞。

【经典异步】:此时柜员喊了1位同事,将清点现钞的事情交给这位同事处理,交代完事情之后,继续回到窗口与我互动。在同事花费15分钟清点完毕后,柜员接过现金,将现金转交给我。整个过程柜员一直与我互动。
花费了15分钟拿到了现钞。

【多线程异步】:此时柜员喊了1位同事,将清点现钞的事情交给这位同事处理,交代完事情之后,继续回到窗口与我互动。在同事花费清点完毕后,由同事直接把现金交给我,并且他/她坐下来作为柜员继续与我进行后续互动,原来的柜员去后台干别的了。整个过程中,始终有一个人与我互动,但前半段是柜员A,后半段是A的同事B。
花费15分钟拿到了现钞。

【多线程异步,进一步提高效率】:此时柜员喊了3位同事,由这三位同事负责清点,花了7分钟,由于提前商量好了,他们把清点完的现钞交由他们其中一个人,由这位柜员将现钞由窗口递给我。在这7分钟中,原来的柜员一直与我互动,我收到现钞后,由递交给我现钞的那位柜员坐下继续负责与我互动,其余柜员去后台干别的去了。整个过程中始终又一位柜员与我互动,前半段是柜员A,后半段是A的同事B/C/D中提前商量好负责交接的那位。
花费7分钟拿到现钞。

总而言之,异步是为了解决主线程(UI)冻结而提出的基于协程的架构,大部分时候底层是通过多线程实现的。

实际工作中,我们其实记住一个点就可以很轻易分辨我们到底更需要关注哪种技术的实现:

I/O密集型操作 —— 异步 吞吐量高【吞吐量一般指相当一段时间内测量出来的系统单位时间处理的任务数或事务数(TPS)】
计算密集型操作 —— 多线程
————————————————

什么是异步?

异步是一种任务执行的机制或者说方式,它的目的在于解决I/O等待会阻塞线程这个问题(最常见的就是GUI线程阻塞造成画面卡顿),它的实现依托于硬件底层的IRP(I/O Request Packet),它的本质其实是回调。

我可以使用比如 ReadAsync + Task.ContinueWith 的组合,来实现一个异步实践

而更简单的方式是通过微软后续推出 .net4.5 的 async-await 这套关键词来实践。

异步函数 async-await

异步函数,实际通过 核心类TaskAwaiter + 状态机 实现。

核心类 TaskAwaiter

这个类比较简单,每个异步Task都有。我把它看作黑盒不细究,只看对外接口:

  • IsCompleted 属性:表示Task是否完成
  • GetResult() 方法:结束异步任务完成的等待
  • UnsafeOnCompleted(Action) 方法:设置延续任务

使用方法

await必须在有async标记的方法内使用。如果async方法内部没有await,那它就和同步方法一样执行。

如果执行中遇到了await,就把需要await执行的Task交给线程池来执行,而原来那个线程则退出async方法的方法体,去执行外部的后续代码,直到await的Task执行完毕返回结果后,这个线程会回到方法体await处继续执行。整体流程看上去很像ContinueWith。

接下来研究编译器做的事。

外部调用层

1.首先编译器看到方法有async标记,就会为其生成一个实现了IAsyncStateMachine接口的类,这个接口意味着它是一个异步状态机

2.async方法自身则会被标记AsyncStateMachine特性,意味着这是个异步方法。

3.async方法内部则是new了一个异步状态机实例,先初始化,然后调用它的Start方法来启动状态机。Start方法内部主要是调用了状态机的MoveNext方法

4.最后将指示方法运行状态的builder.Task对象 return 。

内部状态机层

每个异步状态机都有2个核心字段和一个核心方法:

  • builder:负责异步相关的操作,是方法实现异步执行的核心
  • state:状态机的当前状态,初始化时赋值为 -1
  • MoveNext方法:状态机切换状态、执行任务、设置延续任务的方法

我们直接讲MoveNext方法。

第一次MoveNext时,我把它分成3块流程:

1.第一次进入状态机时state!=0,await之前的代码被包裹到了MoveNext方法体内,照常顺序执行。

2.await那一行的代码,变成了获取任务的awaiter Task.GetAwaiter()。然后对任务的awaiter进行判断:

​ - 如果awaiter.IsCompleted 为true,意味着任务已经执行完了,执行第3步。

​ - 如果awaiter.IsCompleted 为false,意味着任务还未执行完(一般初始化完第一次进来,都是false)那就会做以下操作:

state赋值为0,将awaiter存到自身字段内供后续使用,然后设置延续任务builder.AwaitUnsafeOnCompleted,设置完之后会return掉而不执行第3步

延续任务内部怎么设置的比较复杂,我觉得不需要理解深入,理解为调了awaiter的接口就行了,反正最终效果是await的任务完成后再次调用MoveNext转动状态机

3.结束awaiter TaskAwaiter.GetResult() 。执行原先await那一行之后的后续代码。执行完成后,将state赋值为-2,并为builder标记任务成功AsyncTaskMethodBuilder.SetResult()

如果不是第一次MoveNext,就会省去1、2步:

如果await的任务完成,就会触发延续任务:再次调用MoveNext。但是和第一次进不一样,此时因为state==0会跳过第1步第2步,将state设置为-1之后直接跳入第3步。

多层嵌套async-await

其实实现也只是多层嵌套异步状态机而已,是一样的。

一个async中多个await

在同一个异步状态机中,生成更多的awaiter、更多的state。

state的-2(完成)、-1(初始)是定好的,所以只会从0开始生成,而有几个await就有几个awaiter、state。

一旦state多起来,内部就不再 if-else 了,而是 switch-case + goto 。

上下文

接触的主要是,

SynchronizationContext 同步上下文:捕获提供在各种同步模型中传播同步上下文的基本功能。它有一个Post()虚方法,Winform、WPF等等会重写它,让它被调用时能产生不同的过程,但目的都是一个:实现使用GUI线程执行Post过去的委托。

ExecutionContext 执行上下文:流动。在执行委托时恢复另一个线程的状态环境。

WPF中使用 async await

上面我们都是通过控制台举的例子,这是没有任何SynchronizationContext的,但是WPF(Winform同理)就不同了,在UI线程中,它拥有属于自己的DispatcherSynchronizationContext

这个工作中有体会,就是View.xmal.cs文件内写诸如点击事件的async-await,延续任务会默认借调GUI线程而非await内分配的任务池线程。。

而我可以改成使用SynchronizationContext.Post()来实现类似的效果。

怎么用

用SynchronizationContext可以实现确定性使用GUI线程来执行委托,我的理解只到此。

// TODO 光看不行,还是得自己实现一套简单的Task封装才行,以后参考UniTask。

#############################################################################

基于任务的异步模式(TAP)解析

#############################################################################

#############################################################################

基础

委托,回调,事件,Action,Func, 线程,线程池,Task, async/await

【语法基础】
委托 Delegate
   ↓
【系统内置】
Action(无返回)   Func(有返回)
   ↓
【使用方式】
回调(参数传递)   事件(安全订阅)

==================================================

【并发底层】
Thread(系统线程) → ThreadPool(线程池)
   ↓
【高级抽象】
Task(统一异步/并发)
   ↓
【语法糖】
async / await

一、先把 “语法层” 讲清楚(最容易混的前 6 个)

1. 委托 Delegate

本质:C# 的 “类型安全的函数指针”

  • 用来定义一个方法的签名
  • 可以把方法当作参数、变量传递
  • 是回调、事件、Action、Func 的地基

csharp

运行

// 自定义委托
delegate int Calc(int a, int b);

2. Action

系统自带的、无返回值委托

  • 0~16 个参数
  • 返回 void

csharp

运行

Action<string> act = s => Console.WriteLine(s);

3. Func

系统自带的、有返回值委托

  • 最后一个泛型是返回值
  • 0~16 个参数

csharp

运行

Func<int, int, int> add = (a,b) => a + b;

4. 回调 Callback

用委托 / Action/Func 当参数,干完再调用就是:延迟执行的方法

csharp

运行

void Work(Action callback) => callback();

5. 事件 Event

被安全限制过的委托

  • 外部只能 += / -=
  • 不能随便 Invoke
  • 专门用于订阅 / 通知

csharp

运行

event Action OnCompleted;

二、再讲 “并发 / 异步层”(后面 4 个)

6. 线程 Thread

操作系统真正创建的线程,重量级

  • 创建销毁开销大
  • 适合长时间运行任务

csharp

运行

new Thread(() => { }).Start();

7. 线程池 ThreadPool【池化思想

系统预先创建一堆线程,复用

  • 减少开销
  • 适合大量短任务
  • 难控制、难等待、无返回值

csharp

运行

ThreadPool.QueueUserWorkItem(_ => { });

8. Task

对异步操作的高级封装两种情况:

  • CPU 密集:用线程池
  • I/O 密集:不占线程(IOCP)

支持:等待、返回值、异常、组合

csharp

运行

Task.Run(() => 123);

9. async / await

Task 的语法糖让异步代码写得像同步一样

csharp

运行

async Task<int> F(){
    await Task.Delay(100);
    return 1;
}

四、一句话总结(最精华)

  1. 委托:方法的类型
  2. Action/Func:系统自带的常用委托
  3. 回调:委托当参数,延迟调用
  4. 事件:安全版委托,用于订阅
  5. Thread:操作系统线程,重
  6. ThreadPool:复用线程,轻
  7. Task:异步 / 并发统一封装
  8. async/await:让 Task 写起来像同步
using System;
using System.Threading;
using System.Threading.Tasks;

class Program
{
    static void Main()
    {
        // 1 委托
        Calc calc = Add;
        Console.WriteLine("委托:" + calc(1,2));

        // 2 Action
        Action<string> act = Console.WriteLine;
        act("Action:无返回");

        // 3 Func
        Func<int,int,int> func = (a,b)=>a*b;
        Console.WriteLine("Func:" + func(2,3));

        // 4 回调
        DoCallback(() => Console.WriteLine("回调执行"));

        // 5 事件
        var worker = new Worker();
        worker.OnWork += () => Console.WriteLine("事件触发");
        worker.Run();

        // 6 Thread
        new Thread(()=>Console.WriteLine("Thread")).Start();
        Thread.Sleep(100);

        // 7 ThreadPool
        ThreadPool.QueueUserWorkItem(_=>Console.WriteLine("ThreadPool"));
        Thread.Sleep(100);

        // 8 Task
        Task.Run(()=>Console.WriteLine("Task")).Wait();

        // 9 async/await
        TestAsync().Wait();
    }

    delegate int Calc(int a,int b);
    static int Add(int a,int b)=>a+b;

    static void DoCallback(Action cb)=>cb();

    class Worker{ public event Action OnWork; public void Run()=>OnWork(); }

    static async Task TestAsync()
    {
        await Task.Delay(100);
        Console.WriteLine("async/await");
    }
}

异步时序流程图

一、先看整体结构(一句话串完所有)

plaintext

【基础语法】
委托 Delegate
   ↓
Action / Func(系统自带委托)
   ↓
回调(传方法) / 事件(安全订阅)

【并发底层】
Thread(系统线程)→ ThreadPool(线程池复用)

【现代异步】
Task(对线程/IOCP统一封装)
   ↓
async/await(语法糖,让异步像同步)

二、同步代码执行时序(对比用)

plaintext

主线程:
→ 执行代码A
→ 执行代码B(耗时操作)
→ 执行代码C

【特点】
全程只有一条线,B卡住,C必须等

三、Thread 执行时序(多线程)

plaintext

主线程:
→ 启动新 Thread
→ 继续往下跑

新线程:
→ 执行耗时操作

【特点】
两个线程同时跑
但线程创建昂贵,多了崩溃

四、ThreadPool 执行时序

plaintext

主线程:
→ 把任务丢线程池
→ 继续往下

线程池线程:
→ 有空线程就执行任务

【特点】
线程复用,省资源
但:无返回值、难等待、难抓异常

五、Task 执行时序(Task.Run 版本)

plaintext

主线程:
→ Task.Run(耗时任务)
→ 继续执行

线程池线程:
→ 执行任务
→ 任务完成 → 标记 Task 为完成

【特点】
可以 Wait、可以拿 Result、可以抓异常
但 Wait/Result 会阻塞主线程

六、async/await 真正异步时序(最重要!)

plaintext

主线程遇到:
async Task Test()
{
    CodeA();

    await DoIOAsync();  ← 关键点

    CodeB();
}

执行流程:

1. 主线程运行 CodeA
2. 碰到 await
3. **主线程直接返回调用者,去干别的!**
   (比如响应用户点击、刷新界面)
4. 底层系统(IOCP)等待 I/O 完成
   **这期间没有任何线程在等待!**
5. I/O 完成后
6. 线程池线程(或主线程)回来执行 CodeB

文字版时序图

plaintext

主线程:
CodeA → await → 退出方法 → 去干别的

I/O 操作(网络/文件/数据库):
...等待硬件/网络... 不占线程

I/O 完成后:
线程池线程 → 执行 CodeB

核心结论

  • await 不会卡住线程
  • I/O 异步期间,没有线程在等待
  • 这才是高并发的关键

七、一张总图:从委托到 async/await 进化史

plaintext

1. 委托
   ↓ 用来传方法
2. 回调 / Action / Func / 事件
   ↓ 想并发
3. Thread(太重)
   ↓ 想省资源
4. ThreadPool(难用)
   ↓ 想统一管理
5. Task(支持等待、返回、异常)
   ↓ 想写得简单
6. async/await(最终形态)

八、最容易混淆的 3 个问题一句话答案

  1. Task 一定开线程吗?不一定。I/O 异步(HttpClient、FileStream)不开线程,只靠系统 IOCP。

  2. await 时线程在干嘛?解放了,去干别的了。 没有线程空转。

  3. Task.Run 和 真正异步的区别?

    • Task.Run:用线程池模拟异步(CPU 密集用)
    • await HttpClient.GetStringAsync:真异步,不占线程(I/O 密集用)

#############################################################################

参考:

https://www.cnblogs.com/xiaoxiaotank/p/14303803.html

https://www.cnblogs.com/xiaoxiaotank/p/13666913.html

https://codingcodingk.top/2022/01/14/Tech/CSharp/CLR-Via-CSharp/cp7/

Posts | Kit Lau's Blog

异步的原理是什么?C# 如何基于状态机实现异步? | Kit Lau's Blog

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐