Threads, apenas um exemplo

placeholderantigo

ThreadsThreads são partes de um programa que executam paralelamente. Com seu uso é possível executar simultâneamente duas ou mais rotinas. Atualmente, com o barateamento dos processadores de vários núcleos (multi core), seu uso fica mais justificado. Embora eu não recomende trabalhar com threads, convém entender o que elas representam na prática.

Antes de tudo vou ressaltar: não recomendo trabalhar com threads a menos que seja o desenvolvimento de um jogo AAA, o qual não é foco deste site (digamos um Batman: Asilo Arkhan). Jogos assim, exigem intenso processamento de I.A., física e gráficos e possuem, em geral, muito dinheiro para serem feito. Isso justifica a utilização de uma forma de programação mais complexa. Ainda mais que estes jogos em geral são criados em C++, uma das piores linguagens para trabalhar com threads. Mesmo no Java, cujo uso é relativamente simples (como veremos abaixo), depurar um programa com thread é um inferno.

Além disso, projetar o código para tirar proveito de threads é tarefa para arquitetos competentes – caso contrário você vai criar um código complexo, difícil de depurar e manter e, provavelmente, vai cair em dead locks e outros problemas de concorrência . E ainda corre o risco de acabar com um código pouco eficiente, apesar do esforço adicional.

Fica então o aviso: o código abaixo serve para efeito de exemplo e não deve ser base para um jogo do mundo real.

Bom, se depois do que foi dito acima você ainda não desistiu de ler sobre threads, vamos em frente – o fato é que o conceito por trás delas, e sua correta utilização, são importantes para tirar todo proveito de uma CPU com múltiplos núcleos. Vamos ver como fazer isso com o gameloop desenvolvidos nos meus artigos anteriores. Se você não leu, filtre os posts pela tag Java e leia-os antes, ok?

Modularizando o jogo

Começo criando duas classes especializadas em atualizar a lógica do jogo (lembra do update?) e em atualizar a tela (lembra do render?). Chamarei elas de Controlador e Renderizador, respectivamente. Seu código está abaixo. Basicamente, eu tirei as rotinas update() e render() da minha classe do jogo e coloquei em classes separadas.

Arquivo Controlador.java

001package abrindoojogo.exemplos.thread;
002
003import javax.swing.JFrame;
004
005public class Controlador
006{
007    Contador contador;
008    JFrame frame;
009    Nave nave;
010
011    public Controlador(JFrame frame, Contador contador, Nave nave)
012    {
013        this.frame = frame;
014        this.contador = contador;
015        this.nave = nave;
016    }
017
018    public void update()
019    {
020        contador.contaPulso();
021        nave.x += 1;
022        if (nave.x > frame.getWidth() + 30)
023        {
024            nave.x = 0;
025        }
026    }
027}

Arquivo Renderizador.java

001package abrindoojogo.exemplos.thread;
002
003import java.awt.Color;
004import java.awt.Graphics2D;
005import java.awt.image.BufferStrategy;
006import javax.swing.JFrame;
007
008public class Renderizador
009{
010    BufferStrategy bs;
011    Contador contador;
012    JFrame frame;
013    Nave nave;
014    public boolean terminado = false;
015
016    public Renderizador(JFrame frame, BufferStrategy bs,
017                        Contador contador, Nave nave)
018    {
019        this.frame = frame;
020        this.bs = bs;
021        this.contador = contador;
022        this.nave = nave;
023    }
024
025    public void render()
026    {
027        contador.contaFrame();
028        Graphics2D g = (Graphics2D) bs.getDrawGraphics();
029        g.setColor(Color.black);
030        g.fillRect(0, 0, frame.getWidth(), frame.getHeight());
031        int x = nave.x;
032        int y = nave.y;
033        for (int i = 0; i < nave.qtd; i++)
034        {
035            g.setColor(Color.yellow);
036            g.drawLine(x, y, x - 20, y - 5);
037            g.drawLine(x, y, x - 20, y + 5);
038            g.drawLine(x - 20, y - 5, x - 20, y + 5);
039            y += 15;
040            if (y > 550)
041            {
042                y = nave.y;
043                x += 15;
044            }
045        }
046        g.setColor(Color.white);
047        g.drawString("Pulsos: " + contador.getPulsosPorSegundo() +
048                     "  Frames: " + contador.getFramesPorSegundo() +
049                     "          naves: " + nave.qtd, 10, 20);
050        g.dispose();
051        bs.show();
052    }
053}

Veja que essas classes recebem “de fora”, no momento de sua construção, os objetos necessários para seu funcionamento. Ou seja, elas não possuem dados próprios, encapsulando apenas o algoritmo. Para que elas compartilhem os mesmos dados, criei também uma classe separada para armazenar os dados da nave:

Arquivo Nave.java

001package abrindoojogo.exemplos.thread;
002
003public class Nave
004{
005    int x;
006    int y;
007    int qtd;
008}

A classe do jogo em sí é exatamente igual à classe JogoLoopSimples vista em um artigo anterior, só que sem update() e sem render() e com o gameloop modificado conforme abaixo:

001public void gameloop()
002    {
003        initialize();
004        Controlador c = new Controlador(this, contador, nave);
005        Renderizador r = new Renderizador(this, bs, contador, nave);
006        while (true)
007        {
008            c.update();
009            r.render();
010        }
011    }

Além disso, mais uma pequena modificação: ao sair do programa (pressionar ESC), envio para o console os dados de pulsos e frames, para registro. Isso é feito com um System.out.println() chamado na rotina keyPressed():

001public void keyPressed(KeyEvent e)
002    {
003        if (e.getKeyCode() == KeyEvent.VK_ESCAPE)
004        {
005            System.out.println("Pulsos: " + contador.getPulsosPorSegundo() +
006                                "  Frames: " + contador.getFramesPorSegundo() +
007                                "          naves: " + nave.qtd);
008            System.exit(0);
009        }
010        ...

Executando o programa assim, temos o seguinte resultado:

Thread01a

Thread01b

Reconhece a tela? É o mesmo protótipo da nave dos outros posts. Mas perai… Uau! 460 fps! Não eram apenas 40! Como foi que ficou mais rápido?

Simples: troquei de máquina… Veja: essa é uma máquina bem mais rápida e tem um processador de dois núcleos como podemos ver no painel do gerenciador de tarefas. Note outra coisa também: apesar do jogo estar programado de forma a rodar o mais rápido possível (sem limitação de pulsos), ele utiliza apenas 50% do processamento da máquina. Nos gráficos pode-se ver claramente que o jogo está ocupando praticamente todo núcleo da esquerda, enquanto o da direita está ocioso (quase ocioso, na verdade, está atendendo o S.O. e processos de fundo, como o antivirus).

Abaixo da tela está a saída do console do NetBeans, mostrando o número de pulsos e frames registrados ao fechar o programa.

A soma das partes

Modifiquemos agora nosso gameloop comentando o update(). Assim vamos ver quantos frames conseguimos se eliminarmos o trabalho de atualizar a lógica do jogo.

A saída é a seguinte:

Thread02a

Thread02bVeja que temos zero pulsos, já que update() nunca foi executada. Mas a quantidade de frames não mudou. Ou seja, o impacto de processamento da nossa lógica é muito pequeno. Vejamos o contrário. Vamos comentar o render().

Resultado abaixo:

Thread03aThread03bPois é, sem o render() não aparece a tela. Mas o programa está lá, rodando, como podemos ver pelo processamento. Ao pressionar ESC basta olhar para a saída para ver a quantidade de pulsos e… Caraca! Mais de três milhões e meio de pulsos por segundo! Realmente, a parte (bem) mais pesada é a atualização da tela. A atualização da lógica (nesse caso, pelo menos) é tão efêmera que esse computador consegue realizar mais de 3.000.000 em um segundos contra apenas 460 atualizações da tela no mesmo tempo.

Analisando os resultados, entram as Threads

A primeira coisa importante é ver como o render() segura o jogo quando usamos esse tipo de gameloop, que é o mais simples. A cada volta do laço temos um update() (muito rápido) e um render(), que demora e faz com que o próximo update() acabe demorando a ser executado.

Mas o que conta para este post é o seguinte: reveja as imagens acima e preste atenção no processamento. Ele nunca passa de 50%. Ou seja, meu jogo não está tirando tudo da minha máquina nova! Praticamente ele só usa o núcleo da esquerda, enquanto o outro fica com pouco processamento, oriundo das tarefas do sistema. Isso é bom, na verdade. Mas e se eu precisasse de mais processamento? Teria uma forma de usar o outro núcleo? Tem sim, com threads.

Você sabe que o S.O. é multitarefa, ou seja, executa vários programas ao mesmo tempo. Você pode ver um vídeo e ouvir música ao mesmo simultâneamente, porque o S.O dá um pouco de processador para o vídeo e um pouco para a música. É a mesma CPU, porém dividida entre dois programas.

Se tem mais de uma CPU (mais de um núcleo, como na máquina acima), o S.O. pode dar um núcleo inteiro para o vídeo e o outro núcleo para a música, ao invés de dividir a mesma CPU. Isso torna as coisas mais ágeis ainda.

As threads permitem ter esta multitarefa dentro do mesmo programa. Por exemplo, você cria uma thread para carregar os dados do jogo e outra para rodar uma animação. Assim, o S.O. dá um pouco de processamento para cada uma, e o resultado é que você pode mostrar uma animação enquanto os dados são carregados.

A modificação que faço a seguir no código vai permitir colocar o Controlador em uma thread e o Renderizador em outra, de forma a executarem paralelamente e, de quebra, rodar cada um em um núcleo, utilizando todo o poder da máquina. Se o render() sozinho dá 460 frames e update(), também sozinho, dá 3.000.000, agora deverei obter o jogo rodando simultâneamente com 3.000.000 pulsos e 460 frames.

Vamos ver se funciona:

001public class Controlador implements Runnable
002{
003    Contador contador;
004    JFrame frame;
005    Nave nave;
006
007    public Controlador(JFrame frame, Contador contador, Nave nave)
008    {
009        this.frame = frame;
010        this.contador = contador;
011        this.nave = nave;
012    }
013
014    public void run()
015    {
016        while (true)
017        {
018            Thread.yield();
019            update();
020        }
021    }
022
023    public void update()
024    {
025        ...
026
027public class Renderizador implements Runnable
028{
029    BufferStrategy bs;
030    Contador contador;
031    JFrame frame;
032    Nave nave;
033    public boolean terminado = false;
034
035    public Renderizador(JFrame frame, BufferStrategy bs, Contador contador, Nave nave)
036    {
037        this.frame = frame;
038        this.bs = bs;
039        this.contador = contador;
040        this.nave = nave;
041    }
042
043    public void run()
044    {
045        while (!terminado)
046        {
047            Thread.yield();
048            render();
049        }
050    }
051
052    public void render()
053    {
054        ...

Modifiquei o código das classes Controlador e Renderizador para que implementem a interface Runnable do Java. Para isso, também declarei o método run() e dentro dele coloquei um laço. O Renderizador tem um laço que fica chamando render(). O Controlador tem um laço que fica chamando o update().

Agora a modificação no gameloop:

001public void gameloop()
002    {
003        initialize();
004        Controlador c = new Controlador(this, contador, nave);
005        Renderizador r = new Renderizador(this, bs, contador, nave);
006        Thread t;
007        t = new Thread(c);
008        t.start();
009        t = new Thread(r);
010        t.start();
011    }

Aqui está o uso das threads, finalmente. Basta criar um objeto do tipo Thread informando para ele um objeto do tipo Runnable (o Renderizador, por exemplo). Depois chamamos o método start() da thread e ela vai chamar o método run() do objeto que lhe foi passado. O método run() será executado em paralelo ao resto do programa. Então, estamos colocando o laço do render() e o laço do update() para rodarem em paralelo.

Curioso para ver o resutado? Está abaixo:

Thread04a

Voilá! Um milhão e oitocentos mil pulsos e 459 frames. E usado 100% da CPU. Veja que agora os dois núcleos estão absolutamente carregados – a linha do gráfico está “cravada” no topo.

Mas porque deu valores menores do que quando rodamos cada um separadamente? Bom, é que a CPU ainda precisa atender os tais processos de fundo, S.O., antivirus, etc. Eles devem estar rodando no mesmo núcleo do update(), o que fez ele executar mais lentamente. Mas foi uma boa escolha do S.O., já que esse é o processo mais leve. Inteligentes estas máquinas modernas, não?

De qualquer forma, é um desempenho muito acima da primeira versão que eu mostrei.

Conclusão

Não usem threads.

Pelo menos não até chegar a um ponto onde não tenha mais o quê otimizar no código de vocês e ainda assim o jogo esteja lento. Uma minoria de jogos atuais faz uso intensivo de threads (menos de 20, pelo que sei), e todos especialistas concordam que não é fácil.

O que fiz no código acima, compartilhar um objeto (a nave) entre duas threads rodando paralelamente é perigoso. Nesse caso uma delas atualiza os dados e a outra apenas lê. Mas se duas threads atualizarem os mesmos dados ao mesmo tempo, as consequencias serão imprevisíveis. O Java facilita porque possui formas de sincronizar dados, impedindo sua leitura simultaneamente por duas threads. Mas não adianta sincronizar tudo, senão uma thread vai acabar esperando pela outra para poder acessar os dados e vão acabar rodando em sequencia, ao invés de em paralelo.

Por outro lado, o Java, por sí,  já tira proveito de processadores de múltiplos núcleos. Existem outros processos que a máquina virtual faz, como coleta de lixo e otimização interativa do código. A JVM (Máquina Virtual Java) faz uso de threads para separar e rodar em paralelo estas operações, melhorando o desempenhdo do seu programa sem você ter que fazer nada.

Bom, fica aí um exemplo de uso de threads para quem não conhecia.

Baixe o código fonte deste artigo.

<pre style=”font-family:Monospaced,monospace;color:#000000″><br/><span style=’color:#666;background-color:#DDD;border-right:1px #999 solid;margin-right:5px;padding:2px’>001</span><span style=”color:#0000e6;”>package</span> abrindoojogo.exemplos.thread;<span style=”color:#000000;”><br/><span style=’color:#666;background-color:#DDD;border-right:1px #999 solid;margin-right:5px;padding:2px’>002</span><br/><span style=’color:#666;background-color:#DDD;border-right:1px #999 solid;margin-right:5px;padding:2px’>003</span></span><span style=”color:#0000e6;”>public</span> <span style=”color:#0000e6;”>class</span> Nave<span style=”color:#000000;”><br/><span style=’color:#666;background-color:#DDD;border-right:1px #999 solid;margin-right:5px;padding:2px’>004</span></span>{<span style=”color:#000000;”><br/><span style=’color:#666;background-color:#DDD;border-right:1px #999 solid;margin-right:5px;padding:2px’>005</span>&nbsp;&nbsp;&nbsp;&nbsp;</span><span style=”color:#0000e6;”>int</span> x;<span style=”color:#000000;”><br/><span style=’color:#666;background-color:#DDD;border-right:1px #999 solid;margin-right:5px;padding:2px’>006</span>&nbsp;&nbsp;&nbsp;&nbsp;</span><span style=”color:#0000e6;”>int</span> y;<span style=”color:#000000;”><br/><span style=’color:#666;background-color:#DDD;border-right:1px #999 solid;margin-right:5px;padding:2px’>007</span>&nbsp;&nbsp;&nbsp;&nbsp;</span><span style=”color:#0000e6;”>int</span> qtd;<span style=”color:#000000;”><br/><span style=’color:#666;background-color:#DDD;border-right:1px #999 solid;margin-right:5px;padding:2px’>008</span></span>}</pre>
Autor: Luiz Nörnberg Ver todos os posts de
Sou Bacharel em Ciência da Computação pela Universidade Católica de Pelotas (UCPel), onde também atuei como professor. Desde a época da faculdade (mais de quinze anos atrás) a paixão por jogos tem sido importante no meu direcionamento profissional. Sou sócio-fundador do Izyplay Game Studio, onde exerço o cargo de Diretor de Tecnologia. Sempre tive grande foco em desenvolvimento em Java, embora tenha migrando para a tecnologia Adobe AIR em função de sua portabilidade. Ah, e é claro, dou meus palpites no game design.

5 Comentários em "Threads, apenas um exemplo"

  1. jean patrick 22/10/2009 at 19:17 - Reply

    Ótimo post Luiz, é o primeiro post que leio em seu blog, agora fiquei com uma dúvida, como ficaria o processamento do seu exemplo se você limitasse a quantidade de frames por segundo, por exemplo, pra 30fps?

    • Luiz Alessandro Nörnberg 23/10/2009 at 00:25 - Reply

      Olá Jean. Se utilizarmos a técnica mostrada no outro post, para limitar a taxa de frames ou pulsos, continuamos com 100% de processamento. Isso ocorre porque mesmo tendo uma limitação na quantidade de vezes que update() ou render() é chamado dentro do loop, o loop em sí fican dando voltas o mais rápido possível. Para obter liberação de CPU, é preciso uma técnica mais elaborada, que meça o tempo que o render() (por exemplo) demorou e faça a thread “dormir” (Thread.sleep()) pelos milisegundos que sobraram. Assim o loop pára durante este tempo e o processamento é liberado. Para ter uma idéia, sem utilizar calculos, apenas fazendo cada thread (update e render) dormir por 30 milisegundos a cada volta do loop, tenho pulsos e frames em 30 por segundo e fico com menos de 5% de processamento. E a carga fica em apenas um dos núcleos da CPU.

  2. Cristhian 06/03/2013 at 20:38 - Reply

    Em alguns tutoriais de desenvolvimento de jogos que vi, recomendava-se o uso de uma Thread como loop principal, funcionando da seguinte maneira: o método run, quando chamado, invoca outra chamada ao mesmo método run para um determinado momento no futuro, digamos, daqui 1 segundo. Nesse meio tempo, ele executa os métodos update e render. A idéia, pelo que entendi, é chamar a thread em um tempo determinado no futuro para que a taxa de atualização seja constante, e nesse meio tempo, é preciso ter absoluta certeza de que os métodos update e render terminarão seu trabalho antes do método run seja chamado novamente. Esse método é recomendado?

    • Luiz Nörnberg 07/03/2013 at 09:44 - Reply

      Olá Christian!

      Algum tempo atrás, a recomendação seria usar sempre um loop fechado (while) sempre que possível. Daí o nome game loop. Da forma que você viu, o que fazemos é simular um loop agendando chamadas consecutivas da mesma rotina. Essa simulação é necessária em ambientes onde você não pode fazer um loop comum para o jogo – dentro de um navegador (Javascript) ou no Flash (ActioScript), por exemplo, não é permitido esse tipo de loop. Esses ambientes assumem que seu programa travou se ele entrar em loop por muito tempo.

      Para ter certeza de que falo o mesmo que você, dê uma olhada no tutorial de HTML, onde é feito desa forma (chamadas agendadas): (http://abrindoojogo.com.br/tutorial-html-5-parte-23).

      Hoje em dia já não recomendamos uma forma sobre a outra. Depende do caso. Além das plataformas que dependem do loop simulado (comentadas acima), tem as que tiram proveito dele. Por exemplo, no ambiente mobile uma preocupação constante é o consumo de bateria, e um loop fechado consome mais do que chamar um método exatamente no intervalo que você precisa para seu jogo.

      Por outro lado, esse agendamento de chamada usa rotinas de timer do sistema, que nunca são precisas o suficiente e mais: podem simplesmente não ocorrer no momento esperado. Por isso, para o máximo desempenho e controle, se você programar em Java ou C++ (ou algo semelhante), recomenda-se o loop fechado. Ou seja, ao invés de agendar as chamadas de run() para cada X milisegundos, você faz um loop que repete o mais rápido possível, consequentemente usando todo processamento que ele conseguir obter. Dentro dele você faz sua lógica rodar em uma velocidade constante (veja aqui: http://abrindoojogo.com.br/gameloop-com-taxa-constante-de-pulsos-parte-1-de-2) e renderiza o máximo de vezes que der. Isso é especialmente válido para jogos 3D de PC, que necessitam da máxima taxa de fps possível.

      • Cristhian 07/03/2013 at 14:18 - Reply

        Olá Luiz.
        Compreendi o que quis dizer, e confesso que nunca tinha visto por esse ângulo.
        Esse segundo post que citou esclareceu bastante a questão de jogabilidade para mim, onde a taxa de atualização do update é constante, mas o do render varia de máquina a máquina. Já tinha passado por situações onde a atualização do update gerava uma jogabilidade completamente diferente para cada máquina.
        Agradeço.

Deixar um Comentário