Como limitar a quantidade de operações de E / S assíncronas simultâneas?

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

Aqui está o problema, ele inicia mais de 1000 solicitações da web simultâneas. Existe uma maneira fácil de limitar a quantidade simultânea dessas solicitações http assíncronas? Para que não mais do que 20 páginas da web sejam baixadas a qualquer momento. Como fazer da maneira mais eficiente?

Grief Coder
fonte
2
Como isso é diferente da sua pergunta anterior ?
svick
1
stackoverflow.com/questions/9290498/… Com um parâmetro ParallelOptions.
Chris Disley
4
@ChrisDisley, isso só vai paralelizar o lançamento das requisições.
spender
@svick está certo, qual a diferença? A propósito, adoro a resposta lá stackoverflow.com/a/10802883/66372
eglasius
3
Além disso , HttpClienté IDisposable, e você deve descartá-lo, especialmente quando for usar mais de 1000 deles. HttpClientpode ser usado como um singleton para várias solicitações.
Shimmy Weitzhandler

Respostas:

161

Definitivamente, você pode fazer isso nas versões mais recentes do async para .NET, usando .NET 4.5 Beta. O post anterior de 'usr' aponta para um bom artigo escrito por Stephen Toub, mas a notícia menos anunciada é que o semáforo assíncrono realmente chegou à versão Beta do .NET 4.5

Se você olhar para nossa amada SemaphoreSlimclasse (que você deveria usar, pois tem mais desempenho do que a original Semaphore), ela agora possui uma WaitAsync(...)série de sobrecargas, com todos os argumentos esperados - intervalos de tempo limite, tokens de cancelamento, todos os seus amigos de programação usuais: )

Stephen também escreveu uma postagem no blog mais recente sobre os novos recursos do .NET 4.5 que saíram com o beta, consulte Novidades para paralelismo no .NET 4.5 Beta .

Por último, aqui está um exemplo de código sobre como usar SemaphoreSlim para limitação de método assíncrono:

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

Por último, mas provavelmente uma menção digna é uma solução que usa agendamento baseado em TPL. Você pode criar tarefas vinculadas a delegados no TPL que ainda não foram iniciadas e permitir que um agendador de tarefas personalizado limite a simultaneidade. Na verdade, há uma amostra do MSDN aqui:

Veja também TaskScheduler .

Theo Yaung
fonte
3
não é um parallel.foreach com um grau limitado de paralelismo uma abordagem mais agradável? msdn.microsoft.com/en-us/library/…
GreyCloud
2
Por que você não descarta vocêHttpClient
Shimmy Weitzhandler
4
@GreyCloud: Parallel.ForEachfunciona com código síncrono. Isso permite que você chame o código assíncrono.
Josh Noe
2
@TheMonarca você está errado . Além disso, é sempre um bom hábito embrulhar todos os IDisposables usingou try-finallydeclarações e garantir a sua eliminação.
Shimmy Weitzhandler
29
Dada a popularidade dessa resposta, vale a pena ressaltar que HttpClient pode e deve ser uma única instância comum, em vez de uma instância por solicitação.
Rupert Rawnsley
15

Se você tiver um IEnumerable (ou seja, strings de URL s) e quiser fazer uma operação de ligação de E / S com cada um deles (ou seja, fazer uma solicitação assíncrona de http) simultaneamente E, opcionalmente, também deseja definir o número máximo de simultâneos Solicitações de E / S em tempo real, veja como você pode fazer isso. Dessa forma, você não usa pool de threads e outros, o método usa semáforoslim para controlar o máximo de solicitações de E / S simultâneas, semelhante a um padrão de janela deslizante que uma solicitação completa, deixa o semáforo e a próxima entra.

uso: await 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);
    }
Dogu Arslan
fonte
não, você não deve precisar descartar explicitamente o SemaphoreSlim nesta implementação e uso, pois ele é usado internamente dentro do método e o método não acessa sua propriedade AvailableWaitHandle, caso em que teríamos que descartar ou embrulhar dentro de um bloco using.
Dogu Arslan
1
Apenas pensando nas melhores práticas e lições que ensinamos a outras pessoas. Um usingseria bom.
AgentFire de
Bem, este exemplo eu posso seguir, mas tentando descobrir qual é a melhor maneira de fazer isso, basicamente tenho um throttler, mas meu Func retornaria uma lista, que eu quero em uma lista final de tudo concluído quando feito ... o que pode exigem bloqueado na lista, você tem sugestões.
Seabizkit
você pode atualizar ligeiramente o método para que ele retorne a lista de tarefas reais e aguarde Task.WhenAll de dentro de seu código de chamada. Depois que Task.WhenAll estiver concluída, você pode enumerar cada tarefa na lista e adicionar sua lista à lista final. Altere a assinatura do método para 'public static IEnumerable <Task <TOut>> ForEachAsync <TIn, TOut> (IEnumerable <TIn> inputEnumerable, Func <TIn, Task <TOut>> asyncProcessor, int? MaxDegreeOfParallelism = null)'
Dogu Arslan
7

Infelizmente, o .NET Framework está perdendo os combinadores mais importantes para orquestrar tarefas assíncronas paralelas. Não existe tal coisa embutida.

Veja a classe AsyncSemaphore construída pelo mais respeitável Stephen Toub. O que você quer é chamado de semáforo e precisa de uma versão assíncrona dele.

usr
fonte
12
Observe que "Infelizmente, o .NET Framework está perdendo os combinadores mais importantes para orquestrar tarefas assíncronas paralelas. Não existe tal coisa integrada." não está mais correto a partir do .NET 4.5 Beta. O SemaphoreSlim agora oferece a funcionalidade WaitAsync (...) :)
Theo Yaung
Deve SemaphoreSlim (com seus novos métodos assíncronos) ser preferido em vez de AsyncSemphore, ou a implementação de Toub ainda tem alguma vantagem?
Todd Menier de
Em minha opinião, o tipo integrado deve ser preferido porque é provável que seja bem testado e projetado.
usr
4
Stephen adicionou um comentário em resposta a uma pergunta em seu blog, confirmando que usar o SemaphoreSlim para .NET 4.5 seria geralmente o caminho a percorrer.
jdasilva de
7

Existem muitas armadilhas e o uso direto de um semáforo pode ser complicado em casos de erro, então eu sugeriria usar o Pacote NuGet AsyncEnumerator em vez de reinventar a roda:

// 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);
Serge Semenov
fonte
4

O exemplo de Theo Yaung é bom, mas há uma variante sem lista de tarefas em espera.

 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){/*....*/}
}
Vitidev
fonte
4
Observe que há um perigo de usar essa abordagem - quaisquer exceções que ocorram ProccessUrlou suas subfunções serão realmente ignoradas. Eles serão capturados no Tarefas, mas não filtrados de volta para o chamador original de Check(...). Pessoalmente, é por isso que ainda uso o Tasks e suas funções combinadoras como WhenAlle WhenAny- para obter uma melhor propagação de erros. :)
Theo Yaung
3

O SemaphoreSlim pode ser muito útil aqui. Aqui está o método de extensão que criei.

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

Uso de amostra:

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

Velha pergunta, nova resposta. @vitidev tinha um bloco de código que foi reutilizado quase intacto em um projeto que analisei. Depois de discutir com alguns colegas, um perguntou "Por que você simplesmente não usa os métodos TPL embutidos?" ActionBlock parece ser o vencedor. https://msdn.microsoft.com/en-us/library/hh194773(v=vs.110).aspx . Provavelmente não acabará mudando nenhum código existente, mas definitivamente tentará adotar esse nuget e reutilizar a prática recomendada do Sr. Softy para paralelismo limitado.

Sem Reembolsos Sem Devoluções
fonte
0

Aqui está uma solução que tira proveito da natureza preguiçosa do LINQ. É funcionalmente equivalente à resposta aceita ), mas usa tarefas de trabalho em vez de a SemaphoreSlim, reduzindo assim o consumo de memória de toda a operação. A princípio vamos fazer funcionar sem estrangulamento. O primeiro passo é converter nossos urls em uma lista de tarefas.

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

A segunda etapa é fazer awaittodas as tarefas simultaneamente usando o Task.WhenAllmétodo:

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

Resultado:

Url: https://stackoverflow.com , 105.574 chars
Url: https://superuser.com , 126.953 chars
Url: https://serverfault.com , 125.963 chars
Url: https://meta.stackexchange.com , 185.276 chars
...

A implementação de MicrosoftTask.WhenAll materializa instantaneamente o enumerável fornecido a um array, fazendo com que todas as tarefas sejam iniciadas de uma vez. Não queremos isso, porque queremos limitar o número de operações assíncronas simultâneas. Portanto, precisaremos implementar uma alternativa WhenAllque enumerará nosso enumerável suave e lentamente. Faremos isso criando uma série de tarefas de trabalho (igual ao nível desejado de simultaneidade), e cada tarefa de trabalho irá enumerar nossa tarefa enumerável por vez, usando um bloqueio para garantir que cada tarefa de url será processada por apenas um trabalhador-tarefa. Em seguida, concluímos awaittodas as tarefas do trabalhador e, finalmente, retornamos os resultados. Aqui está a implementação:

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

... e aqui está o que devemos mudar em nosso código inicial, para atingir a limitação desejada:

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

Há uma diferença quanto ao tratamento das exceções. O nativo Task.WhenAllespera que todas as tarefas sejam concluídas e agrega todas as exceções. A implementação acima termina imediatamente após a conclusão da primeira tarefa com falha.

Theodor Zoulias
fonte
A implementação do AC # 8 que retorna um IAsyncEnumerable<T>pode ser encontrada aqui .
Theodor Zoulias
-1

Embora 1000 tarefas possam ser enfileiradas muito rapidamente, a biblioteca de Tarefas Paralelas só pode lidar com tarefas simultâneas iguais à quantidade de núcleos de CPU na máquina. Isso significa que se você tiver uma máquina de quatro núcleos, apenas 4 tarefas serão executadas em um determinado momento (a menos que você diminua o MaxDegreeOfParallelism).

Scottm
fonte
8
Sim, mas isso não se relaciona a operações de E / S assíncronas. O código acima irá disparar mais de 1000 downloads simultâneos, mesmo se estiver sendo executado em um único thread.
Grief Coder
Não vi a awaitpalavra - chave lá. Remover isso deve resolver o problema, correto?
scottm
2
A biblioteca certamente pode lidar com mais tarefas em execução (com o Runningstatus) simultaneamente do que a quantidade de núcleos. Isso será especialmente o caso com tarefas vinculadas a E / S.
svick
@svick: sim. Você sabe como controlar com eficiência o máximo de tarefas TPL simultâneas (não threads)?
Grief Coder
-1

Cálculos paralelos devem ser usados ​​para acelerar as operações vinculadas à CPU. Aqui, estamos falando sobre operações vinculadas a E / S. Sua implementação deve ser puramente assíncrona , a menos que você esteja sobrecarregando o único núcleo ocupado em sua CPU multi-core.

EDITAR Eu gosto da sugestão feita por usr para usar um "semáforo assíncrono" aqui.

GregC
fonte
Bom ponto! Embora cada tarefa aqui contenha código assíncrono e de sincronização (página baixada de forma assíncrona e depois processada de maneira sincronizada). Estou tentando distribuir a parte de sincronização do código entre CPUs e, ao mesmo tempo, limitar a quantidade de operações de E / S assíncronas simultâneas.
Grief Coder
Por quê? Porque lançar mais de 1000 solicitações http simultaneamente pode não ser uma tarefa adequada à capacidade de rede do usuário.
spender
As extensões paralelas também podem ser usadas como uma forma de multiplexar as operações de E / S sem a necessidade de implementar manualmente uma solução assíncrona pura. O que eu concordo que pode ser considerado desleixado, mas contanto que você mantenha um limite rígido no número de operações simultâneas, provavelmente não vai sobrecarregar muito o threadpool.
Sean U
3
Eu não acho que essa resposta está fornecendo uma resposta. Ser puramente assíncrono não é suficiente aqui: realmente queremos estrangular os IOs físicos de uma maneira sem bloqueio.
usr
1
Hmm ... não tenho certeza se concordo ... ao trabalhar em um grande projeto, se muitos desenvolvedores aceitarem essa visão, você ficará inanimado, embora a contribuição de cada desenvolvedor isoladamente não seja suficiente para levar as coisas ao limite. Dado que há apenas um ThreadPool, mesmo se você estiver tratando-o de forma semi-respeitosa ... se todos os outros estiverem fazendo o mesmo, podem surgir problemas. Como tal, sempre aconselho a não executar tarefas longas no ThreadPool.
spender
-1

Use MaxDegreeOfParallelism, que é uma opção que você pode especificar em 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
        });
Sean U
fonte
4
Eu não acho que isso funcione. GetStringAsync(url)deve ser chamado com await. Se você inspecionar o tipo de var html, é a Task<string>, não o resultado string.
Neal Ehardt
2
@NealEhardt está correto. Parallel.ForEach(...)destina-se à execução de blocos de código síncrono em paralelo (por exemplo, em threads diferentes).
Theo Yaung
-1

Essencialmente, você vai querer criar uma ação ou tarefa para cada URL que deseja acessar, colocá-los em uma lista e, em seguida, processar essa lista, limitando o número que pode ser processado em paralelo.

Minha postagem do blog mostra como fazer isso com Tarefas e com Ações e fornece um projeto de amostra que você pode baixar e executar para ver os dois em ação.

Com ações

Se estiver usando Actions, você pode usar a função integrada .Net Parallel.Invoke. Aqui, limitamos a execução de no máximo 20 threads em paralelo.

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

Com Tarefas

Com o Tarefas, não há função incorporada. No entanto, você pode usar o que eu disponibilizo no meu blog.

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

E, em seguida, criar sua lista de tarefas e chamar a função para executá-las, digamos, no máximo 20 simultâneas por vez, você pode fazer o seguinte:

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);
cachorro mortal
fonte
Acho que você está apenas especificando initialCount para SemaphoreSlim e precisa especificar o segundo parâmetro, ou seja, maxCount no construtor de SemaphoreSlim.
Jay Shah
Quero cada resposta de cada tarefa processada em uma lista. Como posso obter o resultado de devolução ou resposta
venkat
-1

esta não é uma boa prática, pois altera uma variável global. também não é uma solução geral para assíncrono. mas é fácil para todas as instâncias de HttpClient, se é isso que você está procurando. você pode simplesmente tentar:

System.Net.ServicePointManager.DefaultConnectionLimit = 20;
simbionte
fonte