如何限制并发异步I / O操作的数量?


115
// let's say there is a list of 1000+ URLs
string[] urls = { "http://google.com", "http://yahoo.com", ... };

// now let's send HTTP requests to each of these URLs in parallel
urls.AsParallel().ForAll(async (url) => {
    var client = new HttpClient();
    var html = await client.GetStringAsync(url);
});

这是问题所在,它会同时启动1000多个Web请求。有没有简单的方法来限制这些异步http请求的并发数量?这样,在任何给定时间下载的网页都不会超过20个。如何以最有效的方式做到这一点?


2
这与您之前的问题有何不同?
svick


4
@ChrisDisley,这只会并行化请求的启动。
支出者2012年

@svick是正确的,有什么不同?顺便说一句,我喜欢那里的答案stackoverflow.com/a/10802883/66372
eglasius

3
除了HttpClientis IDisposable,您应该处理它,尤其是当您要使用1000多个它们时。HttpClient可以用作多个请求的单例。
Shimmy Weitzhandler,2015年

Answers:


161

您绝对可以使用.NET 4.5 Beta在最新版本的.NET异步中执行此操作。上一则来自'usr'的文章指出了Stephen Toub撰写的一篇不错的文章,但鲜为人知的消息是异步信号量实际上已将其纳入.NET 4.5 Beta版中

如果您看一下我们钟爱的SemaphoreSlim类(您应该使用它,因为它比原始类性能更好Semaphore),它现在拥有WaitAsync(...)一系列重载,带有所有预期的参数-超时间隔,取消令牌,所有您通常的计划朋友: )

Stephen's还撰写了一篇有关Beta版发布的新.NET 4.5插件的最新博客文章,请参见.NET 4.5 Beta中的并行性新功能

最后,这是一些有关如何使用SemaphoreSlim进行异步方法限制的示例代码:

public async Task MyOuterMethod()
{
    // let's say there is a list of 1000+ URLs
    var urls = { "http://google.com", "http://yahoo.com", ... };

    // now let's send HTTP requests to each of these URLs in parallel
    var allTasks = new List<Task>();
    var throttler = new SemaphoreSlim(initialCount: 20);
    foreach (var url in urls)
    {
        // do an async wait until we can schedule again
        await throttler.WaitAsync();

        // using Task.Run(...) to run the lambda in its own parallel
        // flow on the threadpool
        allTasks.Add(
            Task.Run(async () =>
            {
                try
                {
                    var client = new HttpClient();
                    var html = await client.GetStringAsync(url);
                }
                finally
                {
                    throttler.Release();
                }
            }));
    }

    // won't get here until all urls have been put into tasks
    await Task.WhenAll(allTasks);

    // won't get here until all tasks have completed in some way
    // (either success or exception)
}

最后,但值得一提的是使用基于TPL的计划的解决方案。您可以在TPL上创建尚未启动的委托绑定任务,并允许自定义任务计划程序限制并发性。实际上,这里有一个MSDN示例:

另请参阅TaskScheduler


3
有限并行度的parallel.foreach不是更好的方法吗?msdn.microsoft.com/en-us/library/…–
GreyCloud

2
你为什么不处理你HttpClient
摆振Weitzhandler

4
@GreyCloud:Parallel.ForEach与同步代码一起使用。这使您可以调用异步代码。
Josh Noe

2
@TheMonarch 你错了。此外,将所有IDisposables 包装在usingtry-finally语句中并确保将其丢弃总是一个好习惯。
Shimmy Weitzhandler '17

29
考虑到此答案的受欢迎程度,值得指出的是,HttpClient可以并且应该是单个公共实例,而不是每个请求的实例。
罗珀特·罗恩斯利

15

如果您有一个IEnumerable(即URL的字符串),并且希望同时对它们中的每一个进行I / O绑定操作(即,发出异步http请求),并且还可以选择设置最大并发数实时的I / O请求,这是您可以执行的操作。这样,您无需使用线程池等方法,该方法使用信号量来控制最大并发I / O请求,类似于一个滑动窗口模式,一个请求完成,离开信号量,下一个进入。

用法:等待ForEachAsync(urlStrings,YourAsyncFunc,optionalMaxDegreeOfConcurrency);

public static Task ForEachAsync<TIn>(
        IEnumerable<TIn> inputEnumerable,
        Func<TIn, Task> asyncProcessor,
        int? maxDegreeOfParallelism = null)
    {
        int maxAsyncThreadCount = maxDegreeOfParallelism ?? DefaultMaxDegreeOfParallelism;
        SemaphoreSlim throttler = new SemaphoreSlim(maxAsyncThreadCount, maxAsyncThreadCount);

        IEnumerable<Task> tasks = inputEnumerable.Select(async input =>
        {
            await throttler.WaitAsync().ConfigureAwait(false);
            try
            {
                await asyncProcessor(input).ConfigureAwait(false);
            }
            finally
            {
                throttler.Release();
            }
        });

        return Task.WhenAll(tasks);
    }


否,您不需要在此实现和用法中显式处置SemaphoreSlim,因为它在方法内部内部使用,并且该方法不访问其AvailableWaitHandle属性,在这种情况下,我们需要将其处置或包装在using块中。
Dogu Arslan

1
仅考虑最佳实践和课程,我们就可以教其他人。一个using会很好。
AgentFire '17

这个例子我可以遵循,但是尝试找出最好的方法是什么,基本上有节流阀,但是我的Func会返回一个列表,我最终希望在完成时列出所有已完成的最终列表...这可能需要锁定在清单上,您有建议吗?
Seabizkit

您可以稍微更新该方法,使其返回实际任务列表,然后在调用代码中等待Task.WhenAll。Task.WhenAll完成后,您可以枚举列表中的每个任务并将其列表添加到最终列表中。将方法签名更改为“公共静态IEnumerable <Task <TOut >> ForEachAsync <TIn,TOut>(IEnumerable <TIn> inputEnumerable,Func <TIn,Task <TOut >> asyncProcessor,int?maxDegreeOfParallelism = null)”
Dogu Arslan

7

不幸的是,.NET Framework缺少用于协调并行异步任务的最重要的组合器。没有内置的东西。

看一下最受尊敬的Stephen Toub构建的AsyncSemaphore类。您想要的就是信号量,您需要它的异步版本。


12
请注意:“不幸的是,.NET Framework缺少用于协调并行异步任务的最重要的组合器。没有内置的东西。” 从.NET 4.5 Beta开始不再正确。SemaphoreSlim现在提供了WaitAsync(...)功能:)
Theo Yaung 2012年

SemaphoreSlim(及其新的异步方法)是否应优先于AsyncSemphore,还是Toub的实现仍具有某些优势?
Todd Menier

我认为,应该首选内置类型,因为它可能经过了良好的测试和精心设计。
usr

4
Stephen在他的博客文章中回答了一个问题,并添加了评论,以确认通常将SemaphoreSlim用于.NET 4.5。
jdasilva 2013年

7

有很多陷阱,在错误情况下直接使用信号量可能会很棘手,因此我建议使用AsyncEnumerator NuGet Package而不是重新发明轮子:

// let's say there is a list of 1000+ URLs
string[] urls = { "http://google.com", "http://yahoo.com", ... };

// now let's send HTTP requests to each of these URLs in parallel
await urls.ParallelForEachAsync(async (url) => {
    var client = new HttpClient();
    var html = await client.GetStringAsync(url);
}, maxDegreeOfParalellism: 20);

4

Theo Yaung示例很好,但是有一个变种,没有等待任务列表。

 class SomeChecker
 {
    private const int ThreadCount=20;
    private CountdownEvent _countdownEvent;
    private SemaphoreSlim _throttler;

    public Task Check(IList<string> urls)
    {
        _countdownEvent = new CountdownEvent(urls.Count);
        _throttler = new SemaphoreSlim(ThreadCount); 

        return Task.Run( // prevent UI thread lock
            async  () =>{
                foreach (var url in urls)
                {
                    // do an async wait until we can schedule again
                    await _throttler.WaitAsync();
                    ProccessUrl(url); // NOT await
                }
                //instead of await Task.WhenAll(allTasks);
                _countdownEvent.Wait();
            });
    }

    private async Task ProccessUrl(string url)
    {
        try
        {
            var page = await new WebClient()
                       .DownloadStringTaskAsync(new Uri(url)); 
            ProccessResult(page);
        }
        finally
        {
            _throttler.Release();
            _countdownEvent.Signal();
        }
    }

    private void ProccessResult(string page){/*....*/}
}

4
请注意,使用这种方法存在一种危险- ProccessUrl实际上发生的任何异常或其子功能都将被忽略。它们将被捕获到Tasks中,但不会渗透回的原始调用者Check(...)。就我个人而言,这就是为什么我仍使用Tasks及其组合函数(例如WhenAllWhenAny-)来获得更好的错误传播的原因。:)
Theo Yaung '16

3

SemaphoreSlim在这里非常有帮助。这是我创建的扩展方法。

    /// <summary>
    /// Concurrently Executes async actions for each item of <see cref="IEnumerable<typeparamref name="T"/>
    /// </summary>
    /// <typeparam name="T">Type of IEnumerable</typeparam>
    /// <param name="enumerable">instance of <see cref="IEnumerable<typeparamref name="T"/>"/></param>
    /// <param name="action">an async <see cref="Action" /> to execute</param>
    /// <param name="maxActionsToRunInParallel">Optional, max numbers of the actions to run in parallel,
    /// Must be grater than 0</param>
    /// <returns>A Task representing an async operation</returns>
    /// <exception cref="ArgumentOutOfRangeException">If the maxActionsToRunInParallel is less than 1</exception>
    public static async Task ForEachAsyncConcurrent<T>(
        this IEnumerable<T> enumerable,
        Func<T, Task> action,
        int? maxActionsToRunInParallel = null)
    {
        if (maxActionsToRunInParallel.HasValue)
        {
            using (var semaphoreSlim = new SemaphoreSlim(
                maxActionsToRunInParallel.Value, maxActionsToRunInParallel.Value))
            {
                var tasksWithThrottler = new List<Task>();

                foreach (var item in enumerable)
                {
                    // Increment the number of currently running tasks and wait if they are more than limit.
                    await semaphoreSlim.WaitAsync();

                    tasksWithThrottler.Add(Task.Run(async () =>
                    {
                        await action(item).ContinueWith(res =>
                        {
                            // action is completed, so decrement the number of currently running tasks
                            semaphoreSlim.Release();
                        });
                    }));
                }

                // Wait for all of the provided tasks to complete.
                await Task.WhenAll(tasksWithThrottler.ToArray());
            }
        }
        else
        {
            await Task.WhenAll(enumerable.Select(item => action(item)));
        }
    }

用法示例:

await enumerable.ForEachAsyncConcurrent(
    async item =>
    {
        await SomeAsyncMethod(item);
    },
    5);


0

这是一个利用LINQ的惰性特性的解决方案。它在功能上等同于可接受的答案),但是使用worker-tasks而不是a SemaphoreSlim,从而减少了整个操作的内存占用量。首先,让它正常工作而不受限制。第一步是将我们的网址转换为可枚举的任务。

string[] urls =
{
    "https://stackoverflow.com",
    "https://superuser.com",
    "https://serverfault.com",
    "https://meta.stackexchange.com",
    // ...
};
var httpClient = new HttpClient();
var tasks = urls.Select(async (url) =>
{
    return (Url: url, Html: await httpClient.GetStringAsync(url));
});

第二步是 await使用以下Task.WhenAll方法同时所有任务:

var results = await Task.WhenAll(tasks);
foreach (var result in results)
{
    Console.WriteLine($"Url: {result.Url}, {result.Html.Length:#,0} chars");
}

输出:

网址:https://stackoverflow.com,105.574个字符,
网址:https://superuser.com,126.953个字符,
网址:https://serverfault.com,125.963个字符,
网址: https ://meta.stackexchange.com,185.276字符
...

微软执行Task.WhenAll物化瞬间提供枚举到一个数组,导致所有任务开始一次。我们不想要那样,因为我们想限制并发异步操作的数量。所以我们需要实施一个替代方案WhenAll,将逐步枚举我们的可枚举对象。我们将通过创建多个工作任务(等于所需的并发级别)来做到这一点,并且每个工作任务将一次枚举我们可枚举的一个任务,并使用锁确保将处理每个url任务仅执行一项工作任务。然后,我们await完成所有工作任务,最后返回结果。这是实现:

public static async Task<T[]> WhenAll<T>(IEnumerable<Task<T>> tasks,
    int concurrencyLevel)
{
    if (tasks is ICollection<Task<T>>) throw new ArgumentException(
        "The enumerable should not be materialized.", nameof(tasks));
    var locker = new object();
    var results = new List<T>();
    var failed = false;
    using (var enumerator = tasks.GetEnumerator())
    {
        var workerTasks = Enumerable.Range(0, concurrencyLevel)
        .Select(async _ =>
        {
            try
            {
                while (true)
                {
                    Task<T> task;
                    int index;
                    lock (locker)
                    {
                        if (failed) break;
                        if (!enumerator.MoveNext()) break;
                        task = enumerator.Current;
                        index = results.Count;
                        results.Add(default); // Reserve space in the list
                    }
                    var result = await task.ConfigureAwait(false);
                    lock (locker) results[index] = result;
                }
            }
            catch (Exception)
            {
                lock (locker) failed = true;
                throw;
            }
        }).ToArray();
        await Task.WhenAll(workerTasks).ConfigureAwait(false);
    }
    lock (locker) return results.ToArray();
}

...这是我们必须在初始代码中进行的更改,以实现所需的限制:

var results = await WhenAll(tasks, concurrencyLevel: 2);

关于例外的处理有所不同。本机Task.WhenAll等待所有任务完成并聚集所有异常。完成第一个错误任务后,以上实现立即终止。


IAsyncEnumerable<T>可在此处找到返回的AC#8实现。
Theodor Zoulias

-1

尽管可能很快就将1000个任务排入队列,但是并行任务库只能处理等于计算机中CPU核心数量的并发任务。这意味着,如果您有四核计算机,则在给定时间将仅执行4个任务(除非您降低MaxDegreeOfParallelism)。


8
是的,但这与异步I / O操作无关。上面的代码即使在单个线程上运行,也将同时触发1000多个下载。
Grief Coder

await那里没有看到关键字。删除那应该解决问题,对吗?
斯科特,2012年

2
该库肯定可以处理Running比内核数量更多的并发运行的任务(具有状态)。对于具有I / O绑定任务的情况尤其如此。
svick

@svick:是的。您知道如何有效控制最大并发TPL任务(不是线程)吗?
Grief Coder 2012年

-1

应该使用并行计算来加快CPU约束的操作。在这里,我们谈论的是I / O绑定操作。您的实现应该是纯异步的,除非您不堪重负多核CPU上繁忙的单核。

编辑 我喜欢usr提出的建议,在这里使用“异步信号量”。


好点子!尽管此处的每个任务都将包含异步和同步代码(异步下载页面然后以同步方式处理页面)。我试图在CPU上分布代码的同步部分,同时限制并发异步I / O操作的数量。
Grief Coder 2012年

为什么?因为同时启动1000个以上的http请求可能不是非常适合用户网络容量的任务。
支出者2012年

并行扩展还可以用作多路I / O操作的方法,而无需手动实现纯异步解决方案。我同意这可能是草率的,但是只要您对并发操作的数量设置严格的限制,它就不会对线程池造成太大的负担。
肖恩·U

3
我认为此答案无法提供答案。仅在这里异步是不够的:我们真的想以非阻塞方式限制物理IO。
usr 2012年

1
嗯..不确定我是否同意...在大型项目中工作时,如果有太多开发人员采取这种观点,即使每个开发人员的孤立性贡献不足以使事情变得过分,您也会感到饥饿。鉴于只有一个 ThreadPool,即使您半尊重地对待它,如果其他所有人都在做同样的事情,也可能会遇到麻烦。因此,我始终建议不要在ThreadPool中运行过多的内容。
支出者2012年

-1

使用MaxDegreeOfParallelism,这是您可以在Parallel.ForEach()以下选项中指定的选项:

var options = new ParallelOptions { MaxDegreeOfParallelism = 20 };

Parallel.ForEach(urls, options,
    url =>
        {
            var client = new HttpClient();
            var html = client.GetStringAsync(url);
            // do stuff with html
        });

4
我认为这行不通。 GetStringAsync(url)被称为与await。如果检查的类型var html,它是a Task<string>,而不是结果string
Neal Ehardt

2
@NealEhardt是正确的。Parallel.ForEach(...)用于并行运行同步代码块(例如,在不同的线程上)。
Theo Yaung '16

-1

本质上,您将要为要单击的每个URL创建一个Action或Task,将它们放入列表中,然后处理该列表,限制可以并行处理的数量。

我的博客文章展示了如何通过“任务”和“动作”执行此操作,并提供了一个示例项目,您可以下载并运行该示例项目以查看实际情况。

有动作

如果使用动作,则可以使用内置的.Net Parallel.Invoke函数。在这里,我们将其限制为最多并行运行20个线程。

var listOfActions = new List<Action>();
foreach (var url in urls)
{
    var localUrl = url;
    // Note that we create the Task here, but do not start it.
    listOfTasks.Add(new Task(() => CallUrl(localUrl)));
}

var options = new ParallelOptions {MaxDegreeOfParallelism = 20};
Parallel.Invoke(options, listOfActions.ToArray());

有任务

使用Tasks时,没有内置功能。但是,您可以使用我在博客上提供的内容。

    /// <summary>
    /// Starts the given tasks and waits for them to complete. This will run, at most, the specified number of tasks in parallel.
    /// <para>NOTE: If one of the given tasks has already been started, an exception will be thrown.</para>
    /// </summary>
    /// <param name="tasksToRun">The tasks to run.</param>
    /// <param name="maxTasksToRunInParallel">The maximum number of tasks to run in parallel.</param>
    /// <param name="cancellationToken">The cancellation token.</param>
    public static async Task StartAndWaitAllThrottledAsync(IEnumerable<Task> tasksToRun, int maxTasksToRunInParallel, CancellationToken cancellationToken = new CancellationToken())
    {
        await StartAndWaitAllThrottledAsync(tasksToRun, maxTasksToRunInParallel, -1, cancellationToken);
    }

    /// <summary>
    /// Starts the given tasks and waits for them to complete. This will run the specified number of tasks in parallel.
    /// <para>NOTE: If a timeout is reached before the Task completes, another Task may be started, potentially running more than the specified maximum allowed.</para>
    /// <para>NOTE: If one of the given tasks has already been started, an exception will be thrown.</para>
    /// </summary>
    /// <param name="tasksToRun">The tasks to run.</param>
    /// <param name="maxTasksToRunInParallel">The maximum number of tasks to run in parallel.</param>
    /// <param name="timeoutInMilliseconds">The maximum milliseconds we should allow the max tasks to run in parallel before allowing another task to start. Specify -1 to wait indefinitely.</param>
    /// <param name="cancellationToken">The cancellation token.</param>
    public static async Task StartAndWaitAllThrottledAsync(IEnumerable<Task> tasksToRun, int maxTasksToRunInParallel, int timeoutInMilliseconds, CancellationToken cancellationToken = new CancellationToken())
    {
        // Convert to a list of tasks so that we don't enumerate over it multiple times needlessly.
        var tasks = tasksToRun.ToList();

        using (var throttler = new SemaphoreSlim(maxTasksToRunInParallel))
        {
            var postTaskTasks = new List<Task>();

            // Have each task notify the throttler when it completes so that it decrements the number of tasks currently running.
            tasks.ForEach(t => postTaskTasks.Add(t.ContinueWith(tsk => throttler.Release())));

            // Start running each task.
            foreach (var task in tasks)
            {
                // Increment the number of tasks currently running and wait if too many are running.
                await throttler.WaitAsync(timeoutInMilliseconds, cancellationToken);

                cancellationToken.ThrowIfCancellationRequested();
                task.Start();
            }

            // Wait for all of the provided tasks to complete.
            // We wait on the list of "post" tasks instead of the original tasks, otherwise there is a potential race condition where the throttler's using block is exited before some Tasks have had their "post" action completed, which references the throttler, resulting in an exception due to accessing a disposed object.
            await Task.WhenAll(postTaskTasks.ToArray());
        }
    }

然后创建任务列表并调用函数以使其运行(一次最多可以同时运行20个),您可以执行以下操作:

var listOfTasks = new List<Task>();
foreach (var url in urls)
{
    var localUrl = url;
    // Note that we create the Task here, but do not start it.
    listOfTasks.Add(new Task(async () => await CallUrl(localUrl)));
}
await Tasks.StartAndWaitAllThrottledAsync(listOfTasks, 20);

我认为您只是为SemaphoreSlim指定了initialCount,并且需要在SemaphoreSlim的构造函数中指定第二个参数,即maxCount。
杰伊·沙

我希望将每个任务的每个响应处理成一个列表。我如何获得返回结果或响应
venkat

-1

这不是一个好习惯,因为它会更改全局变量。它也不是异步的通用解决方案。但是,如果您只需要HttpClient,那么对所有HttpClient实例来说都很容易。您可以尝试:

System.Net.ServicePointManager.DefaultConnectionLimit = 20;
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.