Atualizar e renderizar em threads separados

12

Estou criando um mecanismo de jogo 2D simples e quero atualizar e renderizar os sprites em diferentes threads, para aprender como isso é feito.

Preciso sincronizar o thread de atualização e o de renderização. Atualmente, eu uso duas bandeiras atômicas. O fluxo de trabalho se parece com:

Thread 1 -------------------------- Thread 2
Update obj ------------------------ wait for swap
Create queue ---------------------- render the queue
Wait for render ------------------- notify render done
Swap render queues ---------------- notify swap done

Nesta configuração, limito o FPS do thread de renderização ao FPS do thread de atualização. Além disso, eu uso sleep()para limitar o FPS do thread de renderização e atualização para 60, para que as duas funções de espera não esperem muito tempo.

O problema é:

O uso médio da CPU é de cerca de 0,1%. Às vezes, chega a 25% (em um PC quad core). Isso significa que um encadeamento está aguardando o outro, porque a função de espera é um loop while com uma função de teste e configuração, e um loop while usará todos os recursos da CPU.

Minha primeira pergunta é: existe outra maneira de sincronizar os dois threads? Notei que std::mutex::locknão use a CPU enquanto espera bloquear um recurso, para que não seja um loop while. Como funciona? Não posso usar std::mutexporque precisarei travá-los em um segmento e desbloquear em outro.

A outra pergunta é; como o programa é executado sempre a 60 FPS, por que o uso da CPU às vezes aumenta para 25%, o que significa que uma das duas esperas está aguardando muito? (os dois threads são limitados a 60fps, portanto, idealmente, não precisarão de muita sincronização).

Edit: Obrigado por todas as respostas. Primeiro, quero dizer que não inicio um novo thread a cada quadro para renderizar. Eu inicio o loop de atualização e renderização no início. Eu acho que multithreading pode economizar algum tempo: Eu tenho as seguintes funções: FastAlg () e Alg (). Alg () é meu objeto de atualização e objeto de renderização e Fastalg () é minha fila de "envio de renderização para" renderizador "". Em um único encadeamento:

Alg() //update 
FastAgl() 
Alg() //render

Em dois segmentos:

Alg() //update  while Alg() //render last frame
FastAlg() 

Portanto, talvez o multithreading economize o mesmo tempo. (na verdade, em um aplicativo matemático simples, onde alg é um algoritmo longo e fastalg é mais rápido)

Sei que dormir não é uma boa ideia, embora nunca tenha tido problemas. Isso vai melhorar?

While(true) 
{
   If(timer.gettimefromlastcall() >= 1/fps)
   Do_update()
}

Mas este será um loop while infinito que usará toda a CPU. Posso usar o sono (um número <15) para limitar o uso? Dessa forma, ele será executado a, por exemplo, 100 fps, e a função de atualização será chamada apenas 60 vezes por segundo.

Para sincronizar os dois threads, usarei waitforsingleobject com createSemaphore, para poder bloquear e desbloquear em threads diferentes (sem usar um loop while), não é?

Liuka
fonte
5
"Não diga que meu multithreading é inútil neste caso, eu só quero aprender como fazê-lo" - nesse caso, você deve aprender as coisas corretamente, ou seja, (a) não use sleep () para controlar o quadro raro , nunca , e (b) evitar o design de encadeamento por componente e executar a etapa de bloqueio, em vez disso, divida o trabalho em tarefas e lide com tarefas de uma fila de trabalho.
Damon
1
O @Damon (a) sleep () pode ser usado como um mecanismo de taxa de quadros e é de fato bastante popular, embora eu tenha que concordar que existem opções muito melhores. (b) O usuário aqui deseja separar a atualização e a renderização em dois segmentos diferentes. Essa é uma separação normal em um mecanismo de jogo e não é tão "thread por componente". Ele oferece vantagens claras, mas pode trazer problemas se feito incorretamente.
Alexandre Desbiens
@AlphSpirit: O fato de algo ser "comum" não significa que não está errado . Mesmo sem entrar em cronômetros divergentes, a mera granularidade do sono em pelo menos um sistema operacional de desktop popular é motivo suficiente, senão a falta de confiabilidade por design em todos os sistemas de consumo existentes. Explicar por que a separação da atualização e da renderização em dois threads, conforme descrito, é imprudente e causa mais problemas do que vale a pena levar muito tempo. O objetivo do OP é declarado como aprender como é feito , que deve ser aprendido como é feito corretamente . Muitos artigos sobre o design moderno do motor MT.
Damon
@ Damon Quando eu disse que era popular ou comum, não quis dizer que estava certo. Eu só quis dizer que foi usado por muitas pessoas. "... embora eu tenha que concordar que há opções muito melhores" significava que de fato não é uma maneira muito boa de sincronizar o tempo. Desculpe pelo mal entendido.
Alexandre Desbiens
@AlphSpirit: Não se preocupe :-) O mundo está cheio de coisas que muitas pessoas fazem (e nem sempre por uma boa razão), mas quando alguém começa a aprender, ainda deve tentar evitar as mais obviamente erradas.
Damon

Respostas:

25

Para um mecanismo 2D simples com sprites, uma abordagem de thread único é perfeitamente boa. Mas como você deseja aprender a executar multithreading, deve aprender a fazê-lo corretamente.

Não

  • Use 2 threads que executam mais ou menos etapas de bloqueio, implementando um comportamento de thread único com vários threads. Isso tem o mesmo nível de paralelismo (zero), mas adiciona sobrecarga para alternância de contexto e sincronização. Além disso, a lógica é mais difícil de entender.
  • Use sleeppara controlar a taxa de quadros. Nunca. Se alguém lhe disser, acerte-o.
    Primeiro, nem todos os monitores funcionam a 60Hz. Segundo, dois cronômetros marcando na mesma velocidade, correndo lado a lado, sempre acabam sempre fora de sincronia (solte duas bolas de pingue-pongue em uma mesa da mesma altura e ouça). Terceiro, sleepé por design nem preciso nem confiável. A granularidade pode ser tão ruim quanto 15,6ms (na verdade, o padrão no Windows [1] ), e um quadro é de apenas 16,6ms a 60fps, o que deixa apenas 1ms para todo o resto. Além disso, é difícil obter 16,6 para ser um múltiplo de 15,6 ...
    Além disso, sleepé permitido (e às vezes!) Retornar somente após 30, 50 ou 100 ms, ou ainda mais.
  • Use std::mutexpara notificar outro segmento. Não é para isso que serve.
  • Suponha que o TaskManager seja bom em dizer o que está acontecendo, especialmente a julgar por um número como "25% da CPU", que pode ser gasto no seu código ou no driver do modo de usuário ou em outro lugar.
  • Tenha um thread por componente de alto nível (é claro que existem algumas exceções).
  • Crie threads em "horários aleatórios", ad hoc, por tarefa. A criação de threads pode ser surpreendentemente cara e eles podem levar um tempo surpreendentemente longo antes que eles façam o que você disse de maneira aguda (especialmente se você tiver muitas DLLs carregadas!).

Faz

  • Use multithreading para que as coisas sejam executadas de forma assíncrona o máximo possível. Velocidade não é a idéia principal de encadear, mas fazer as coisas em paralelo (por isso, mesmo que demorem mais no total, a soma de tudo ainda é menor).
  • Use a sincronização vertical para limitar a taxa de quadros. Essa é a única maneira correta (e sem falhas) de fazê-lo. Se o usuário o substituir no painel de controle do driver da tela ("forçar o desligamento"), que assim seja. Afinal, é o computador dele, não o seu.
  • Se você precisar "marcar" algo em intervalos regulares, use um cronômetro . Os temporizadores têm a vantagem de ter uma precisão e confiabilidade muito melhores em comparação com sleep[2] . Além disso, um cronômetro recorrente contabiliza o tempo corretamente (incluindo o tempo que passa no meio), enquanto o sono por 16,6 ms (ou 16,6 ms menos o tempo_medido_elapsado) não.
  • Execute simulações de física que envolvam integração numérica em uma etapa de tempo fixo (ou suas equações explodirão!), Interpole gráficos entre as etapas (isso pode ser uma desculpa para um thread por componente separado, mas também pode ser feito sem).
  • Use std::mutexpara que apenas um encadeamento acesse um recurso por vez ("excluir mutuamente") e siga a semântica estranha de std::condition_variable.
  • Evite ter threads competindo por recursos. Bloqueie o mínimo necessário (mas não menos importante!) E mantenha os bloqueios apenas pelo tempo absolutamente necessário.
  • Compartilhe dados somente leitura entre os segmentos (sem problemas de cache e sem bloqueio), mas não modifique dados simultaneamente (precisa de sincronização e mata o cache). Isso inclui a modificação de dados próximos a um local que outra pessoa possa ler.
  • Use std::condition_variablepara bloquear outro encadeamento até que alguma condição seja verdadeira. A semântica std::condition_variabledesse mutex extra é reconhecidamente bastante estranha e distorcida (principalmente por razões históricas herdadas dos encadeamentos POSIX), mas uma variável de condição é a primitiva correta a ser usada para o que você deseja.
    Caso você ache std::condition_variableestranho demais para se sentir confortável com isso, você também pode simplesmente usar um evento do Windows (um pouco mais lento) ou, se tiver coragem, criar seu próprio evento simples em torno do NtKeyedEvents (envolve coisas assustadoras de baixo nível). Ao usar o DirectX, você já está vinculado ao Windows de qualquer maneira, portanto, a perda de portabilidade não deve ser demais.
  • Divida o trabalho em tarefas de tamanho razoável, executadas por um conjunto de encadeamentos de trabalho de tamanho fixo (não mais que um por núcleo, sem contar os núcleos hiperencadeados). Deixe as tarefas de acabamento enfileirarem as tarefas dependentes (sincronização automática e gratuita). Faça tarefas que tenham pelo menos algumas centenas de operações não triviais cada (ou uma operação de bloqueio de comprimento como uma leitura de disco). Preferir acesso contíguo ao cache.
  • Crie todos os threads no início do programa.
  • Aproveite as funções assíncronas que o sistema operacional ou a API gráfica oferece para um paralelismo melhor / adicional, não apenas no nível do programa, mas também no hardware (pense em transferências PCIe, paralelismo CPU-GPU, DMA de disco etc.).
  • 10.000 outras coisas que eu esqueci de mencionar.


[1] Sim, você pode definir a taxa do agendador para 1ms, mas isso é mal visto, pois causa muito mais alternância de contexto e consome muito mais energia (em um mundo em que mais e mais dispositivos são dispositivos móveis). Também não é uma solução, pois ainda não torna o sono mais confiável.
[2] Um timer aumentará a prioridade do thread, o que permitirá interromper outro quantum médio de prioridade igual e ser agendado primeiro, o que é um comportamento quase-RT. É claro que não é verdade RT, mas chega muito perto. Despertar do sono significa apenas que o encadeamento fica pronto para ser agendado em algum momento, sempre que possível.

Damon
fonte
Você pode explicar por que não deve "Ter um thread por componente de alto nível"? Você quer dizer que não se deve misturar física e áudio em dois tópicos separados? Não vejo motivo para não fazê-lo.
Elviss Strazdins
3

Não tenho certeza do que você deseja alcançar limitando o FPS da atualização e renderizando ambos para 60. Se você os limitar ao mesmo valor, poderá colocá-los no mesmo segmento.

O objetivo ao separar Atualização e Renderização em threads diferentes é ter ambos "quase" independentes um do outro, para que a GPU possa render 500 FPS e a lógica de Atualização ainda vá para 60 FPS. Você não obtém um ganho de desempenho muito alto ao fazer isso.

Mas você disse que só queria saber como funciona, e está tudo bem. No C ++, um mutex é um objeto especial usado para bloquear o acesso a determinados recursos para outros threads. Em outras palavras, você usa um mutex para tornar os dados sensíveis acessíveis por apenas um thread por vez. Para fazer isso, é bastante simples:

std::mutex mutex;
mutex.lock();
// Do sensible stuff here...
mutex.unlock();

Fonte: http://en.cppreference.com/w/cpp/thread/mutex

EDIT : Verifique se o seu mutex é da classe ou de todo o arquivo, como no link fornecido, ou cada thread criará seu próprio mutex e você não conseguirá nada.

A primeira thread a bloquear o mutex terá acesso ao código interno. Se um segundo thread tentar chamar a função lock (), ele bloqueará até que o primeiro thread a desbloqueie. Portanto, um mutex é uma função de bloqueio, ao contrário de um loop while. As funções de bloqueio não sobrecarregam a CPU.

Alexandre Desbiens
fonte
E como funciona o bloco?
Liuka
Quando o segundo thread chamar lock (), ele aguardará pacientemente o primeiro thread desbloquear o mutex e continuará na próxima linha depois (neste exemplo, do material sensível). EDIT: O segundo thread irá bloquear o mutex para si.
Alexandre Desbiens
1
Use std::lock_guardou similar, não .lock()/ .unlock(). RAII não é apenas para gerenciamento de memória!
22124 bcrist