为什么异步等待任务中的同步代码比异步代码慢得多

6

我因为无聊而从维基百科上检索随机文章玩了一段时间。首先我写了这段代码:

private async void Window_Loaded(object sender, RoutedEventArgs e)
{
    await DownloadAsync();
}

private async Task DownloadAsync()
    {
        Stopwatch sw = new Stopwatch();
        sw.Start();
        var tasks = new List<Task>();
        var result = new List<string>();

        for (int index = 0; index < 60; index++)
        {
            var task = Task.Run(async () => {
                var scheduledAt = DateTime.UtcNow.ToString("mm:ss.fff");
                using (var client = new HttpClient())
                using (var response = await client.GetAsync("https://en.wikipedia.org/wiki/Special:Random"))
                using (var content = response.Content)
                {
                    var page = await content.ReadAsStringAsync();
                    var receivedAt = DateTime.UtcNow.ToString("mm:ss.fff");
                    var data = $"Job done at thread: {Thread.CurrentThread.ManagedThreadId}, Scheduled at: {scheduledAt}, Recieved at: {receivedAt} {page}";
                    result.Add(data);
                }
            });

            tasks.Add(task);
        }

        await Task.WhenAll(tasks.ToArray());

        sw.Stop();
        Console.WriteLine($"Process took: {sw.Elapsed.Seconds} sec {sw.Elapsed.Milliseconds} ms");

        foreach (var item in result)
        {
            Debug.WriteLine(item);
        }
    }

但是我想要摆脱这个异步匿名方法:Task.Run(async () => ...,因此我将代码的相关部分替换为以下内容:

for (int index = 0; index < 60; index++)
{
    var task = Task.Run(() => {
        var scheduledAt = DateTime.UtcNow.ToString("mm:ss.fff");
        using (var client = new HttpClient())
        // Get this synchronously.
        using (var response = client.GetAsync("https://en.wikipedia.org/wiki/Special:Random").Result)
        using (var content = response.Content)
        {
            // Get this synchronously.
            var page = content.ReadAsStringAsync().Result;
            var receivedAt = DateTime.UtcNow.ToString("mm:ss.fff");
            var data = $"Job done at thread: {Thread.CurrentThread.ManagedThreadId}, Scheduled at: {scheduledAt}, Recieved at: {receivedAt} {page}";
            result.Add(data);
        }
    });

    tasks.Add(task);
}

我原以为,将异步代码替换为同步代码时,程序的表现应该完全一样,因为我已经在任务中封装了异步代码。这样,我可以确保任务调度程序(WPF任务调度程序)会将其排队到ThreadPool的某个空闲线程上。正如我所看到的返回结果一样:

Job done at thread: 6, Scheduled at: 53:57.534, Recieved at: 54:54.545 ...
Job done at thread: 21, Scheduled at: 54:06.742, Recieved at: 54:54.574 ...
Job done at thread: 41, Scheduled at: 54:26.742, Recieved at: 54:54.576 ...
Job done at thread: 10, Scheduled at: 53:59.018, Recieved at: 54:54.614 ...

问题在于第一段代码需要执行约6秒钟,而第二段同步的.Result则需要约50秒钟。当任务数量减少时,差异变得更小。有没有人能解释为什么它们需要这么长时间,即使它们在单独的线程上执行并且执行完全相同的单个操作?


3
最终,在未完成的任务上触及.Result会导致未定义行为,你绝不应该这样做;它可能被卡在同步上下文中,也可能在等待线程池增长(从内存中最多每秒一个) - 因为所有现有的线程池线程都正在阻塞等待.Result,谁知道会发生什么? - Marc Gravell
1
@MarcGravell,您能否详细说明一下或提供一些文档/文章的链接?我不知道在任务内等待异步代码会导致未定义的行为。 - FCin
1
HttpClient不应该被放置在using语句中或者被多次实例化,除非绝对必要。请参考"YOU'RE USING HTTPCLIENT WRONG AND IT IS DESTABILIZING YOUR SOFTWARE" - ProgrammingLlama
@FCin 我不知道有没有 - 而且文档中也没有明确说明 - 但是:这仍然是真的。关键风险在于,在涉及同步上下文的情况下,使用 .Result 通常会导致死锁。您在同步上下文线程上,并且阻塞等待 .Result;当值可用时,完成尝试发生,需要通过同步上下文进行 - 这会阻塞等待同步线程。这已经被阻塞等待 .Result - Marc Gravell
1
@john 谢谢,这段代码只是为了好玩。我主要想写一些异步的东西,而且 HttpClient 是我首先想到的。 - FCin
显示剩余10条评论
1个回答

6
由于线程池在请求新线程时可能会引入延迟,如果池中的总线程数高于可配置的最小值,则应该设置这个最小值,默认情况下为核心数。在带有 .Result 的示例中,您将排队 60 个任务,它们都会占用线程池线程的整个执行时间。这意味着只有 number of cores 个任务会立即开始,其余任务将延迟启动(线程池将等待一定时间,以便已经忙碌的线程变得可用,如果没有,则添加新线程)。
更糟糕的是 - client.GetAsync 的继续函数(在收到服务器回复后在 GetAsync 函数内部执行的代码)也被安排在线程池线程上。这会阻塞您的所有 60 个任务,因为它们在获取 GetAsync 的结果之前无法完成,并且 GetAsync 需要空闲的线程池线程来运行其继续函数。结果,存在一个额外的争用:您创建了 60 个任务,而 GetAsync 的 60 个继续函数也需要线程池线程运行(而您的 60 个任务则被阻塞等待这些继续函数的结果)。
在带有 await 的示例中 - 线程池线程被释放,以便进行异步 http 调用。因此,当您调用 await GetAsync() 并且该 GetAsync 到达异步 IO 点(实际上发出 http 请求)时 - 您的线程会被释放回池中。现在它可以自由处理其他请求。这意味着 await 示例的线程池线程使用时间更短,并且在等待线程池线程可用时(几乎)没有延迟。
您可以轻松地通过执行以下操作进行确认(请勿在实际代码中使用,仅供测试):
ThreadPool.SetMinThreads(100, 100);

要增加上面提到的池中可配置的线程最小数量。当您将其增加到较大值时-针对带有.Result的60个任务的所有任务将同时在60个线程池线程上启动,没有延迟,因此您的两个示例将在大致相同的时间内完成。

这里是一个示例应用程序,以观察它如何工作:

public class Program {
    public static void Main(string[] args) {
        DownloadAsync().Wait();
        Console.ReadKey();
    }

    private static async Task DownloadAsync() {
        Stopwatch sw = new Stopwatch();
        sw.Start();
        var tasks = new List<Task>();
        for (int index = 0; index < 60; index++) {
            var tmp = index;
            var task = Task.Run(() => {
                ThreadPool.GetAvailableThreads(out int wt, out _);
                ThreadPool.GetMaxThreads(out int mt, out _);
                Console.WriteLine($"Started: {tmp} on thread {Thread.CurrentThread.ManagedThreadId}. Threads in pool: {mt - wt}");
                var res = DoStuff(tmp).Result;
                Console.WriteLine($"Done {res} on thread {Thread.CurrentThread.ManagedThreadId}");
            });

            tasks.Add(task);
        }

        await Task.WhenAll(tasks.ToArray());

        sw.Stop();
        Console.WriteLine($"Process took: {sw.Elapsed.Seconds} sec {sw.Elapsed.Milliseconds} ms");
    }

    public static async Task<string> DoStuff(int i) {
        await Task.Delay(1000); // web request
        Console.WriteLine($"continuation of {i} on thread {Thread.CurrentThread.ManagedThreadId}"); // continuation
        return i.ToString();
    }
}

你是对的。它需要相同的时间。我以为线程池会自动增加可用线程的数量。但问题是,为什么异步版本能够工作呢? - FCin
@FCin 我已经扩展了答案,提供了更多信息和示例应用程序。 - Evk
@Evk 谢谢,我理解问题了,但是看看它的行为还是很好的 :) - FCin
@FCin,你最后的评论显示你还没有完全理解它。GetAsync().Result<当前线程正在忙碌中。它什么有用的事情都不做,但是它很忙,无法用于其他工作。它在等待结果。await GetAsync()<当前线程是空闲的,可以用于其他工作。当GetAsync将来提供结果时,线程池中的另一个线程(如果发生这种情况,可以是相同的线程)将运行方法的其余部分。因此,使用GetAsync().Result会使许多线程忙于无事可做,这对线程池来说是不好的,因为它设计用于短时间任务。 - Evk
@Evk 是的,我的意思是单个线程在线程池中的线程完成工作并变得可用后,会获取结果。 - FCin
显示剩余8条评论

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