Vinicius Quinafelex Alves

🌐English version

[C#] Prefetching de métodos assíncronos

Prefetching (ou pré-carregamento) é uma técnica que inicia o carregamento de dados antes deles serem necessários, diminuindo o tempo total de execução, mas com o risco de carregar dados desnecessariamente.

Na programação, através do código, o desenvolvedor pode identificar quais dados serão necessários em quais situações, reduzindo ou eliminando o risco de carregamentos desnecessários.

No C#, usar async e tasks permite iniciar o carregamento de dados sem interromper o fluxo do código. Isso ajuda a diluir o tempo que o algoritmo ficará esperando pelo resultado de operações de I/O, pois ele estará trabalhando em outros comandos enquanto espera o resultado.

Exemplos de pré-carregamento com assincronia abaixo:

Pré-carregando um resultado

Executar um método assíncrono imediatamente inicia a execução do método sem interromper o fluxo de código. Ao guardar referência à task retornada, é possível utilizar await para aguardar o carregamento apenas quando ele será necessário.

public static async Task<string> GetHtmlAsync(string uri)
{
    using (var client = new HttpClient())
        return await client.GetStringAsync(uri);
}
// Start prefetching
var taskHtml = GetHtmlAsync("https://domain.com");
CodeWithoutHtml();

// Await and consume the result
var html = await taskHtml
CodeWithHtml(html);

Executando múltiplos pré-carregamentos

Invocar métodos assíncronos sem await em sequência é o suficiente para buscar os dados múltiplos dados em paralelo, sem gerar interrupções de código. O fluxo só será interrompido na presença de um await.

// Start pre-fetching
var taskHtml1 = GetHtmlAsync("https://domain1.com");
var taskHtml2 = GetHtmlAsync("https://domain2.com");
var taskHtml3 = GetHtmlAsync("https://domain3.com");

// Await results
var html1 = await taskHtml1;
var html2 = await taskHtml2;
var html3 = await taskHtml3;

Encadeamento de pré-carregamentos

Existem situações em que um método assíncrono precisa de um dado carregado por outro método assíncrono, criando uma corrente de carregamentos.

Tasks podem ser encadeadas usando ContinueWith(), que invocará um método qualquer assim que a task for concluída, e Unwrap(), que expõe a task interna sendo executada no ContinueWith. Considerando que o ContinueWith só é executado depois que a task foi concluída, não há bloqueio de thread quando é chamado diretamente o .Result da task.

// Start pre-fetching
var taskUrl = RetrieveUrlAsync();

var taskStatusCode = taskUrl.ContinueWith(async (task) => 
{
    return await GetStatusCodeAsync(task.Result);
}).Unwrap();

var taskFavicon = taskUrl.ContinueWith(async (task) => 
{
    return await HasFaviconAsync(task.Result);
}).Unwrap();

// Await results
var statusCode = await taskStatusCode;
var hasFavicon = await taskFavicon;

Pré-carregamento com IAsyncEnumerable

Quando utilizar IAsyncEnumerable, também é possível pré-carregar o próximo resultado ao controlar o IEnumerator, como demonstrado pelo extension method abaixo. Os benchmarks foram gerados pelo BenchmarkDotNet.

public static async IAsyncEnumerable<T> WithPrefetch<T>(this IAsyncEnumerable<T> enumerable)
{
    await using(var enumerator = enumerable.GetAsyncEnumerator())
    {
        ValueTask<bool> hasNextTask = enumerator.MoveNextAsync();

        while(await hasNextTask)
        {
            T data = enumerator.Current;
            hasNextTask = enumerator.MoveNextAsync();
            yield return data;
        }
    }
}
// Prefetching 1 item
await foreach(var item in EnumerateAsync().WithPrefetch())
    Process(item);

Há um ganho de performance significante em utilizar pré-carregamento quando o tempo de carregamento e o tempo de processamento são próximos. Pré-carregamento apresenta uma sobrecarga desnecessária para dados que já estão em memória.

FetchProcessExecution time (no-prefetch)Execution time (prefetch)Improvement
0 ms0 ms0.0006 ms0.0013 ms-116%
20 ms20 ms964 ms497 ms93%
100 ms20 ms2117 ms1675 ms26%
20 ms100 ms2118 ms1674 ms26%
200 ms20 ms3540 ms3099 ms14%
20 ms200 ms3572 ms3093 ms15%

Não há benefícios em pré-carregar mais que um item por vez, se for realizar o processamento de um item por vez. Código para pré-carregar mais de um item:

public static IAsyncEnumerable<T> WithPrefetch<T>(this IAsyncEnumerable<T> enumerable, int prefetchDepth)
{
    while(prefetchDepth > 0)
    { 
        enumerable = enumerable.WithPrefetch();
        prefetchDepth--;
    }

    return enumerable;
}
// Prefetching 10 items
await foreach(var item in EnumerateAsync().WithPrefetch(10))
    Process(item);

Benchmarks constatam que pré-carregar múltiplos registros causa um aumento linear de tempo baseado na profundidade de pré-carregamento.

O método utilizado como referência soma 100 números de uma enumeração nos cenários em que ela é carregada de forma síncrona, assíncrona ou assíncrona com pré-carregamento. 1 ms = 1.000.000 ns

MethodMeanErrorStdDevGen0Allocated
Sync132.4 ns0.53 ns0.44 ns--
AsyncWithoutPrefetch5,062.2 ns49.27 ns46.09 ns0.0381168 B
AsyncWithPrefetch_01_Record8,872.0 ns175.48 ns195.05 ns0.0763344 B
AsyncWithPrefetch_02_Records15,175.8 ns260.77 ns243.93 ns0.1221520 B
AsyncWithPrefetch_04_Records21,440.6 ns143.50 ns127.21 ns0.1831872 B
AsyncWithPrefetch_08_Records37,753.9 ns251.59 ns196.43 ns0.36621576 B
AsyncWithPrefetch_16_Records78,780.6 ns944.58 ns837.35 ns0.61042984 B
AsyncWithPrefetch_32_Records159,310.1 ns2,197.14 ns2,055.21 ns1.22075800 B

Alterações exclusivamente no tempo de processamento ou tempo de carregamento não afeta o tempo geral da execução da função, independente da profundidade de pré-carregamentos.

MethodMeanErrorStdDevGen0Allocated
Fetch0ms_Process20ms_Prefetch0468.0 ms9.0 ms7.5ms-16608 B
Fetch0ms_Process20ms_Prefetch1466.0 ms2.2 ms2.0ms-16784 B
Fetch0ms_Process20ms_Prefetch2470.8 ms7.9 ms7.0ms-10992 B
Fetch0ms_Process20ms_Prefetch10465.8 ms4.2 ms3.3ms-18376 B
Fetch20ms_Process0ms_Prefetch0469.9 ms5.8 ms5.4 ms-17072 B
Fetch20ms_Process0ms_Prefetch1466.4 ms3.2 ms2.8 ms-17664 B
Fetch20ms_Process0ms_Prefetch2466.3 ms3.0 ms2.5 ms-17976 B
Fetch20ms_Process0ms_Prefetch10466.0 ms2.6 ms2.3 ms-20840 B

Aumentar a profundidade de pré-carregamentos não apresenta melhoria na performance mesmo em situações aonde carregamento e processamento favorecem o paralelismo.

MethodMeanImprovement to non-prefetch
Fetch100ms_Process20ms_Prefetch02.117 sN/A
Fetch100ms_Process20ms_Prefetch11.675 s26.39%
Fetch100ms_Process20ms_Prefetch21.681 s25.94%
Fetch100ms_Process20ms_Prefetch101.675 s26.39%
MethodMeanImprovement to non-prefetch
Fetch200ms_Process20ms_Prefetch03.540 sN/A
Fetch200ms_Process20ms_Prefetch13.099 s14.23%
Fetch200ms_Process20ms_Prefetch23.096 s15.35%
Fetch200ms_Process20ms_Prefetch103.099 s14.23%
MethodMeanImprovement to non-prefetch
Fetch20ms_Process100ms_Prefetch02.118 sN/A
Fetch20ms_Process100ms_Prefetch11.674 s26.52%
Fetch20ms_Process100ms_Prefetch21.680 s26.07%
Fetch20ms_Process100ms_Prefetch101.675 s26.45%
MethodMeanImprovement to non-prefetch
Fetch20ms_Process200ms_Prefetch03.527 sN/A
Fetch20ms_Process200ms_Prefetch13.093 s14.03%
Fetch20ms_Process200ms_Prefetch23.096 s13.92%
Fetch20ms_Process200ms_Prefetch103.091 s14.11%