【C#杂谈】异步与多线程的区别与联系 async / await / multithreading

8 篇文章 6 订阅
2 篇文章 0 订阅

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

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

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

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

多线程是什么

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

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

WeChat Screenshot_20210105091239

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

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

下面的代码是一个简单的多线程例子。运行这段代码发现,最后打印的总耗时比每段加起来的耗时要少,这就是并行计算的结果。读者感兴趣可以自行把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));

异步是什么

异步” 这个概念是对应于 “同步” 概念而言的。“同步”的意思是,所有代码从头至尾按顺序逐条执行,在一行代码执行完之前,不能执行后面的所有代码。下面的例子的中,当Sum()函数被调用的时候,for循环之后的打印 sumHello 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次
  • 由于使用了随机数生成器,每次运行的结果会不一样

ProducerConsumer子程序中,每次程序执行时都是从上一次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();     // 生产
    }
}

WeChat Screenshot_20210105221023

为什么需要异步

这个场景我们是常常遇到的:

我们想要在一个十分耗时的操作结束后,更新某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();
}

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

WeChat ScreenShot_20210105231016

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

情况是这样的:

  1. 主线程进入Main()
  2. 由于遇到var dummy = ReadyHeavy(),主线程进入ReadHeavy()函数
  3. 主线程执行打印函数,打印 “In sub, …” 至控制台
  4. 【关键点来了】 主线程遇到 await 关键词,主线程直接返回,并将 ReadyAllBytesAsync() 函数交给后台某子线程执行。
  5. 主线程由ReadHeavy()返回后,继续按顺序执行打印 “Read file started.”,以及 “In main: …”
  6. 主线程遇到 await 关键词,由于这已经是最顶层函数Main(),因此无法返回,此时主线程进入等待
  7. 此时由子线程执行的 ReadyAllBytesAsync() 完成,子线程继续执行后续打印 “Read finished.” 以及 “In sub, after read, …”
  8. 子线程遇到 ReadHeavy() 函数的尾部,结束执行函数,并通知一直在等待的主线程
  9. 【关键点又来】主线程收到子线程发来的贺电,直接退出,将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密集型操作 —— 异步
  • 计算密集型操作 —— 多线程
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值