Start Debugging

Como executar trabalho intensivo de CPU em um app Blazor WebAssembly com Web Workers no .NET 11

Guia completo para tirar o trabalho intensivo de CPU da thread de UI do Blazor WebAssembly no .NET 11: por que Task.Run não ajuda, o novo template blazorwebworker, a API WebWorkerClient com cancelamento e timeouts, os limites de marshalling do JSExport e o custo do segundo runtime que você paga por worker.

O Blazor WebAssembly executa seu código .NET na única thread de UI do navegador, então um laço for apertado congela a página: sem repinturas, sem cliques, sem StateHasChanged. Task.Run não te salva, porque não existe uma segunda thread onde executar. A solução no .NET 11 é o template de projeto blazorwebworker, que gera uma biblioteca de classes cujos métodos rodam dentro de um Web Worker real do navegador, em uma thread de sistema operacional separada. Você marca esses métodos com [JSExport], referencia a biblioteca a partir do seu app e os chama através de WebWorkerClient.InvokeAsync<TResult>.

Tudo abaixo tem como alvo o .NET 11 (Preview 6 no momento em que isto foi escrito, SDK 11.0.100-preview.6) com C# 14. O template chegou no .NET 11 Preview 1 com o nome webworker e foi renomeado para blazorwebworker antes da versão final; projetos gerados com o nome antigo continuam funcionando, apenas o identificador do template mudou. Duas capacidades são novas no cliente final do .NET 11: InvokeVoidAsync, e suporte a cancelamento e timeout tanto na criação do worker quanto na invocação.

Os seis passos, do início ao fim

  1. Crie uma biblioteca de classes worker com dotnet new blazorwebworker e referencie-a a partir do app Blazor WebAssembly.
  2. Escreva seu código intensivo de CPU como métodos static marcados com [JSExport] dentro de uma static partial class.
  3. Retorne apenas primitivos ou strings; serialize para JSON dentro do worker qualquer coisa mais rica.
  4. Crie o WebWorkerClient uma única vez (não por chamada) e mantenha-o por toda a vida do componente ou do app.
  5. Invoque os métodos pelo nome totalmente qualificado, passando um CancellationToken e um timeout.
  6. Descarte o cliente para encerrar o worker e liberar o segundo runtime que ele carregou.

O resto deste post trata de por que cada um importa, e do que quebra quando você pula algum.

Por que Task.Run não tira o trabalho da thread de UI

Isto é a primeira coisa que as pessoas tentam, e vale a pena entender exatamente por que falha antes de recorrer aos workers.

// .NET 11, C# 14 - Blazor WebAssembly. This still freezes the browser.
private async Task Compute()
{
    status = "Working...";
    await Task.Run(() => CountPrimes(5_000_000));
    status = "Done";
}

private static int CountPrimes(int limit)
{
    var count = 0;
    for (var n = 2; n <= limit; n++)
    {
        var isPrime = true;
        for (var d = 2; d * d <= n; d++)
        {
            if (n % d == 0) { isPrime = false; break; }
        }
        if (isPrime) count++;
    }

    return count;
}

A linha status = "Working..." nunca é renderizada. A aba do navegador para de responder por vários segundos, e então as duas atualizações de status aparecem de uma vez.

O motivo é que o runtime do Blazor WebAssembly é de thread única. Task.Run enfileira trabalho no thread pool do .NET, mas no runtime browser-wasm esse pool é emulado sobre a única thread que o runtime possui. O delegate não começa até que o bloco síncrono atual ceda o controle, e uma vez que começa, nada mais pode se intercalar até que ele retorne. Um await Task.Delay(1) antes do laço deixa o primeiro render passar, mas o laço continua bloqueando tudo o que vem depois.

A pergunta óbvia em seguida é se você pode simplesmente ligar as threads. O runtime de fato suporta <WasmEnableThreads>true</WasmEnableThreads>, mas esse é um recurso de nível de runtime, e o Blazor WebAssembly não o suporta. O renderizador do Blazor depende da garantia histórica de thread única: lotes de render são entregues ao JavaScript via memória compartilhada sem cópia, e eventos são despachados para o .NET de forma síncrona. O runtime multithread move todo o código .NET para uma thread “deputy” em segundo plano, o que quebra as duas premissas. A issue de acompanhamento dotnet/aspnetcore#54365 continua aberta. Ligar a flag em um projeto Blazor WASM te dá um build que não roda, não um app mais rápido.

Então a única opção real é executar uma segunda cópia independente do runtime .NET dentro de um Web Worker, e conversar com ela por troca de mensagens. É exatamente isso que o template constrói.

Criando o projeto worker

Dois comandos e uma referência de projeto:

# .NET 11 SDK
dotnet new blazorwasm -n SampleApp
dotnet new blazorwebworker -n WebWorker

cd SampleApp
dotnet add reference ../WebWorker/WebWorker.csproj

A biblioteca gerada tem esta cara:

WebWorker/
├── WebWorker.csproj
├── WebWorkerClient.cs
├── WorkerMethods.cs
└── wwwroot/
    ├── dotnet-web-worker-client.js
    └── dotnet-web-worker.js

dotnet-web-worker.js é o ponto de entrada do worker. Ele chama dotnet.create() para inicializar um runtime WebAssembly sem nenhuma camada do Blazor, depois getAssemblyExports(assemblyName) para obter um handle sobre seus métodos [JSExport], e resolve contra esse grafo de objetos os nomes de método que chegam. dotnet-web-worker-client.js roda na thread principal, cria o worker e correlaciona requisições com respostas por ID. WebWorkerClient.cs é o wrapper em C# sobre esse cliente JavaScript. Você não precisa editar nenhum dos três.

Uma propriedade de projeto importa e o template já a configura:

<PropertyGroup>
  <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>

[JSExport] e [JSImport] geram código de marshalling que usa ponteiros, então o compilador recusa sem ela. Se mais tarde você adicionar chamadas [JSImport] no próprio projeto do app Blazor, precisa da mesma propriedade lá.

Escrevendo os métodos do worker

Os métodos do worker são static, marcados com [JSExport], e vivem em uma static partial class. O partial não é decorativo: o gerador de código-fonte de interop com JS emite a outra metade. [SupportedOSPlatform("browser")] suprime os avisos do analisador de compatibilidade de plataforma, já que essas APIs só existem no runtime do navegador.

WebWorker/WorkerMethods.cs:

// .NET 11, C# 14
using System.Runtime.InteropServices.JavaScript;
using System.Runtime.Versioning;
using System.Text.Json;

namespace WebWorker;

[SupportedOSPlatform("browser")]
public static partial class WorkerMethods
{
    [JSExport]
    public static int CountPrimes(int limit)
    {
        var count = 0;
        for (var n = 2; n <= limit; n++)
        {
            var isPrime = true;
            for (var d = 2; d * d <= n; d++)
            {
                if (n % d == 0) { isPrime = false; break; }
            }
            if (isPrime) count++;
        }

        return count;
    }

    [JSExport]
    public static string Analyze(string csv)
    {
        var rows = csv.Split('\n', StringSplitOptions.RemoveEmptyEntries);
        var report = new Report(rows.Length, rows.Length == 0 ? 0 : rows.Max(r => r.Length));
        return JsonSerializer.Serialize(report);
    }
}

public record Report(int RowCount, int WidestRow);

Repare no formato de Analyze. [JSExport] faz marshalling de um conjunto fixo de tipos através da fronteira com o JavaScript: primitivos, string, byte[], Task<T> desses, e alguns poucos tipos específicos de JS. Ele não faz marshalling de POCOs ou records arbitrários. A solução padrão é serializar dentro do worker e desserializar do outro lado, que é o que a documentação recomenda e o que o exemplo gerado faz. Se seu payload é uma hierarquia polimórfica, a configuração do discriminador [JsonDerivedType] se aplica aqui sem mudanças, porque as duas pontas são System.Text.Json.

Também vale saber: byte[] cruza diretamente, e o cliente gerado otimiza as transferências de ArrayBuffer para que resultados binários grandes sejam movidos em vez de copiados. Se você está retornando bytes de imagem ou de arquivo, prefira byte[] a base64 dentro de uma string JSON.

Chamando o worker a partir de um componente

WebWorkerClient.CreateAsync inicializa o worker e espera até que o runtime dentro dele reporte que está pronto. Essa é uma operação assíncrona que envolve um download de rede, então ela pertence ao OnAfterRenderAsync, não ao OnInitializedAsync.

Pages/Home.razor.cs:

// .NET 11, C# 14
using System.Text.Json;
using System.Runtime.Versioning;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using WebWorker;

namespace SampleApp.Pages;

[SupportedOSPlatform("browser")]
public partial class Home : ComponentBase, IAsyncDisposable
{
    private WebWorkerClient? worker;
    private string status = "Booting worker...";

    [Inject] private IJSRuntime JSRuntime { get; set; } = default!;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            worker = await WebWorkerClient.CreateAsync(JSRuntime);
            status = "Ready";
            StateHasChanged();
        }
    }

    private async Task Run()
    {
        if (worker is null) return;

        status = "Working...";

        var count = await worker.InvokeAsync<int>(
            "WebWorker.WorkerMethods.CountPrimes", [5_000_000]);

        status = $"Found {count} primes";
    }

    public async ValueTask DisposeAsync()
    {
        if (worker is not null)
        {
            await worker.DisposeAsync();
        }
    }
}

Agora status = "Working..." é renderizado imediatamente, o spinner gira, e a UI continua interativa enquanto cinco milhões de números são fatorados em outra thread do sistema operacional.

O nome do método é uma string: AssemblyName.ClassName.MethodName. O worker a divide e percorre o objeto de exports retornado por getAssemblyExports, então um erro de digitação é uma falha em tempo de execução em vez de um erro de compilação. Envolver cada chamada em um pequeno método tipado em uma classe de serviço vale as dez linhas, porque te dá um único lugar onde as strings mágicas vivem.

A posição em OnAfterRenderAsync não é estilística. Em um Blazor Web App cujo projeto .Client é pré-renderizado no servidor, a interop com JS fica indisponível durante a passagem de prerender, e chamá-la ali lança o erro JavaScript interop calls cannot be issued at this time. OnAfterRenderAsync só roda depois que a interatividade é estabelecida, então o worker é criado exatamente uma vez, no cliente.

Cancelamento e timeouts

Esta é a adição do .NET 11 que torna o cliente utilizável em produção. A superfície completa:

// .NET 11
public sealed class WebWorkerClient : IAsyncDisposable
{
    public static async Task<WebWorkerClient> CreateAsync(
        IJSRuntime jsRuntime,
        int timeoutMs = 60000,
        string? assemblyName = null,
        CancellationToken cancellationToken = default);

    public async Task<TResult> InvokeAsync<TResult>(
        string method,
        object[] args,
        int timeoutMs = 60000,
        CancellationToken cancellationToken = default);

    public async Task InvokeVoidAsync(
        string method,
        object[] args,
        int timeoutMs = 60000,
        CancellationToken cancellationToken = default);

    public async ValueTask DisposeAsync();
}

Tanto timeoutMs quanto o token protegem a espera da thread principal, não a execução do worker. Um método [JSExport] rodando um laço síncrono não consegue observar um CancellationToken, porque não há como interrompê-lo de fora. O que o cancelamento te dá é a capacidade de parar de aguardar e derrubar um worker travado:

// .NET 11, C# 14
private CancellationTokenSource? cts;

private async Task RunCancellable()
{
    cts?.Cancel();
    cts?.Dispose();
    cts = new CancellationTokenSource();

    try
    {
        var count = await worker!.InvokeAsync<int>(
            "WebWorker.WorkerMethods.CountPrimes",
            [5_000_000],
            timeoutMs: 10_000,
            cancellationToken: cts.Token);

        status = $"Found {count} primes";
    }
    catch (OperationCanceledException)
    {
        status = "Cancelled";

        // The worker is still busy. Kill it and start a fresh one.
        await worker.DisposeAsync();
        worker = await WebWorkerClient.CreateAsync(JSRuntime);
    }
}

private void Cancel() => cts?.Cancel();

Descartar depois de um cancelamento é a metade importante. Se você cancela a espera mas mantém o cliente, o cálculo abandonado continua queimando um núcleo e o próximo InvokeAsync fica na fila atrás dele. DisposeAsync chama terminate() no Worker subjacente, o que o para imediatamente, não importa o que ele esteja fazendo. O formato geral de propagar um token por uma cadeia de chamadas está coberto no guia sobre propagar um CancellationToken através de métodos assíncronos, e CancellationTokenSource.CancelAfter se compõe com timeoutMs se você quiser um prazo do lado do cliente que também dispare sua própria limpeza.

Para trabalho cujo resultado você não precisa, InvokeVoidAsync pula a viagem de volta do resultado:

await worker.InvokeVoidAsync("WebWorker.WorkerMethods.WarmCaches", []);

O custo: cada worker baixa seu próprio runtime

Esta é a parte que surpreende as pessoas, e ela dirige a maioria das decisões de design acima.

O worker não compartilha o runtime da thread principal. Ele inicializa um segundo runtime .NET WebAssembly completo: dotnet.js, o .wasm do runtime, e todo assembly que sua biblioteca worker referencia transitivamente. O cache HTTP do navegador faz com que o segundo download normalmente seja barato depois da primeira carga, mas a instanciação não é de graça, e a memória realmente dobra porque os dois runtimes têm heaps separados.

As regras práticas que decorrem disso:

O que não cruza a fronteira

O worker é um runtime genuinamente separado, e tratá-lo como uma thread em segundo plano no mesmo processo é de onde vêm os bugs.

Sem estado compartilhado. Campos estáticos do seu assembly worker existem duas vezes: uma cópia no runtime da thread principal, outra no worker. Escrever em um estático a partir de um componente e lê-lo de um método [JSExport] retorna o que quer que a cópia do worker contenha. Todo o estado precisa viajar nos argumentos e no valor de retorno.

Sem injeção de dependência. Os métodos do worker são estáticos e o runtime do worker nunca constrói um provedor de serviços. Se seu código de cálculo precisa de configuração, passe-a como argumentos ou como um blob JSON.

Sem DOM, sem IJSRuntime, sem NavigationManager. Um Web Worker não tem document nem window. Qualquer coisa que toque a UI precisa acontecer de volta na thread principal depois que InvokeAsync retornar.

Sem callbacks de progresso, de fábrica. O cliente gerado modela requisição e resposta, não streaming. Se você precisa de uma barra de progresso para um cálculo longo, divida o trabalho em pedaços e faça uma chamada por pedaço, atualizando a UI entre as chamadas.

Depuração e trimming, as duas arestas ásperas

Exceções lançadas dentro de um método [JSExport] voltam como uma string de mensagem através do postMessage, então o stack trace de C# que você recebe na thread principal descreve a camada de interop, não seu laço. Quando um método do worker se comporta mal, o caminho mais rápido normalmente é chamar temporariamente o mesmo método estático direto do componente, reproduzi-lo na thread principal com o depurador anexado, e então movê-lo de volta.

Trimming é a segunda coisa a observar. Apps Blazor publicados fazem trimming agressivo, e o worker resolve seus métodos pelo nome em tempo de execução através de getAssemblyExports. O atributo [JSExport] é o que mantém esses métodos enraizados, então um método exportado está seguro. Qualquer coisa que ele alcance apenas por reflexão, não. Se uma chamada ao worker funciona no dotnet run e falha depois do dotnet publish, reflexão mais trimming é a primeira hipótese a testar, e as mesmas regras de segurança de trimming que se aplicam ao Native AOT se aplicam aqui.

Por fim, seja honesto sobre se você precisa disso. Se você está construindo um Blazor Web App em vez de um app WebAssembly standalone, o servidor normalmente consegue fazer o cálculo mais rápido do que o cliente leva para inicializar um segundo runtime, e uma simples chamada de API é menos maquinário para o mesmo resultado. Os trade-offs entre os modelos de hospedagem estão descritos na comparação de Blazor Server, WebAssembly e United. Web Workers são a resposta certa quando os dados já estão no cliente, quando o trabalho é genuinamente intensivo de CPU em vez de intensivo de IO, e quando uma ida e volta ao servidor não é aceitável. Para todo o resto, o servidor continua sendo um thread pool com hardware melhor.

Relacionados

Fontes

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Voltar