Voltar para o Blog
Quest Log

Multithreading na Godot 4 com WorkerThreadPool e Thread

Diagrama de uma tarefa pesada rodando em thread paralela enquanto o loop principal da Godot mantém o FPS estável

Guia prático de multithreading na Godot 4: rode geração procedural, pathfinding e carregamento pesado em paralelo com WorkerThreadPool e Thread sem travar o FPS.

O trabalho pesado que trava o frame

A Godot roda seu jogo num loop. A cada frame ela chama _process, desenha a cena e entrega o resultado na tela. Esse ciclo precisa terminar rápido: pra rodar a 60 FPS, cada frame tem cerca de 16 milissegundos pra fazer tudo. Se você enfia um cálculo pesado dentro do _process, um mapa procedural gigante ou o pathfinding de duzentas unidades ao mesmo tempo, o frame estoura o orçamento de tempo e o jogo trava. A tela congela por um instante, o áudio engasga, e o jogador sente aquele soluço horrível.

Multithreading na Godot resolve isso tirando o trabalho pesado do loop principal e mandando pra rodar em paralelo, numa outra thread. Enquanto a thread paralela sua pra terminar o cálculo, a thread principal continua desenhando frames de boa. O jogo não trava. Neste post você vai ver os dois caminhos que a Godot 4 oferece, a classe Thread pra uma tarefa isolada e o WorkerThreadPool pra várias tarefas, além do Mutex pra proteger dados compartilhados e a regra de ouro que separa quem usa thread de quem quebra o jogo com ela.

Antes de mais nada, um aviso honesto: se você ainda não tem um gargalo real, isso aqui não é pra você agora. Vale muito mais otimizar o FPS na Godot com técnicas simples primeiro. Thread é a última carta, não a primeira.

O básico com a classe Thread

A classe Thread é a forma mais direta. Você cria uma thread, dá pra ela uma função pra executar e, quando ela termina, você pega o resultado. A função que roda na thread recebe um Callable, que é basicamente uma referência pra um método seu.

Olha um exemplo tipado de geração procedural rodando fora do frame:

extends Node

var thread: Thread = Thread.new()
var mapa_pronto: Array = []

func _ready() -> void:
    # start recebe um Callable: o metodo que vai rodar na outra thread
    thread.start(_gerar_mapa.bind(256))

func _gerar_mapa(tamanho: int) -> void:
    var resultado: Array = []
    for x in range(tamanho):
        for y in range(tamanho):
            # calculo pesado de terreno, ruido, biomas...
            resultado.append(_calcular_celula(x, y))
    mapa_pronto = resultado

func _calcular_celula(x: int, y: int) -> float:
    return sin(x * 0.1) * cos(y * 0.1)

func _exit_tree() -> void:
    # SEMPRE espere a thread terminar antes de destruir o no
    if thread.is_started():
        thread.wait_to_finish()

Os métodos que importam aqui são poucos e vale decorar. Thread.new() cria a thread. start(Callable) dispara a função na thread paralela. is_alive() retorna true enquanto a função ainda está rodando, útil pra checar sem bloquear. E wait_to_finish() bloqueia a thread principal até a paralela acabar e devolve o valor retornado pela função.

O detalhe que muita gente esquece: você precisa chamar wait_to_finish() em algum momento, senão a Godot reclama que a thread ficou pendurada. O _exit_tree é um bom lugar pra garantir isso ao fechar a cena. Repare que eu uso is_started() antes, pra não chamar wait_to_finish() numa thread que nunca rodou.

Um jeito comum de consumir o resultado sem travar é checar is_alive() dentro do _process e só ler os dados quando a thread terminou:

func _process(_delta: float) -> void:
    if thread.is_started() and not thread.is_alive():
        thread.wait_to_finish()
        _aplicar_mapa(mapa_pronto)

Como a thread já terminou quando is_alive() vira false, o wait_to_finish() aqui retorna na hora, sem bloqueio real. Ele serve pra fechar a thread de forma limpa.

WorkerThreadPool para várias tarefas

A classe Thread é ótima pra uma tarefa isolada e longa. Mas quando você tem muitas tarefas curtas, criar e destruir uma Thread pra cada uma é desperdício. É aí que entra o WorkerThreadPool, um pool de threads que a própria engine mantém pronto. Você só entrega tarefas e ele distribui pelos núcleos disponíveis.

O WorkerThreadPool é um singleton, você não instancia nada. Adiciona uma tarefa com add_task(callable), que retorna um int, o id da tarefa. Guarde esse id, é por ele que você acompanha o andamento.

extends Node

var task_id: int = -1

func _ready() -> void:
    # add_task retorna um int (o id da tarefa)
    task_id = WorkerThreadPool.add_task(_processar_lote)

func _processar_lote() -> void:
    for i in range(1000):
        _trabalho_pesado(i)

func _trabalho_pesado(indice: int) -> void:
    # calculo caro por item
    pass

func _process(_delta: float) -> void:
    if task_id != -1 and WorkerThreadPool.is_task_completed(task_id):
        # a tarefa acabou; agora e seguro finalizar
        WorkerThreadPool.wait_for_task_completion(task_id)
        task_id = -1
        print("Lote processado")

Dois métodos guiam o fluxo. WorkerThreadPool.is_task_completed(id) retorna true quando a tarefa terminou, sem bloquear, perfeito pra checar todo frame. E WorkerThreadPool.wait_for_task_completion(id) bloqueia até a tarefa acabar. Como no exemplo eu só chamo o wait_for_task_completion depois que o is_task_completed já deu true, ele retorna imediatamente e serve pra liberar a tarefa do pool de forma correta.

Quando você tem o mesmo trabalho repetido muitas vezes, como calcular o caminho de várias unidades, existe o add_group_task. Ele recebe um Callable e o número de elementos, e chama seu método uma vez pra cada índice, distribuindo os índices pelas threads do pool:

var group_id: int = -1

func _ready() -> void:
    # roda _processar_unidade(i) para i de 0 a 199, em paralelo
    group_id = WorkerThreadPool.add_group_task(_processar_unidade, 200)

func _processar_unidade(indice: int) -> void:
    # calcula o pathfinding da unidade de numero 'indice'
    pass

func _process(_delta: float) -> void:
    if group_id != -1 and WorkerThreadPool.is_group_task_completed(group_id):
        WorkerThreadPool.wait_for_group_task_completion(group_id)
        group_id = -1

Esse padrão combina bem com cálculo em lote de rotas. Se você ainda está montando a navegação do seu jogo, vale ver antes como fazer pathfinding com Navigation2D, porque o NavigationServer da Godot já processa boa parte disso fora da thread principal e talvez você nem precise montar o pool na mão.

Próximo nível
Quer aprender isso na prática?

No CursoGame.Dev você sai dos tutoriais soltos e constrói jogos publicáveis, com trilha progressiva, quests práticas e feedback real.

Conhecer a plataforma
+500 alunos4.9/5Garantia 7 dias

Mutex e dados compartilhados

Aqui mora o perigo de verdade. Quando duas threads leem e escrevem a mesma variável ao mesmo tempo, o resultado é imprevisível. Uma pode ler pela metade enquanto a outra escreve, e você ganha um bug que só aparece de vez em quando, quase impossível de reproduzir. Esse tipo de corrida por dado compartilhado é o pesadelo clássico de quem começa com threads.

A ferramenta pra isso é o Mutex, uma trava. Só uma thread por vez consegue segurar a trava. As outras esperam a vez. Você tranca antes de mexer no dado compartilhado e destranca depois.

var mutex: Mutex = Mutex.new()
var pontos_gerados: int = 0

func _somar_pontos(valor: int) -> void:
    mutex.lock()
    pontos_gerados += valor
    mutex.unlock()

A regra é manter o trecho entre lock() e unlock() o menor possível. Você tranca, faz a operação mínima no dado compartilhado e destranca na hora. Trabalho pesado deve ficar fora da trava, senão as threads passam o tempo todo esperando umas às outras e você perde o paralelismo que foi buscar.

Na prática, um bom design evita muito Mutex: cada thread trabalha no próprio pedaço de dado, sem cruzar com as outras, e só no fim os resultados se juntam. No exemplo do mapa lá em cima, cada thread poderia preencher uma fatia diferente do array. Menos compartilhamento significa menos trava e menos dor de cabeça.

A regra de ouro: não mexa na cena de outra thread

Se você guardar só uma coisa deste post, guarde esta. A árvore de cena da Godot não é thread-safe. Criar um nó, adicionar com add_child, mudar a position de um sprite, tocar uma animação, nada disso pode ser feito de dentro de uma thread paralela. Se você fizer, o jogo pode funcionar mil vezes e crashar na milésima primeira, ou corromper o estado interno da engine de um jeito silencioso.

A saída é call_deferred. Você calcula o que precisar na thread, com números e arrays puros, e quando o resultado está pronto você pede pra thread principal aplicar a mudança na cena. O call_deferred agenda a chamada pra rodar no início do próximo frame, já de volta na thread segura.

func _gerar_e_aplicar() -> void:
    # isso roda na thread paralela: so calculo, nada de nos
    var dados: Array = _gerar_mapa_dados()
    # volta pra thread principal pra tocar na cena
    call_deferred("_aplicar_na_cena", dados)

func _aplicar_na_cena(dados: Array) -> void:
    # aqui ja estamos na thread principal: seguro criar nos
    for celula in dados:
        var bloco: Sprite2D = Sprite2D.new()
        add_child(bloco)

O padrão mental é sempre o mesmo: calcule na thread, aplique na main thread. A thread paralela produz dados crus. A thread principal transforma esses dados em nós. Enquanto você respeitar essa fronteira, o multithreading na Godot vira uma ferramenta confiável em vez de uma fonte de crash aleatório.

Quando NÃO usar thread

Vou ser direto: a maioria dos jogos simples não precisa de thread nenhuma. Um platformer, um puzzle, um RPG de escopo pequeno rodam liso na thread principal porque nenhum cálculo deles estoura o orçamento do frame. Adicionar thread onde não precisa só traz complexidade, bugs difíceis e um código que ninguém mais entende.

Pense em thread só quando você tem um gargalo medido. Abra o profiler, veja qual função come o frame, e só então decida. E mesmo com gargalo, thread nem sempre é a melhor resposta. Duas alternativas costumam resolver sem o risco:

Otimizar o algoritmo. Muita lentidão vem de um loop mal feito ou de uma estrutura de dados errada, não de falta de núcleos. Um Dictionary no lugar de uma busca linear pode ser mais rápido que espalhar o problema por quatro threads.

Dividir o trabalho em pedaços. Em vez de gerar o mapa inteiro num frame, gere um pedaço por frame ao longo de vários frames. Isso se chama fatiar o trabalho e resolve muitos casos de travamento sem tocar em thread alguma, mantendo o código simples e sequencial.

Sobre linguagem: threads existem tanto em GDScript quanto em C#, com APIs parecidas. Se seu gargalo é CPU puro e extremo, C# pode dar um ganho a mais no cálculo em si, mas isso é decisão de projeto, não de tutorial. Se você está nessa dúvida, dá uma olhada no comparativo de C# vs GDScript na Godot antes de reescrever qualquer coisa.

Próximo passo

Resumindo o caminho: Thread pra uma tarefa longa e isolada, WorkerThreadPool pra muitas tarefas ou um lote com add_group_task, Mutex pra proteger o pouco de dado que precisa ser compartilhado, e call_deferred pra voltar à thread principal toda vez que for tocar na cena. A regra de ouro acima de tudo: calcule na thread, aplique na main thread.

O próximo passo prático é escolher um único gargalo real do seu jogo, medir quanto tempo ele leva no frame, e só então mover esse cálculo pra uma Thread seguindo o primeiro exemplo. Comece pequeno, com uma tarefa só, confirme que o FPS parou de travar, e vá crescendo dali. Multithreading recompensa quem avança devagar e testa cada passo.

Perguntas frequentes

Posso mexer em um nó da cena de dentro de uma thread?

Não. A árvore de cena não é thread-safe. Calcule o resultado na thread e volte pra thread principal com call_deferred pra criar, mover ou alterar qualquer nó.

Qual a diferença entre Thread e WorkerThreadPool?

Thread é uma única thread que você cria e gerencia na mão. WorkerThreadPool é um pool pronto da engine que distribui várias tarefas por um id, ideal quando você tem muitas tarefas curtas ou um lote pra processar.

Meu jogo simples precisa de multithreading?

Provavelmente não. Só vale a pena quando um cálculo pesado trava o frame de forma visível. Antes de partir pra thread, tente otimizar o algoritmo ou dividir o trabalho em pedaços.