[聚合文章] NET多线程和异步总结(二)

c# 2018-02-22 15 阅读

承接上文。

线程池

线程池主要有两个好处:

  1. 避免线程创建和销毁的开销。
  2. 自动缩放:可以按需增减线程的数量。

总之,Windows系统自带了线程池的功能,通常情况下,你不可能有更好的实现。所以只需了解如何使用。

Windows的线程池有两种,分别是非托管线程池和托管线程池(即.NET线程池)。下面分别来介绍。

非托管线程池

  • 每个进程都有一个线程池,线程池有个IOCP。
  • 其中的线程分为IO线程和工作者线程(或非IO线程)。

    • 其中工作者线程监听线程池的IOCP。
    • IO线程专门执行APC的异步完成例程。在空闲时一直是可唤醒状态。
  • 调用Windows API QueueUserWorkItem会让一个监听在IOCP上的工作者线程醒来,并执行例程。
  • 调用BindIOCompletionCallback把一个文件句柄绑定到线程池的IOCP上。当此文件有关的IO操作完成时,一个工作者线程会被唤醒来执行后面的操作。
  • 调用QueueUserWorkItem并传入WT_EXECUTEINPERSISTENTTHREAD标识时,会将一个APC回调放入IO线程的APC队列中。

托管线程池

  • 每个CLR都有一个线程池,线程池有个IOCP。(一般来说一个进程有一个CLR,也可能有多个)
  • 其中的线程分为工作者线程和IO线程。

    • 工作者线程从任务队列中取得任务并执行(不通过IOCP)。
    • IO线程则监听IOCP,在唤醒时执行任务的ContinueWith集合中的任务。
  • 可以调用Task.Run()ThreadPool.QueueUserWorkItem()来添加任务到任务队列中。
  • 使用ThreadPool.UnsafeQueueNativeOverlapped()可以将任务添加到IO线程中。但很少使用。

可见,托管与非托管线程池的差距是巨大的。

.NET多线程及异步编程模型

  1. .NET 3.5及之前
    只有Thread, ThreadPool等接口。直接操作线程。
  2. .NET 4
    引入了Task
  3. .NET 4.5.1 (C# 5.0)
    引入了TAP(基于Task的异步模式),引入了 async/await

下面来着重分析Task编程模式。

Task初级

Task最重要的两个方法是Task.Run()new Task().Start()。使用这两个方法:

  • 调用者传递一个委托给Task。
  • 线程池的一个线程会执行此委托。
  • 返回值将保存在Task.Result中。
  • 调用Task.Wait()Task.Result会阻塞地等待工作线程执行结束。

以下图示演示了这个过程具体经历了什么。

在任务完成时继续执行其他Task

调用Task.ContinueWith(continueAction, continueOptions)。其中,ContinuationOptions是一个枚举,提供了更多选项。

这个过程如何理解呢?请看下图。

Task里面有什么

以下是一些主要的属性:

  • Id
  • State
  • Reference of parent task
  • Reference of Task scheduler
  • Delegate
  • AsyncState (to pass method’s objects)
  • ExecutionContext
  • CancellationToken
  • Collection of ContinueWithTask

async/await的机制

它实际上是Task.ContinueWith()的语法糖。

为此我们来看一段程序:

private async void btnDoStuff_Click()
{
    lblStatus.Content = "Doing Stuff";

    await Task.Delay(4000);

    lblStatus.Content = "After await";
}

它相当于

private void btnDoStuff_Click()
{
    lblStatus.Content = "Doing Stuff";

    Task t = Task.Delay(4000);

    t.ContinueWith(task => lblStatus.Content = "After await");
}

但是ContinueWith里面的是一个委托,最好写成函数。另写一个函数最简单,不过C#使用另一种“状态机”技术把这个函数写在了原来的方法内。也就是说,第一段代码实际上会被编译为(并不精确):

private void btnDoStuff_Click(int step)
{
    switch (step)
    {
        case 0:
            lblStatus.Content = "Doing Stuff";
            Task t = Task.Delay(4000);
            t.ContinueWith(task => btnDoStuff_Click(task.Step));
            break;
        case 1:
            lblStatus.Content = "After await";
            break;
    }
}

方法被添加了一个参数step, 第一次调用方法时step为0,此后每进入一次则加一。由此则用一个方法实现了两端逻辑,这便是状态机重入技术。C#的另一个语法糖:用yield实现IEnumerable接口也是采用这种技术。

Execution Context 执行上下文

它在多线程的好比空气:你可以不知道它,但它非常重要。ExecutionContext是为了解决线程本地存储在多线程中无法传递的问题:总得有一种机制能够传递全局信息。否则只能通过函数调用参数传递了。
当一个线程发起异步调用的时候,ExecutionContext会自动的在线程之间传递以下信息:

  • 线程安全设置
  • Host设置(与web服务有关)
  • Logical Call Context, 可以在其中保存和传递对象。
  • 线程的Culture(从.NET 4.6以后)。

Synchronization Context 同步上下文

它是为了描述异步调用返回时的行为所创建的抽象。它有两个基本接口方法:

  • Send 同步地等待任务执行完毕。
  • Post 把任务发出去就不管了。

那么异步调用返回时的行为是什么意思?既然是抽象,那就会有具体的实现。后面我们会看到几种实现。

当开始异步调用时,C#会捕获(capture)当前线程的同步上下文,并保存到Task中。在异步调用返回时,需要恢复(resume)同步上下文。此时就会调用同步上下文的Send或者Post

下面是几种典型的同步上下文实现:

  1. UI同步上下文。由于UI界面操作必须在UI线程中进行,因此这个上下文做的事情就是把需要恢复的工作Marshal起来交给UI线程。(可能有人会好奇如何交给UI线程去做。简单来说, UI线程有个Windows消息循环,同步上下文将任务封装在一个特定消息中,UI线程得到这个消息后,就去执行其中的任务)。
  2. ASP.NET同步上下文。它有以下特点:

    • 不会切换线程,因为后台线程没什么区别。
    • 会把线程的Principle和Culture传递过去。(因为ASP.NET依赖于此)
    • 在异步页面中记录尚未完成的IO数量。
  3. 默认同步上下文。就是线程池的调度器,基本上没有特别的操作。

最后,调用ConfigureAwait(false)时,就会跳过恢复同步上下文这一过程。所以,有时候必要(当没必要传递任何信息时,使用它可以提高效率),有时候又会出错。例如,UI程序的异步调用本来没问题,你加了这个语句,反而会造成修改界面的操作可能不在UI线程中执行,从而出错。但是注意,无论如何,执行上下文都是会传递的。

结合以上,第一段程序的更精确的编译后版本是这样的:

private void btnDoStuff_Click(int step)
{
    switch (step)
    {
        case 0:
            lblStatus.Content = "Doing Stuff";
            Task t = Task.Delay(4000);
            t.ContinueWith(
                task => SynchronizationContext.Current.Post(
                 state => btnDoStuff_Click(task.Step), 
                 task)
            );
            break;
        case 1:
            lblStatus.Content = "After await";
            break;
    }
}

到底是谁在执行异步调用?

这个问题曾经困扰我很久。如果我当前的线程调用一个异步调用后返回了,那到底是谁在完成真正调用的工作呢?答案是一个(或几个)共享的线程:线程池中的IO线程。

如下是一段代码:

async void GetButton_OnClick(object o, EventArgs e)
{
    Task<Image> task = GetFaviconAsync(_url);

    Image image = await task;

    AddAFavicon(image);
}

async Task<Image> GetFaviconAsync(string url)
{
    var task = _webClient.DownloadDataTaskAsync(url);

    byte[] bytes = await task;

    return MakeImage(bytes);
}

线程的执行情况如下图:

大部分的时间都在用户线程中。只有调用到非常底层,IO完成之后,才有IO线程被唤醒(见11),然后它调用Task的同步上下文的Post,将剩下的任务再交给用户线程去执行。

下面是一个动态的解释:

ASP.NET应用的异步线程模型

关于IIS的架构和工作过程,有一些资料,这里也不打算深究。提供两张图,以供理解。

注:本文内容来自互联网,旨在为开发者提供分享、交流的平台。如有涉及文章版权等事宜,请你联系站长进行处理。