【问题标题】:Why task cancellation takes so long in Flurl.Http DownloadFileAsync / HttpClient GetAsync为什么在 Flurl.Http DownloadFileAsync / HttpClient GetAsync 中取消任务需要这么长时间
【发布时间】:2022-03-02 18:20:37
【问题描述】:

在编写一个类以从服务器并行下载图像时(使用由DataFlow TPL 库支持的消费者/生产者模式),使用ActionBlockFlurl.Http 设施方法DownloadFileAsync,我已经意识到取消需要很长时间。由于所有下载操作都共享一个CancellationToken,我希望所有任务都会立即(或几乎)取消。实际上,如果我产生大量并行下载,取消所有任务/线程可能需要几分钟。我通过将ExecutionDataflowBlockOptions.MaxDegreeOfParallelism 属性设置为10 来解决这个问题。这样一来,在任何给定时间最多有10 个并发下载要取消(这仍然不是我期望的立即行动)。

我创建了一个控制台 .NET 5 程序,它可以单独重现问题(没有 DataFlow TPL、ActionBlock 等)。它首先询问并发下载的数量(默认按 Enter 键:即 100 次下载)。然后它使用Flurl.Http(使用HttpClient)并行生成所有这些下载,并将CancellationToken 传递给每个操作。然后它等待按键被按下,然后通过调用CancellationTokenSource.Cancel 方法取消挂起的下载。最后,它会打印一些统计信息,包括成功和失败/取消的下载次数,以及完成取消所用的时间。完整代码如下:

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Flurl.Http;

const string imageSource = "https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png";
const int defaultCount = 100;
var watch = Stopwatch.StartNew();
int completed = 0;
int failed = 0;

Console.WriteLine("Flurl.DownloadFileAsync Cancellation test!");
Console.Write($"Number of downloads ({defaultCount}): ");
var input = Console.ReadLine();
if (!int.TryParse(input, out var count)) 
    count = defaultCount;
Console.WriteLine($"Will spawn {count} parallel downloads of '{imageSource}'");

CancellationTokenSource cts = new CancellationTokenSource();
List<Task> tasks = new();
for (int i = 0; i < count; i++) 
    tasks.Add(Download(i));

Console.WriteLine("Hit anything to cancel...");
Console.ReadKey(true);

log("Cancelling pending downloads");
var cancelMoment = watch.Elapsed;
cts.Cancel();
Task.WaitAll(tasks.ToArray());
log("Downloads cancelled. Program ended!");

Console.WriteLine($"### Total cancellation time: {watch.Elapsed - cancelMoment} -> Completed: {completed}, Failed/Cancelled: {failed}");

async Task Download(int i) {
    var fn = $"test_{i}.png";
    try {
        await imageSource.DownloadFileAsync(fn, cancellationToken: cts.Token);
        Interlocked.Increment(ref completed);
        log($"DONE: {fn}");
    } catch(Exception e) {
        Interlocked.Increment(ref failed);
        log($"# ERROR: {fn}/r/n >> {e.Message}");
    }
}

void log(string s) => Console.WriteLine($"{watch.Elapsed}- {s}");

最让我印象深刻的是,允许所有下载完成(即使我输入 1000 次下载)比取消操作要快。我不知道是否发生了任何类型的死锁(这会导致操作在锁定超时后结束),或者这些下载的取消是否只是被破坏了。对于这个问题,我找不到很好的解释或解决方案。

要重现该问题,您必须在所有下载完成之前按一个键取消挂起的下载。如果你的时间正确,你可以让一些下载成功。如果你打得太快,你将取消所有下载。如果您等待的时间过长,所有下载都已经完成。

这次运行产生了以下结果:

取消 99 个待处理的操作花费了 55 多秒的时间。如果我只是让所有下载完成,它所花费的时间比取消相同操作所花费的时间要少很多。

更新


我已经完全删除了 Flurl 并直接使用 HttpClient 下载文件,问题仍然存在。我已将 Downlod 方法更改为以下内容:

async Task Download(int i) {
    var fn = $"test_{i}.png";
    try {
        var r = await client.GetAsync(imageSource, cancellationToken: cts.Token);
        using var httpStm = await r.Content.ReadAsStreamAsync(cts.Token);
        var file = new FileInfo(fn);
        using var f = file.OpenWrite();
        await httpStm.CopyToAsync(f, cts.Token);
        Interlocked.Increment(ref completed);
        log($"DONE: {fn}");
    } catch(Exception e) {
        Interlocked.Increment(ref failed);
        log($"# ERROR: {fn}/r/n >> {e.Message}");
    }
}

结果与基于 FLURL 的实现相同(毕竟,Flurl.Http 只是 HttpClient 的包装器)。

更新 2


我已将下载方法更改为简单地等待可取消的Task.Delay,现在取消 100 次操作的时间约为 2 秒。虽然它更快,但它不是瞬时的,并且根据屏幕上日志的时间,在我看来,取消是按顺序触发的,而不是并行/一次触发的。本次下载代码为:

async Task Download(int i) {
    var fn = $"test_{i}.png";
    try {
        await Task.Delay(TimeSpan.FromMinutes(1), cts.Token);
        Interlocked.Increment(ref completed);
        log($"DONE: {fn}");
    } catch (Exception e) {
        Interlocked.Increment(ref failed);
        log($"# ERROR: {fn}/r/n >> {e.Message}");
    }
}

以下屏幕截图显示了上面代码的结果:

有人对此有很好的解释或解决方案吗?

【问题讨论】:

  • 我猜在下载返回之前它实际上并没有检查令牌。也许您应该在调用DownloadAsync 之前检查令牌
  • 您能否检查是否将取消令牌添加到执行中,例如:Task.WaitAll(tasks.ToArray(), cts.Token); 是您想要的结果吗?
  • 在不登录控制台的情况下试一试,不要使用互锁增量。仅在任务启动时才尝试打印到控制台,然后在所有任务都停止时打印出来。看看最小化的输出是否会有所作为。
  • @quain 已经这样做了! :-) 看来 HttpClient 是罪魁祸首......它只是不能很好地“取消”......不过仍在研究。

标签: c# task-parallel-library dotnet-httpclient cancellationtokensource flurl


【解决方案1】:

我没有研究 Flurl.Http,但我认为取消没有得到足够的支持。如果您使用的是 .Net6,则可以使用 WaitAsync。如果没有,您可以创建一个任务,您可以取消该任务并使用 Task.WhenAny 中的下载任务等待此任务,如下所示:

await Task.WhenAny(new[] { cancellableTask, downloadTask })

缺点是我不知道如何真正取消与下载相关的工作。

【讨论】:

  • 问题正是取消下载。我可以取消令牌并忘记,但由于我必须优雅而快速地停止下载,遗憾的是异步等待对我没有用(虽然不知道 WaitAsync,但谢谢你:-))
  • 我正在更新问题,因为 vanilla HttpClient 和流 copyto 操作也会出现问题。
  • 我想知道stackoverflow.com/questions/16195694/… 是否适合您?
  • 该答案使用过时的WebClient。它也不会等待取消完成。这相当于只是在CancellationToken 上调用Cancel(),而不是真正等待下载被取消。我还意识到,与 Flurl 或 HttpClient 无关的其他可取消任务也会发生这种情况,因此我必须深入研究这一点,但感谢所有麻烦。
  • 这很奇怪。我唯一要改变的是我会在下载中传递取消令牌,而不是将 cts 放入闭包中。 Neverteless 我不认为这可以解释你所经历的行为。
【解决方案2】:

如果您将取消令牌单独传递给任务而不是父Task.WaitAll(),则在您调用cts.Cancel() 之后,其他任务将不知道取消请求,直到为每个任务调用Download() .

将令牌传递给父级,因此它就像所有任务的协调器:

Task.WaitAll(tasks.ToArray(), cts.Token);

然后,一旦取消触发,剩余的任务将不需要执行,从而节省您的取消时间。

Flurl.DownloadFileAsync Cancellation test!
Number of downloads (100):
Will spawn 100 parallel downloads of 'https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png'
Hit anything to cancel...
00:00:03.8603945- DONE: test_3.png
00:00:03.8603937- DONE: test_41.png
00:00:03.8603967- DONE: test_12.png
00:00:03.8603920- DONE: test_22.png
00:00:03.8640374- DONE: test_27.png
00:00:03.8651895- DONE: test_57.png
00:00:03.8652114- DONE: test_64.png
00:00:03.8652039- DONE: test_1.png
00:00:03.8652149- DONE: test_10.png
00:00:03.8652222- DONE: test_0.png
00:00:03.8653910- DONE: test_2.png
00:00:03.8865928- DONE: test_28.png
00:00:03.8990767- DONE: test_11.png
00:00:03.8993285- DONE: test_20.png
00:00:03.9048105- DONE: test_6.png
00:00:03.9056048- DONE: test_68.png
00:00:03.9067027- DONE: test_69.png
00:00:03.9075434- DONE: test_54.png
00:00:03.9094678- DONE: test_5.png
00:00:03.9166961- DONE: test_50.png
00:00:03.9169709- DONE: test_13.png
00:00:03.9185492- DONE: test_87.png
00:00:03.9298495- DONE: test_7.png
00:00:03.9326280- DONE: test_43.png
00:00:03.9327424- DONE: test_33.png
00:00:03.9357554- DONE: test_45.png
00:00:03.9363720- DONE: test_37.png
00:00:03.9386855- DONE: test_30.png
00:00:03.9393222- DONE: test_95.png
00:00:03.9427672- DONE: test_14.png
00:00:03.9463875- DONE: test_84.png
00:00:03.9487063- DONE: test_34.png
00:00:03.9488809- DONE: test_80.png
00:00:03.9493661- DONE: test_90.png
00:00:03.9533510- DONE: test_17.png
00:00:03.9550988- DONE: test_85.png
00:00:03.9559576- DONE: test_71.png
00:00:03.9559739- DONE: test_31.png
00:00:03.9581015- DONE: test_52.png
00:00:03.9595559- DONE: test_89.png
00:00:03.9596396- DONE: test_61.png
00:00:03.9604608- DONE: test_36.png
00:00:03.9605789- DONE: test_26.png
00:00:03.9643205- DONE: test_25.png
00:00:03.9708155- DONE: test_18.png
00:00:03.9712862- DONE: test_39.png
00:00:03.9780414- DONE: test_98.png
00:00:03.9782002- DONE: test_42.png
00:00:03.9788898- DONE: test_48.png
00:00:03.9844171- DONE: test_15.png
00:00:03.9856963- DONE: test_16.png
00:00:03.9862520- DONE: test_96.png
00:00:03.9923971- DONE: test_58.png
00:00:03.9944836- DONE: test_59.png
00:00:04.0070565- DONE: test_40.png
00:00:04.0093207- DONE: test_29.png
00:00:04.0119729- DONE: test_55.png
00:00:04.0148981- DONE: test_24.png
00:00:04.0163724- DONE: test_32.png
00:00:04.0173879- DONE: test_9.png
00:00:04.0191403- DONE: test_46.png
00:00:04.0426426- DONE: test_23.png
00:00:04.0549689- DONE: test_81.png
00:00:04.0550977- DONE: test_4.png
00:00:04.0554659- DONE: test_63.png
00:00:04.1206750- DONE: test_21.png
00:00:04.1207557- DONE: test_44.png
00:00:04.1773007- DONE: test_65.png
00:00:04.1854760- DONE: test_62.png
00:00:04.1854846- DONE: test_38.png
00:00:04.1883149- Cancelling pending downloads
00:00:04.2348932- # ERROR: test_19.png/r/n >> A task was canceled.
00:00:04.2757044- # ERROR: test_35.png/r/n >> A task was canceled.
00:00:04.2757328- # ERROR: test_8.png/r/n >> A task was canceled.
00:00:04.2759605- # ERROR: test_56.png/r/n >> A task was canceled.
00:00:04.2763499- Downloads cancelled. Program ended!
### Total cancellation time: 00:00:00.0954826 -> Completed: 70, Failed/Cancelled: 4
00:00:04.2845926- # ERROR: test_70.png/r/n >> A task was canceled.

请注意,取消时间要小得多,因为我们只需要取消当时正在执行的任务,而不是所有任务自己都收到取消通知。我们完成了 70 个任务,其中 4 个被取消,剩下的刚刚被丢弃,因此取消时间大大加快。

【讨论】:

  • 如果我这样做,我将取消 WaitAll,每个任务仍将运行,WaitAll 本身将被取消,这不是我需要的。这与在 CancelatioToken 上调用 Cancel() 并且不等待完成相同。发生这种情况的原始场景是在具有无限并行性的 DataFlow 管道的 ActionBlock 内。
  • 我奖励你从赏金中获得 50 分,即使它没有完全回答问题。我认为 HttpClient 中的任务取消存在问题:它不能很好地取消。也就是说,我已经将调用包装在一个 Task.Run 中,它本身接收取消令牌,这样如果我提前取消它,这个过程甚至不会开始。我还将并行度限制为 5,以防止线程池饥饿。无论如何感谢您的努力!
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2022-01-08
  • 1970-01-01
  • 2012-08-31
  • 1970-01-01
  • 2011-08-27
  • 2011-12-07
相关资源
最近更新 更多