在C#中是否可能创建自己的非阻塞异步任务?

6
许多 C# 中内置的 IO 函数是非阻塞的,也就是说它们在等待操作完成时不会锁住线程。例如,返回一个 Task<string[]>System.IO.File.ReadAllLinesAsync 就是非阻塞的。它不仅会暂停使用的线程,还会释放线程以便其他进程可以使用它。我猜这是通过调用操作系统来实现的,这样操作系统就可以在没有程序浪费线程等待的情况下检索文件并回调到程序中。
你能否自己创建一个非阻塞的异步任务呢?像 Thread.sleep() 这样的操作显然不会像 System.IO.File.ReadAllLinesAsync 那样释放当前线程。我知道睡眠线程不会占用 CPU 资源,但它仍然占用一个线程,这可能在处理众多请求的 Web 服务器中成为问题。我的问题不是如何一般地生成任务,而是关于处理文件/网络调用的内置 C# 函数在等待时如何释放它们的线程。

3
创建一个需要等待某些外部事件的任务时,通常会使用 TaskCompletionSource。你必须等待某个东西。 - Klaus Gütter
我快速搜索了“C# task io”,第二个结果是微软的这篇不错的文章,它有帮助吗? - JHBonarius
@JHBonarius 是的,看起来这是一个比较深入的主题,通常情况下你不需要处理它,因为内置函数已经处理了。知道有多种实现任务的方式是很好的,其中并不是所有方式都会创建一个线程。谢谢! - markv12
1
你可能想看一下这个:为什么File.ReadAllLinesAsync()会阻塞UI线程?。有时现实并不符合我们的期望。 - Theodor Zoulias
4个回答

2
基本上,每个释放线程的异步函数最终都编译成一个回调函数,通常由操作系统执行。
在现代术语中,这种风格通常被称为“Promise”,但它一直是所有良好操作系统的一部分。一般的方法是取一个回调函数并注册它,然后开始某种操作。当操作完成时,就会调用回调函数。
这一过程一直延伸到处理器级别,其中IO设备发出中断信号,该信号通过OS内核、内核模式驱动程序、用户模式驱动程序,最终传递到某个应用程序线程正在等待的某种等待句柄(例如窗口消息或异步IO)。
让我们深入了解其中一个主要示例,看看它是如何完成的。我们将浏览 .NET Github 主要存储库以及MSDN 上的 Win32 文档。类似的原则适用于大多数现代操作系统。我假设您已经有了基本 IO 操作和现代 PC 的基本组件的相当了解。
大容量 IO 类,例如FileStreamSocketPipeStreamSerialPort 这些使用非常相似的方法。让我们只看FileStream
浏览源代码,它使用了一个叫做 AsyncWindowsFileStreamStrategy 的类,而该类又利用了一个名为 Overlapped IO 的 Win32 API。最终通过回调函数传递给 ThreadPoolBoundHandle.AllocateNativeOverlapped,并将得到的 OVERLAPPED 结构体传递给 Win32 API,如 ReadFileEx。
我们没有Win32的源代码,但从一个一般的层面来说,这些函数会调用Kernel32ntdll的API。这些API进入内核模式,在那里文件系统驱动程序将数据传递给磁盘驱动程序。
大多数批量IO硬件(如驱动器和网络适配器)使用的系统是直接内存访问。驱动程序只需告诉硬件在RAM中放置数据的位置。硬件直接加载数据到RAM,完全绕过CPU。
然后它向CPU发出中断信号,CPU停止正在执行的操作,并将控制权转移到内核的中断处理程序。然后将控制权传递回驱动程序链,返回到用户模式,最终应用程序中的回调已准备就绪。
什么在应用程序中接收回调?ThreadPool类(这里是本机版本),它使用IO完成端口(用于将许多IO回调合并为单个句柄以等待)。我们应用程序中的本机级线程不断循环调用GetQueuedCompletionStatus,如果没有可用内容则阻塞。一旦它返回,相关的回调就会触发,一直传递到我们的FileStream,最终继续我们离开的地方的函数,稍后将看到。
这可能取决于我们如何设置同步上下文,可能会或可能不会在我们原始的本地线程上。如果我们需要将回调传递到UI线程,则通过窗口消息完成。

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

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

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

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

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

如果没有更多的线程需要运行,处理器就会进入低功耗的“HALT”状态,并在下一个中断信号唤醒。

Taskasync/await

这是一个非常大的话题,我大部分时间都会交给其他人处理。但回到我最初的前提,释放线程会触发操作系统级别的回调:Task是如何做到这一点的?

首先,我们已经犯了一个错误。线程和任务是不同的东西。一个线程只能被内核挂起,而一个任务只是我们想要完成的工作单位,我们可以根据需要随时拿起和放下。

当在最深层级别(我们想要暂停执行的点)遇到await时,任何回调都会像上面提到的那样被注册。当被调用时,回调函数将把Task的继续代码排队到调度程序以供执行。Task 利用CLR设置的现有调度程序 来拾取和丢弃任务和继续。 最后,TaskScheduler是实现如何安排Task的逻辑的类:它们应该通过ThreadPool执行吗?它们应该返回到UI线程,甚至只是在循环中直接执行吗?

不明白为什么这个答案只有一个赞。它对我帮助很大。 - Yarek T

2

针对I/O绑定的任务

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

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

您可以在System.IO.File.ReadAllLinesAsync方法的参考源代码中看到一个示例。

对于CPU密集型任务

System.Threading.Tasks命名空间中的Task类应该提供您所需的功能。您可以使用它来创建一个Task对象,以运行您想要实现的任何进程。例如,如果您有一个需要很长时间才能执行的方法int LongRunner,并且您希望以异步方式访问它,则可以定义Task<int> 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.Run(...)提供更多特定功能时才使用此方法。

请参阅Microsoft文档页面。


很好的答案,特别是对文档的引用。仍然有很多缺失,比如async/await、线程等。Task.Run并不总是最好的解决方案。此外,你需要等待任务完成(否则可能会丢失抛出的异常等)。 - JHBonarius
1
我知道任务的一般工作原理。我说的是一个任务不会在等待某些东西(文件/网络等)时占用线程的特定情况。 你提供的所有示例都涉及运行CPU密集型代码的任务。我说的是一种情况,你想等待外部进程完成而不占用线程。许多内置的C#函数可以做到这一点,但我不知道如何实现。我想知道是否可能复制那种行为。 - markv12
@markv12,请查看我对这个答案的更新以及Async In Depth指南中有关更多信息的此部分 - bisen2
@JHBonarius 我完全同意,这个答案(或任何SO答案)所涵盖的内容远不止于此。我已经更新了一些关于IO绑定任务的信息,其中Task.Run可能不是最佳解决方案。我觉得async/await和等待任务完成有点超出了这个问题的范围,但如果你认为答案会受益于它,请随意添加。 - bisen2

1

评论中似乎有相当多的讨论,但我不确定它们是否按您想要的方式回答了问题,因此我会尽力而为。

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

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

您问了这个问题,但您已经说过'我认为这是通过以使操作系统回调程序的方式调用操作系统来实现的'。您自己回答了这个问题。这些内置操作正在执行调用,将其交给操作系统,并在操作系统完成后接收操作系统的警报。

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

带有回调的异步

想象一下函数大致如下:

public void CallFoo(Action finishedCallback);

您希望能够这样调用它:

public Task CallFoo();

我会将其定义为如下内容(保留HTML标记):

我会定义它类似于这样:

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);
    }
}

这种方法不太理想,因为你只是在轮询以查看是否完成,但它使用的资源比进行线程休眠要少得多。明显的缺点是它不能实时响应异步操作的完成。您可以根据需要降低/增加延迟。

1

当您调用 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代码。没有什么特别的。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接