【问题标题】:Is it possible to create your own non-blocking asynchronous task in C#是否可以在 C# 中创建自己的非阻塞异步任务
【发布时间】:2021-03-28 14:37:35
【问题描述】:

C# 中的许多内置 IO 函数都是非阻塞的,也就是说,它们在等待操作完成时不会保留线程。

例如,返回Task<string[]>System.IO.File.ReadAllLinesAsync 是非阻塞的。

它不只是暂停它正在使用的线程,它实际上释放了线程以便其他进程可以使用它。

我假设这是通过调用操作系统来完成的,这样操作系统在检索文件时调用回程序,而程序不必浪费线程等待它。

是否可以自己创建非阻塞异步任务?

执行Thread.sleep() 之类的操作显然不会像System.IO.File.ReadAllLinesAsync 那样释放当前线程。

我意识到休眠线程不会占用 CPU 资源,但它仍会占用一个线程,这在处理大量请求的 Web 服务器中可能是个问题。

我不是在谈论如何生成任务。我说的是用于处理文件/网络调用的内置 C# 函数如何在它们等待时释放它们的线程。

【问题讨论】:

  • 你必须等待一些事情。当创建一个应该等待一些外部事件的任务时,这通常是一个 TaskCompletionSource。
  • 我在 Google 上快速搜索了“C# task io”,第二次点击是 this nice article by Microsoft 有帮助吗?
  • @JHBonarius 是的,看起来这是一个非常深奥的话题,您通常不必处理它,因为内置函数会处理它。很高兴知道有多种方法可以实现一个任务,并不是所有的方法都创建一个线程。谢谢!
  • 您可能想看看这个:Why File.ReadAllLinesAsync() blocks the UI thread?。有时现实并不符合我们的预期。

标签: c# asynchronous async-await


【解决方案1】:

对于 IO 绑定任务

对于 IO 绑定任务,您可以简单地定义一个 Task<T> 类型的方法,并在该方法中返回您的 T 类型的值。例如,如果您有一个方法string getHTML(string url),您可以像这样异步调用它:

public async Task<string> getHTMLAsync(string url) {
    return getHTML(url)
}

您可以在reference source for the System.IO.File.ReadAllLinesAsync method. 中查看此示例

对于 CPU 密集型任务

System.Threading.Tasks 命名空间中的 Task 类应该提供您正在寻找的功能。您可以使用它来创建一个Task 对象来运行您想要实现的任何进程。例如,如果您有一个方法int LongRunner 需要很长时间才能执行,并且您希望异步访问它,您可以定义Task&lt;int&gt; LongRunnerAsync

public Task<int> LongRunnerAsync() {
    return Task.Run( () => LongRunner() );
}

有几种方法可以定义您的自定义Task

  • 使用Task.Run(...) 方法定义Task。这是我定义Task 的默认方法,因为它很容易编写并立即启动Task。你可以通过调用来做到这一点:
Task.Run( () => {
    doWork();
}
  • 定义Task 以使用构造函数运行预定义的操作。这允许您定义一个不会立即启动的Task。这可以通过以下方式完成:
Action action = () => doWork();
Task task = new Task(action);
task.Start();
  • 使用Task.Factory.StartNew(...) 方法定义Task。此方法允许比Task.Run(...) 更多的自定义,但提供类似的功能。如果有特定原因需要此方法,我只建议使用此方法Task.Run(...)

Microsoft's documentation page.

【讨论】:

  • 不错的答案,尤其是对文档的引用。仍然缺少很多,例如 async/await、线程等。Task.Run 并不总是最好的解决方案。另外,您需要等待任务完成(或丢失可能抛出的异常等)
  • 我知道任务的一般工作方式。我说的是在等待某些东西(文件/网络/等...)时不保留其线程的任务的特定情况您提供的所有示例均指运行 CPU 密集型代码的任务。我说的是您想要等待外部进程完成而不占用线程的情况。许多内置的 C# 函数都这样做,但我不知道如何。我想知道是否可以复制这种行为。
  • @markv12 看看我对这个答案的更新和this section of the Async In Depth guide for more info
  • @JHBonarius 我完全同意,除了这个答案所说的(或任何 SO 答案都可以涵盖)之外,还有很多其他内容。我已经用 Task.Run 不是最佳解决方案的 IO 绑定任务的一些信息对其进行了更新。我觉得 async/await 和等待任务完成有点超出这个问题的范围,但如果你觉得答案会从中受益,请随意添加。
【解决方案2】:

在 cmets 中似乎对此进行了大量讨论,但我不确定他们中是否有人按照您的要求回答了,所以我会尽力而为。

目前,我通常可以想到两种方法,即在没有任务的情况下调用异步方法。这些通常是旧的 API(例如 SqlCommand.BeginExecuteNonQuery),已经被基于任务的调用所取代。如果您有更具体的场景,这将有助于提供更好的示例。

我说的是用于处理文件/网络调用的内置 C# 函数如何在等待时释放线程。

你问这个,但你已经说过'我假设这是通过调用操作系统来完成的,操作系统调用回程序'。你有点回答了你自己的问题。这些内置操作正在执行移交给操作系统的调用,并在它们完成时得到操作系统的警报。

在我的示例中,假设CallFoo 正在调用某种操作系统操作来处理所有操作。操作系统调用方式的实际实现对您来说并不重要,但如果您想了解更多信息,可以查看从 C# 调用 windows 内核。

与回调异步

想象一下这个函数看起来像这样:

public void CallFoo(Action finishedCallback);

而且你希望能够做到,所以你可以这样称呼它:

public Task CallFoo();

我会这样定义它:

public Task CallFoo()
{
    var taskCompletionSource = new TaskCompletionSource();

    // Calls to the API that has a non blocking IO call but no async Task API
    CallFoo(() =>
    {
        // Callback is called when the IO task has finished.
        // SetResult will mark the returned Task as complete
        taskCompletionSource.SetResult();
    });

    return taskCompletionSource.Task;
}

与句柄异步

我认为它工作的另一种方式是使用某种返回的“句柄”来指定异步任务是否已完成。

该方法可能如下所示:

public IAsyncHandle CallFoo();

在这种情况下,我会像这样实现它:

public async Task CallFoo()
{
    var handle = CallFoo();

    while (!handle.IsCompleted)
    {
        await Task.Delay(100);
    }
}

这不太理想,因为您只是轮询以查看它是否完成,但它确实比执行 thread.sleep 使用的资源少得多。明显的缺点是它并没有真正实时地对异步动作完成做出反应。您可以根据需要降低/增加延迟。

【讨论】:

    【解决方案3】:

    这是调用System.IO.File.ReadAllLinesAsync时运行的代码:

    private static async Task<string[]> InternalReadAllLinesAsync(string path, Encoding encoding, CancellationToken cancellationToken)
    {
        using StreamReader sr = AsyncStreamReader(path, encoding);
        cancellationToken.ThrowIfCancellationRequested();
        List<string> lines = new List<string>();
        string item;
        while ((item = await sr.ReadLineAsync().ConfigureAwait(continueOnCapturedContext: false)) != null)
        {
            lines.Add(item);
            cancellationToken.ThrowIfCancellationRequested();
        }
        return lines.ToArray();
    }
    

    这只是普通的async 东西。如果您深入了解.ReadLineAsync(),这一切都只是async 代码。没什么特别的。

    【讨论】:

      【解决方案4】:

      从根本上说,每个释放线程的异步函数最终都会编译为回调,通常由操作系统执行。

      在现代术语中,这种风格通常被称为Promise,但它自古以来就是所有优秀操作系统的一部分。一般的方法是获取一个回调函数并注册它,然后开始某种操作。当操作完成时,回调被调用。

      这一直到处理器级别,其中 IO 设备发出一条中断线信号,该中断线馈送到 OS 内核、内核模式驱动程序、用户模式驱动程序,最后是应用程序的某种等待句柄线程正在等待(例如窗口消息或异步 IO)。


      让我们深入研究一个主要示例,看看它是如何完成的。我们将通过the main .NET Github repoWin32 docs on MSDN类似的原则适用于大多数现代操作系统。我假设我已经对基本 IO 操作和现代 PC 的基本组件有相当了解。

      批量 IO 类如FileStreamSocketPipeStreamSerialPort

      这些使用非常相似的方法。让我们看看FileStream

      查看源代码,it utilizes 一个名为 AsyncWindowsFileStreamStrategy 的类,该类又使用名为 Overlapped IO 的 Win32 API。它最终通过一个回调函数传递给ThreadPoolBoundHandle.AllocateNativeOverlapped,并将生成的OVERLAPPED struct 传递给Win32 API,例如ReadFileEx

      我们没有 Win32 的源代码,但总的来说,这些函数将调用 Kernel32ntdll API。这些依次进入内核模式,文件系统驱动程序传递给磁盘驱动程序。

      大多数大容量 IO 硬件(如驱动器和网络适配器)使用的系统是 Direct Memory Access。驱动程序只会告诉硬件在 RAM 中放置数据的位置。硬件直接将数据加载到 RAM,完全绕过 CPU。

      然后它向 CPU 发出一条中断线信号,CPU 停止正在执行的操作并将控制权转移到内核的中断处理程序。然后将控制权转移到驱动程序链上,返回到用户模式,最终应用程序中的回调准备就绪。

      什么在应用程序中获取回调? ThreadPool 类(native version, which is here),它使用 IO Completion Port(用于将大量 IO 回调合并到一个手柄等待)。我们应用程序中的本机级别线程不断循环调用GetQueuedCompletionStatus,如果没有可用的内容,则阻塞。一旦它返回,相关的回调就会被触发,它会一直反馈到我们的 FileStream 并最终继续我们离开的函数,稍后会看到。

      这可能在我们原来的原生线程上,也可能不在,这取决于我们如何设置SynchronizationContext如果我们需要编组一个回调到 UI 线程,这可以通过窗口消息。


      等待句柄,例如 ManualResetEventSemaphoreReaderWriterLock,以及经典的窗口消息传递

      这些完全阻塞调用线程,它们不能直接与async/await 一起使用,因为它们完全依赖于 Win32 线程模型。但是整个模型有点类似于Task:您可以等待一个事件或多个事件,并在需要时分派您的回调。其中有一些与async/await 兼容的单独版本。

      等待事件本质上是对内核的调用,表示“请暂停我的线程,直到某某发生。”

      本地操作系统线程被挂起时会发生什么?

      本机操作系统线程在处理器内核上持续运行。 Win32 内核调度程序设置硬件处理器计时器以中断线程并让步给可能需要运行的其他线程。在任何时候,如果本机线程被 Win32 调度程序挂起(无论是在询问时还是因为调度程序产生),它都会从可运行线程队列中删除。一旦线程准备好再次运行,它就会被放入可运行队列中,并在调度程序有机会时运行。

      如果没有更多线程可以运行,处理器将进入低功耗HALT,并在下一个中断信号时被唤醒。


      Taskasync/await

      这是一个非常大的话题,我主要会留给其他人。但是回到我最初的前提,即释放线程会触发操作系统级别的回调:Task 是如何做到这一点的?

      首先,我们已经犯了一个错误。线程和任务是不同的东西。 线程只能由内核挂起,任务只是我们想要完成的一个工作单元,我们可以根据需要拾取和丢弃。

      await 在最深层次(我们想要暂停执行的点)被击中时,任何回调都会像我们上面提到的那样注册。调用时,回调函数会将Task 的延续代码排队到调度程序执行。 Taskutilizes the existing scheduler 由 CLR 设置,用于根据需要拾取和删除任务和延续。

      最后,TaskScheduler 是实现如何调度Tasks 的逻辑的类:它们应该通过ThreadPool 执行吗?是否应该将它们编组回 UI 线程,或者甚至只是在循环中内联执行?

      【讨论】:

        猜你喜欢
        • 2019-01-24
        • 1970-01-01
        • 1970-01-01
        • 2012-09-29
        • 2013-05-07
        • 1970-01-01
        • 2011-02-22
        • 2018-04-06
        • 2014-04-19
        相关资源
        最近更新 更多