async-await 如何“保存线程”?

use*_*000 5 .net c# multithreading asynchronous async-await

我知道使用无线程异步有更多线程可用于服务输入(例如 HTTP 请求),但我不明白当异步操作完成并且需要一个线程来运行它们时,这不会导致线程饥饿延续。

假设我们只有 3 个线程

Thread 1 | 
Thread 2 |
Thread 3 |
Run Code Online (Sandbox Code Playgroud)

并且它们在需要线程的长时间运行的操作中被阻塞(例如在单独的数据库服务器上进行数据库查询)

Thread 1 | --- | Start servicing request 1 | Long-running operation .................. |
Thread 2 | ------------ | Start servicing request 2 | Long-running operation ......... |
Thread 3 | ------------------- | Start servicing request 3 | Long-running operation ...|
               |
              request 1
                        |
                      request 2
                                |
                              request 3
                                               |
                                           request 4 - BOOM!!!!
Run Code Online (Sandbox Code Playgroud)

使用 async-await 你可以像这样

Thread 1 | --- | Start servicing request 1 | --- | Start servicing request 4 | ----- |
Thread 2 | ------------ | Start servicing request 2 | ------------------------------ |
Thread 3 | ------------------- | Start servicing request 3 | ----------------------- |
               |
              request 1
                        |
                      request 2
                                |
                              request 3
                                                 |
                                           request 4 - OK   
Run Code Online (Sandbox Code Playgroud)

但是,在我看来,这可能会导致“进行中”的异步操作过多,如果同时完成的操作太多,则没有可用的线程来运行它们的延续。

Thread 1 | --- | Start servicing request 1 | --- | Start servicing request 4 | ----- |
Thread 2 | ------------ | Start servicing request 2 | ------------------------------ |
Thread 3 | ------------------- | Start servicing request 3 | ----------------------- |
               |
              request 1
                        |
                      request 2
                                |
                              request 3
                                                 |
                                           request 4 - OK   
                                                      | longer-running operation 1 completes - BOOM!!!!                    
Run Code Online (Sandbox Code Playgroud)

Evk*_*Evk 5

假设您有一个 Web 应用程序,它使用非常常见的流程处理请求:

  • 预处理请求参数
  • 执行一些IO
  • 后处理IO结果并返回给客户端

这种情况下的IO可以是数据库查询、socket读\写、文件读\写等。

作为 IO 的示例,我们以文件读取和一些任意但实际的计时为例:

  1. 请求参数的预处理(验证等)需要 1ms
  2. 文件读取 (IO) 需要 300 ms
  3. 后处理需要 1ms

现在假设有 100 个请求进来,间隔为 1 毫秒。通过这样的同步处理,您需要多少个线程来立即处理这些请求?

public IActionResult GetSomeFile(RequestParameters p) {
    string filePath = Preprocess(p);
    var data = System.IO.File.ReadAllBytes(filePath);
    return PostProcess(data);
}
Run Code Online (Sandbox Code Playgroud)

嗯,显然是 100 个线程。由于在我们的示例中文件读取需要 300 毫秒,因此当第 100 个请求到来时 - 前 99 个请求正忙于被文件读取阻塞。

现在让我们“使用异步等待”:

public async Task<IActionResult> GetSomeFileAsync(RequestParameters p) {
    string filePath = Preprocess(p);
    byte[] data;
    using (var fs = System.IO.File.OpenRead(filePath)) {
        data = new byte[fs.Length];
        await fs.ReadAsync(data, 0, data.Length);
    }
    return PostProcess(data);
}
Run Code Online (Sandbox Code Playgroud)

现在需要多少个线程才能无延迟地处理 100 个请求?仍然是 100。这是因为文件可以以“同步”和“异步”模式打开,并且默认情况下以“同步”模式打开。这意味着即使您正在使用ReadAsync- 底层 IO 也不是异步的,并且线程池中的某些线程被阻塞等待结果。通过这样做,我们取得了什么有用的成果吗?在网络应用程序的上下文中 - 根本不是。

现在让我们以“异步”模式打开文件:

public async Task<IActionResult> GetSomeFileReallyAsync(RequestParameters p) {
    string filePath = Preprocess(p);
    byte[] data;
    using (var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, FileOptions.Asynchronous)) {
        data = new byte[fs.Length];
        await fs.ReadAsync(data, 0, data.Length);
    }

    return PostProcess(data);
}
Run Code Online (Sandbox Code Playgroud)

我们现在需要多少线程?理论上,现在 1 个线程就足够了。当您以“异步”模式打开文件时 - 读取和写入将利用(在 Windows 上)窗口重叠 IO。

简单来说,它的工作原理如下:有一个类似队列的对象(IO 完成端口),操作系统可以在其中发布有关某些 IO 操作完成的通知。.NET 线程池注册了一个这样的 IO 完成端口。每个.NET应用程序只有一个线程池,因此有一个IO完成端口。

当文件以“异步”模式打开时 - 它将其文件句柄绑定到此 IO 完成端口。现在,当您ReadAsync执行实际读取时,不会阻塞专用(针对此特定操作)线程来等待读取完成。当操作系统通知 .NET 完成端口此文件句柄的 IO 已完成时 - .NET 线程池在线程池线程上执行延续。

现在让我们看看在我们的场景中如何处理 100 个间隔为 1 毫秒的请求:

  • 请求 1 进入后,我们从池中抓取线程来执行 1ms 的预处理步骤。然后线程执行异步读取。它不需要阻塞等待完成,因此返回到池中。

  • 请求 2 进入。池中已经有一个线程刚刚完成请求 1 的预处理。我们不需要额外的线程 - 我们可以再次使用该线程。

  • 对于所有 100 个请求也是如此。

  • 在处理完 100 个请求的预处理后,距离第一个 IO 完成还有 200 毫秒,在此期间我们的 1 个线程可以做更多有用的工作。

  • IO 完成事件开始到达 - 但我们的后处理步骤也非常短(1 毫秒)。同样只有一个线程可以处理所有这些。

这当然是一个理想化的场景,但它展示了如何不是“异步等待”而是具体的异步 IO 可以帮助您“节省线程”。

如果我们的后处理步骤并不短,但我们决定在其中执行繁重的 CPU 密集型工作怎么办?嗯,这会导致线程池饥饿。线程池将立即创建新线程,直到达到可配置的“低水位线”(您可以通过 获取ThreadPool.GetMinThreads()并通过 更改ThreadPool.SetMinThreads())。达到该线程数量后 - 线程池将尝试等待繁忙线程之一空闲。当然它不会永远等待,通常它会等待 0.5-1 秒,如果没有线程空闲 - 它会创建一个新线程。尽管如此,在重负载情况下,这种延迟可能会大大降低您的 Web 应用程序的速度。因此,不要违反线程池假设 - 不要在线程池线程上运行长时间的 CPU 密集型工作。


gle*_*bob 0

事实是,async/await“节省线程”的概念是事实和废话的混合体。确实,它通常并不涉及创建更多线程来服务特定任务,但它愉快地掩盖了这样一个事实:在幕后有许多线程在等待完成端口上的事件,这些事件是由运行时创建的。完成端口线程的数量大约是系统中处理器核心的数量。因此,在具有八个处理器核心的系统上,大约有八个线程等待 IO 完成事件。在一个疯狂使用异步 IO 的应用程序中,这很好,但在一个不执行太多 IO 的应用程序中,它们大多只是坐在那儿吃资源,而不是通过任何想象来“节省线程”。

当异步 IO 操作完成时,其中一个线程将“唤醒”并最终调用任何相关任务的延续。如果当另一个 IO 操作完成时所有完成线程都忙于执行延续(可能是因为开发人员犯了在延续中执行大量 CPU 密集型工作的错误),则在释放其中一个完成线程之前不会处理该完成起来并且能够处理它。这就是所谓的“线程饥饿”,这就是为什么建议在大量使用异步 IO 的应用程序中启动比处理器核心数量更多的线程。

.NET 和异步 IO 的问题以及异步 IO“节省线程”的笼统概念是,许多开发人员不了解幕后实际发生的情况,并且错误地使用异步/等待模式,从而导致线程挨饿。完成线程池太容易了。

无论如何,“无线程”这个术语在这里没有任何意义。

  • “许多开发人员不明白幕后实际发生了什么” - 是的,就像在线程池线程上安排的延续性事实一样,线程池线程通常比 CPU 拥有更多的线程...... (3认同)