Як обмежити кількість одночасних асинхронних операцій вводу / виводу?


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+ одночасних веб-запитів. Чи є простий спосіб обмежити одночасну кількість цих запитів на асинхронізацію http? Так що в будь-який момент часу завантажується не більше 20 веб-сторінок. Як це зробити найбільш ефективно?


2
Чим це відрізняється від вашого попереднього запитання ?
svick

1
stackoverflow.com/questions/9290498/… З параметром ParallelOptions.
Кріс Діслі

4
@ChrisDisley, це лише паралельно запускає запити.
витрачається

@svick має рацію, як це відрізняється? До речі, я люблю там відповідь stackoverflow.com/a/10802883/66372
eglasius

3
До того ж HttpClientє IDisposable, і вам слід розпоряджатися цим, особливо коли ви збираєтесь використовувати 1000+ з них. HttpClientможе використовуватися як синглтон для кількох запитів.
Шиммі Вайцхандлер

Відповіді:


161

Ви точно можете це зробити в останніх версіях async для .NET, використовуючи .NET 4.5 Beta. Попередня публікація з 'usr' вказує на гарну статтю, написану Стівеном Тубом, але менш оголошеною новиною є те, що семафор асинхрон насправді перетворився на бета-версію .NET 4.5

Якщо ви подивитесь на наш улюблений SemaphoreSlimклас (яким ви повинні користуватися, оскільки він є більш ефективним, ніж оригінал Semaphore), він тепер може похвалитися WaitAsync(...)низкою перевантажень, з усіма очікуваними аргументами - інтервалами очікування, скасуванням жетонів, усіма вашими звичними друзями з планування: )

Стівен також написав нещодавню публікацію в блозі про нові. 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
це не паралель. пропонуйте з обмеженим ступенем паралелізму приємніший підхід? msdn.microsoft.com/en-us/library/…
GreyCloud

2
Чому б вам не розпоряджатисяHttpClient
Шиммі Вайцхандлер,

4
@GreyCloud: Parallel.ForEachпрацює з синхронним кодом. Це дозволяє викликати асинхронний код.
Джош Ное

2
@TheMonarch ви помиляєтесь . Крім того , це завжди гарна звичка , щоб обернути все IDisposableз в usingабо try-finallyзаявах, а також забезпечити їх утилізацію.
Шиммі Вайцхандлер

29
З огляду на те, наскільки популярною є ця відповідь, варто зазначити, що HttpClient може і повинен бути єдиним загальним екземпляром, а не екземпляром на запит.
Руперт Роунслі

15

Якщо у вас є номер IEnumerable (тобто рядки URL-адрес), і ви хочете робити операцію, пов'язану з входом / виводом з кожним із них (тобто зробити запит на асинхронний http) одночасно І необов'язково, ви також хочете встановити максимальну кількість одночасних Запити вводу / виводу в режимі реального часу, ось як це зробити. Таким чином, ви не використовуєте пул потоків та ін., Метод використовує semaphoreslim для управління максимум одночасними запитами вводу / виводу, аналогічними шаблону розсувного вікна, який завершує один запит, залишає семафор і наступний входить.

використання: чекайте ForEachAsync (urlStrings, YourAsyncFunc, необов'язковоMaxDegreeOfConcurrency);

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, і в цьому випадку нам потрібно було б розпоряджатися або загортати його в блок використання.
Dogu Arslan

1
Тільки думаючи про найкращі практики та уроки, яких ми навчаємо інших людей. А usingбуло б добре.
AgentFire

ну цей приклад я можу наслідувати, але намагаючись розібратися, який найкращий спосіб це зробити, в основному є дросель, але мій Func поверне список, який я в кінцевому підсумку хочу в остаточному списку всіх завершених після завершення ... що може потрібно заблокований у списку, чи є у вас пропозиції.
Seabizkit

ви можете трохи оновити метод, щоб він повернув список актуальних завдань, і ви чекаєте Завдання. Коли все з вашого коду виклику. Після завершення завдання Task.WhenAll ви можете перерахувати кожне завдання зі списку та додати його до остаточного списку. Метод Змінити підпис 'публічний статичний IEnumerable <Task <Tout >> ForEachAsync <Олово, Tout> (IEnumerable <Tin> inputEnumerable, Func <Олово, Task <Tout >> asyncProcessor, внутр maxDegreeOfParallelism = нуль?)'
Догові Арслан

7

На жаль, у .NET Framework відсутні найважливіші комбінатори для упорядкування завдань паралельної асинхронізації. Вбудованого такого немає.

Подивіться на клас AsyncSemaphore, побудований найповажнішим Стівеном Тубом. Те, що ви хочете, називається семафором, і вам потрібна його версія асинхронізації.


12
Зауважте, що "На жаль, .NET Framework не вистачає найважливіших комбінаторів для оркестрування паралельних завдань асинхронізації. Вбудованої такої речі немає". більше не є правильним, ніж для .NET 4.5 Beta. SemaphoreSlim тепер пропонує функціональність WaitAsync (...) :)
Theo Yaung

Чи варто віддавати перевагу SemaphoreSlim (з його новими методами асинхронізації) перед AsyncSemphore, чи реалізація Toub все ж має певну перевагу?
Тодд Меньє

На мою думку, слід віддавати перевагу вбудованому типу, оскільки він, ймовірно, добре перевірений та добре спроектований.
usr

4
Стівен додав коментар у відповідь на запитання у своєму дописі в блозі, який підтверджує, що використання SemaphoreSlim для .NET 4.5, як правило, було б шляхом.
jdasilva

7

Існує багато підводних каменів і пряме використання семафору може бути складним у випадках помилок, тому я б запропонував використовувати пакет AsyncEnumerator NuGet замість того, щоб заново винайти колесо:

// 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його підфункціях, і фактично будуть ігноровані. Вони будуть захоплені у Завдання, але не прошиті до початкового абонента Check(...). Особисто тому я все ще використовую Завдання та їх функції комбінатора, як WhenAllі WhenAny- для кращого поширення помилок. :)
Тео Яун

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

Старе запитання, нова відповідь. @vitidev мав блок коду, який повторно використовувався майже неушкодженим у проекті, який я рецензував. Після обговорення з кількома колегами один запитав: "Чому ви просто не використовуєте вбудовані методи TPL?" ActionBlock виглядає як переможець там. https://msdn.microsoft.com/en-us/library/hh194773(v=vs.110).aspx . Напевно, не вдасться змінити будь-який існуючий код, але, безумовно, прагнемо прийняти цей нут і використати найкращу практику містера Софті для придушення паралелізму.


0

Ось рішення, яке використовує перевагу лінивого характеру LINQ. Він функціонально еквівалент прийнятої відповіді ), але використовує робочі завдання замість a SemaphoreSlim, зменшуючи таким чином слід пам'яті всієї операції. Спочатку давайте змусити його працювати без дроселювання. Перший крок - перетворити наші URL-адреси на безліч завдань.

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

Вихід:

URL: https://stackoverflow.com , 105.574 символів
Url: https://superuser.com , 126.953 символів
Url: https://serverfault.com , 125.963 символів
Url: https://meta.stackexchange.com , 185.276 символів
...

Реалізація Microsoft, з 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чекає, коли всі завдання завершаться, і зведе всі винятки. Виконання вище закінчується негайно після виконання першого несправного завдання.


Реалізацію AC № 8, яка повертає, IAsyncEnumerable<T>можна знайти тут .
Теодор Зуліяс

-1

Хоча 1000 завдань може бути поставлено в чергу дуже швидко, бібліотека Parallel Tasks може обробляти лише одночасні завдання, рівні кількості ядер CPU в машині. Це означає, що якщо у вас чотириядерна машина, за вказаний час буде виконуватися лише 4 завдання (якщо ви не знизите MaxDegreeOfParallelism).


8
Так, але це не стосується операцій асинхронізації вводу / виводу. Код, наведений вище, призведе до одночасного завантаження 1000+, навіть якщо він працює на одному потоці.
Горе Кодер

awaitТам не було ключового слова. Видалення, що повинно вирішити проблему, правильно?
scottm

2
Бібліотека, безумовно, може впоратися з більшістю завдань, що виконуються (зі Runningстатусом) одночасно, ніж кількість ядер. Особливо це стосується завдань, пов'язаних з введенням / виводу.
svick

@svick: так. Чи знаєте ви, як ефективно контролювати максимум одночасних завдань TPL (не потоки)?
Горе Кодер

-1

Паралельні обчислення слід використовувати для прискорення операцій, пов'язаних з процесором. Тут мова йде про операції, пов'язані з входом / виводом. Ваша реалізація повинна бути суто асинхронною , якщо ви не перевантажуєте зайнятий одноядерний процесор багатоядерного процесора.

EDIT Мені подобається пропозиція usr використовувати тут "асинхронний семафор".


Гарна думка! Хоча тут кожне завдання буде містити код асинхронізації та синхронізації (сторінка завантажується асинхронно, потім обробляється синхронізовано). Я намагаюся розподілити частину синхронізації коду між процесорами і в той же час обмежити кількість одночасних операцій вводу-виводу з асинхронізацією.
Горе Кодер

Чому? Оскільки одночасно запускати 1000+ HT-запитів може не бути завданням, добре підходящим до мережевої потужності користувача.
витрачається

Паралельні розширення можуть також використовуватися як спосіб мультиплексування операцій вводу / виводу без необхідності вручну реалізувати чисте рішення для асинхронізації. Я погоджуюсь, що це можна вважати неохайним, але поки ви дотримуєтесь жорсткого обмеження кількості одночасних операцій, він, ймовірно, не надто напружить нитку.
Шон U

3
Я не думаю, що ця відповідь не дає відповіді. Бути чисто асинхронним тут недостатньо: ми дійсно хочемо придушити фізичні ІО не блокуючим способом.
usr

1
Хм .. не впевнений, що я згоден ... якщо працюєте над великим проектом, якщо хтось із занадто багатьох розробників дотримується такої точки зору, ви отримаєте голодування, навіть якщо внесок кожного розробника в ізоляцію недостатній для того, щоб переламати речі на край. Зважаючи на те, що є лише один ThreadPool, навіть якщо ви ставитеся до нього напівповажно ... якщо всі інші роблять те саме, проблеми можуть настати. Як такий, я завжди раджу не запускати довгі речі в ThreadPool.
витрачається

-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, це результат Task<string>, а не результат string.
Ніл Ехардт

2
@NealEhardt вірно. Parallel.ForEach(...)призначений для паралельного запуску блоків синхронного коду (наприклад, на різних потоках).
Тео Яун

-1

По суті, ви хочете створити дію або завдання для кожної URL-адреси, яку ви хочете отримати, помістити їх у список, а потім обробити цей список, обмеживши кількість, яке можна паралельно обробляти.

У моєму дописі в блозі показано, як це зробити як із Завданнями, так і з Діями, і надається зразок проекту, який ви можете завантажити та запустити, щоб побачити обидва в дії.

З діями

Якщо ви використовуєте Дії, ви можете використовувати вбудовану функцію .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());

З завданнями

З Завданнями немає вбудованої функції. Однак ви можете використовувати той, який я надаю у своєму блозі.

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

Я думаю, ви просто вказуєте початковийCount для SemaphoreSlim і вам потрібно вказати 2-й параметр, тобто maxCount у конструкторі SemaphoreSlim.
Джей Шах

Я хочу, щоб кожна відповідь від кожної задачі була оброблена в Список. Як я можу отримати повернення Результат або відповідь
venkat

-1

це не є хорошою практикою, оскільки це змінює глобальну змінну. це також не є загальним рішенням для асинхронізації. але це легко для всіх примірників HttpClient, якщо це все, що ви шукаєте. ви можете просто спробувати:

System.Net.ServicePointManager.DefaultConnectionLimit = 20;
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.