一个高频问题:C#异步操作会创建线程吗?

这个问题在微信上被别人问过好多次,想来想去觉得有必要统一解答下,先说下我的答案:可能会,也有可能不会

要想寻找答案,需要从 异步处理 的底层框架说起。.

一:异步底层是什么

异步 从设计层面上来说它就是一个 发布订阅者 模式,毕竟它的底层用到了 端口完成队列,可以从 IO完成端口内核对象 所提供的三个方法中有所体现。

  1. CreateIoCompletionPort

可以粗看下签名:

HANDLE WINAPI CreateIoCompletionPort(
  _In_     HANDLE    FileHandle,
  _In_opt_ HANDLE    ExistingCompletionPort,
  _In_     ULONG_PTR CompletionKey,
  _In_     DWORD     NumberOfConcurrentThreads
);

这个方法主要是将 文件句柄 和 IO完成端口内核对象 进行绑定,其中的 NumberOfConcurrentThreads 表示完成端口最多允许 running 的线程上限。

  1. PostQueuedCompletionStatus

再看签名:

BOOL WINAPI PostQueuedCompletionStatus(
  _In_     HANDLE       CompletionPort,
  _In_     DWORD        dwNumberOfBytesTransferred,
  _In_     ULONG_PTR    dwCompletionKey,
  _In_opt_ LPOVERLAPPED lpOverlapped
);

这个函数的作用就是将一个  通过 内核对象 丢给 驱动设备程序 ,由后者与硬件交互,比如文件

  1. GetQueuedCompletionStatus

看签名:

BOOL GetQueuedCompletionStatus(
  [in]  HANDLE       CompletionPort,
        LPDWORD      lpNumberOfBytesTransferred,
  [out] PULONG_PTR   lpCompletionKey,
  [out] LPOVERLAPPED *lpOverlapped,
  [in]  DWORD        dwMilliseconds
);

这个方法尝试从 IO完成端口内核对象 中提取 IO 包,如果没有提取到,那么就会无限期等待,直到提取为止。

对上面三个方法有了概念之后,接下来看下结构图:

一个高频问题:C#异步操作会创建线程吗?

 

这张图非常言简意赅,不过只画了 端口完成队列, 其实还有三个与IO线程有关的队列,分别为:等待线程队列, 已释放队列已暂停队列,接下来我们稍微解读一下。

当 线程t1 调用 GetQueuedCompletionStatus 时,假使此刻 任务队列q1 无任务, 那么 t1 会卡住并自动进去 等待线程队列 ,当某个时刻 q1 进了任务(由驱动程序投递的),此时操作系统会将 t1 激活来提取 q1 的任务执行,同时将 t1 送到已释放队列中。

这个时候就有两条路了。

  1. 遇到 Sleep 或者 lock 情况。

如果 t1 在执行的时候,遇到了 Sleep 或者 lock 锁时需要被迫停止,此时系统会将 t1 线程送到 已暂停线程队列 中,如果都 sleep 了,那 NumberOfConcurrentThreads 就会变为 0 ,此时就会遇到无人可用的情况,那怎么办呢?只能让系统从 线程池 中申请更多的线程来从 q1 队列中提取任务,当某个时刻, 已暂停线程队列 中的线程激活,那么它又回到了 已释放队列 中继续执行任务,当任务执行完之后,再次调用 GetQueuedCompletionStatus 方法进去 等待线程队列

当然这里有一个问题,某一个时刻 等待线程队列 中的线程数会暂时性的超过 NumberOfConcurrentThreads 值,不过问题也不大。

说了这么多理论是不是有点懵, 没关系,接下来我结合 windbg 和 coreclr 源码一起看下。

一个高频问题:C#异步操作会创建线程吗?

 

以我的机器来说,IO完成端口内核对象 默认最多允许 12 个 running 线程,当遇到 sleep 时看看会不会突破 12 的限制,上代码:

    class Program
    {
        static void Main(string[] args)
        {
            for (int i = 0; i < 2000; i++)
            {
                Task.Run(async () =>
                {
                    await GetString();
                });
            }

            Console.ReadLine();
        }

        public static int counter = 0;

        static async Task<string> GetString()
        {
            var httpClient = new HttpClient();

            var str = await httpClient.GetStringAsync("http://cnblogs.com");

            Console.WriteLine($"counter={++counter}, 线程:{Thread.CurrentThread.ManagedThreadId},str.length={str.Length}");

            Thread.Sleep(1000000);

            return str;
        }
    }

一个高频问题:C#异步操作会创建线程吗?

 

从图中看,已经破掉了 12 的限制,那是不是 30 呢?可以用 windbg 帮忙确认一下。

0:059> !tp
CPU utilization: 3%
Worker Thread: Total: 13 Running: 0 Idle: 13 MaxLimit: 2047 MinLimit: 12
Work Request in Queue: 0
--------------------------------------
Number of Timers: 1
--------------------------------------
Completion Port Thread:Total: 30 Free: 0 MaxFree: 24 CurrentLimit: 30 MaxLimit: 1000 MinLimit: 12


从最后一行看,没毛病, IO完成端口线程 确实是 30 个。

在这种情况,异步操作一定会创建线程来处理

  1. 遇到耗时操作

所谓的耗时操作,大体上是大量的序列化,复杂计算等等,这里我就用 while(true) 模拟,因为所有线程都没有遇到暂停事件,所以理论上不会突破 12 的限制,接下来稍微修改一下 GetString() 方法。

        static async Task<string> GetString()
        {
            var httpClient = new HttpClient();

            var str = await httpClient.GetStringAsync("http://cnblogs.com");

            Console.WriteLine($"counter={++counter},时间:{DateTime.Now}, 线程:{Thread.CurrentThread.ManagedThreadId},str.length={str.Length}");

            while (true) { }

            return str;
        }

一个高频问题:C#异步操作会创建线程吗?

 

对比图中的时间,过了30s也无法突破 12 的限制,毕竟这些线程都是 running 状态并都在 已释放队列中,这也就造成了所谓的 请求无响应 的尴尬情况。

二:直面问题

如果明白了上面我所说的,那么 异步操作会不会创建线程 ? 问题,我的答案是 有可能会也有可能不会,具体还是取决于上面提到了两种 callback 逻辑。