Processamento paralelo é um assunto que mexe com os profissionais. Muitos a amam, outros nem tanto, mas todos respeitam seu potencial. A capacidade de dividir a carga de processamento em pequenas unidades que executam ao mesmo tempo, é uma vantagem que não pode ser desprezada.
A maioria dos desenvolvedores não sabe explorar os recursos de multithreading e da computação paralela em seus projetos. O grande problema não é a tecnologia. São as pessoas. Programar paralelamente não é apenas criar uma thread, mas sim pensar paralelamente. Eis a grande dificuldade. Não é possível utilizar os recursos de mutithreading e da computação paralela enquanto o desenvolvedor pensa apenas serialmente (linearmente). Isso é uma barreira, porque uma vez que você ultrapassa esse limite, o resto é apenas tecnologia. E a tecnologia está aí para nos ajudar.
Mas o que é uma thread? É simplesmente um processo. Um programa é composto, inicialmente, por um processo único, conhecido como main thread (thread principal). É o seu processo inicial. Entretanto, o programa não precisa ter simplesmente um único processo, podendo criar outros processos à vontade. Ele começa com um, porque é o mínimo possível para ele existir (caso contrário, ele não iria executar).
A dica aqui é pensarmos em threads como se fossem outros programas, desvinculados do programa principal. Ela não é realmente isso, porque existem áreas do sistema que precisam ser compartilhadas (de outra forma, você não usaria threads, e sim, faria outro programa) mas essa forma de pensar ajuda a estruturar o pensamento e evitar falhas. Vamos ver mais sobre isso depois.
A grande parte dos exemplos que encontramos quando vamos trabalhar com TThread é sobre como fazer percorrer dois TGauges. Esse exemplo é bom para mostrar como o processamento paralelo funciona, porque é possível visualmente perceber que um dos gauges é mais rápido que o outro, o que dá a noção de que as threads estão sendo gerenciadas por algo que está além dos nossos programas, no caso, o sistema operacional. Além disso, dá a percepção importante que não existe uma sequencialidade na execução das threads, uma vez que provavelmente uma delas irá terminar bem antes da outra. Todos esses conceitos são importantes, e necessários, mas eles não dão a capacidade de pensar dieferente. Eles só dizem respeito à como a tecnologia funciona. E lembra o que é o mais importante? Saber como pensar!
Outros exemplos mostram como tornar uma tela responsível mesmo quando está executando um processamento. No Delphi, se você utiliza VCL, verá que se você não utilizar um recurso de multithreading, a tela irá parar de responder porque a thread principal, que é a responsável pelo processamento gráfico da VCL, estará ocupada executando outra tarefa. Mesmo nesse caso, estamos falando apenas de tecnologia novamente.
É importante saber que não sou contra o conhecimento da tecnologia. Ele é essencial porque sem ela você não consegue desenvolver seus sistemas. Mas dou mais importância em “como as coisas são feitas” ao invés de “do que elas são feitas”.
Multithreading versus Processamento Paralelo
Precisamos antes de tudo definir as diferenças, para evitar qualquer mal entendido. Multithreading não é o mesmo que processamento paralelo. Quando falamos de processamento paralelo, falamos de utilização de recursos de hardware com múltiplos processadores e execução de código ao mesmo tempo, ou seja, de forma paralela. Multithreading diz respeito à concorrência na execução dos processos. Um computador com um única core de processamento não pode executar códigos de forma paralela, embora consiga executar códigos de forma concorrente. A concorrência é sobre a disputa da utilização do CPU por diferentes processos, enquanto paralelismo é sobre a utilização de diferentes “cores” de processamento. Contudo, como o sistema operacional manipula as threads em execução, ele pode fazer com que um sistema multithread também seja paralelo, caso haja o recurso de hardware para isso.
Thread
Documentação: https://docwiki.embarcadero.com/Libraries/Tokyo/en/System.Classes.TThread
TThread é um wrapper de uma thread do Windows (ou outros sistemas operacionais, caso esteja utilizando o Firemonkey. No caso do Windows, você pode saber mais em: https://msdn.microsoft.com/en-us/library/windows/desktop/ms684841(v=vs.85).aspx) utilizando a própria API do sistema . Um wrapper é uma abstração, uma transposição, uma ponte de algo que existe na API do Windows e que podemos utilizar no Delphi.
Utilizamos a TThread para conseguirmos executar processos concorrentes à main thread, ou seja, à nossa thread principal. Assim, faremos com que o sistema “faça” duas coisas concorrentemente, de forma que o sistema operacional divida o tempo de processamento do processador entre todas as threads.
Cabe ao sistema operacional determinar o paralelismo da execução da thread, conforme a carga do CPU. Dessa forma, na utilização das threads, não necessariamente o código será executado de forma paralela, porque isso dependente de outros fatores, que não a própria existência da thread.
Minha experiência demonstrou que o uso de threads concorrentes, mesmo que não utilizando os recursos da computação paralela, embora não em todos os casos, ainda melhora a performance do sistema em geral.
Create
Esse é um exemplo básico de uma implementação de uma thread no Delphi.
No construtor (Create) temos algumas coisas interessantes a saber:
- Parâmetro CreateSuspended da TThread – O padrão para a criação de uma thread é que tenha o parâmetro CreateSuspended como true. Isso fará com que a thread seja criada de forma suspensa, ou seja, ela não inicia seu funcionamento imediatamente depois de sua criação. Caso seja definido para false, a thread já inicia a sua execução logo depois de ser criada, mas nem sempre isso é interessante. Quando criamos uma thread suspensa, precisamos fazer o uso do método Start para iniciarmos a thread (conforme exemplo acima). Caso ela não tenha sido criada suspensa, então o uso do método Start não é necessário.
- FreeOnTerminate – Isso é uma facilidade quando trabalhamos com thread. Por se tratar de processamento concorrente, nós não temos o controle de quando o processo irá terminar. Por isso, é uma boa prática (a não ser que você tenha alguma boa razão para não fazê-lo) sempre optar por FreeOnTerminate := True, pois isso fará com que a instância da thread seja destruída, e a memória liberada, depois que terminar sua execução.
Execute
O método mais importante é o método Execute. Ele é o método que será chamado quando o processo concorrente entrar em execução. A thread encerra sua execução (e seu ciclo de vida, caso tenha FreeOnTerminate = True) assim que chegar na última linha do método Execute. Por isso, caso a thread precise permanecer viva por longo tempo, você deve criar um laço de repetição (provavelmente o While) para que a thread não seja destruída.
Existe uma “flag” que determina quando a thread precisa ser destruída, chamada Terminated. Quando utilizamos TThread.Terminated := True, estamos apenas sinalizando que a thread foi marcada para finalização. Ela não termina a execução da thread abruptamente, apenas sinaliza que ela pode ser finalizada. Isso, com o uso do laço de repetição, teríamos:
Ou seja, estaríamos constantemente verificando se a thread está marcada para terminar, antes de realizarmos nossas operações.
OnTerminate
Outro método muito importante de uma thread é o OnTerminated. Ele é executado no momento que a thread finaliza sua execução e começa a se preparar para sua liberação.
Assim, caso queiramos que algo seja executado assim que a thread finalizar suas operações, OnTerminated deve ser utilizado:
Antes do início da thread, foi definido:
FMeuProcesso.OnTerminate := ExecutarDepoisThread;
A procedure ExecutarDepoisThread simplesmente chamou o método Synchronize passando um método anônimo para exibir as informações no memo. Synch…oque?
Synchronize
O método syncronize foi utilizado nos dois exemplos àcima quando quisemos alimentar as informações no memo. Lembra que as thread são procedimentos concorrentes e funcionam como se fossem programas diferentes? Synchronize é uma forma de sincronizar as informações entre as threads, no caso, com a thread principal.
Toda alteração visual da VCL deve ser feita através da thread principal, a responsável por gerenciar a VCL. Assim, quando uma thread pretende alterar qualquer aspecto visual deve fazê-lo através de algum método de sincronização. No caso acima, utilizei o shyncronize.
Queue
Queue, assim como Synchronize, possui a capacidade de trabalhar com diferentes thread, todavia, enquanto Synchronize é executada de forma serializada pela main thread quando existem diferentes threads chamando Syncronize ao mesmo tempo, Queue simplesmente gera uma fila que é executada no tempo livre da main thread. E porque isso importa? Porque a chamada dos métodos Syncronize penetra na main thread e, se não forem performáticos, travarão a main thread, enquanto Queue só será executada no tempo livre da thread principal. Legal né? Assim, use Queue sempre que possível.
Tratamento de exceções nas threads
Qualquer exceção não tratada gera uma parada abrupta da execução do programa. Na thread, isso ocorre da mesma forma, mas com o fato de que se a thread principal não ficar sabendo da exceção, a interrupção da thread que gerou a exceção será tratada de forma transparente, sem nenhum aviso. Ela simplesmente irá parar de funcionar. Como exemplo, altere o código conforme abaixo e faça um teste:
Mas então, o que fazer?
Vamos utilizar o método OnTerminate para nos ajudar com o tratamento:
Foi criado a procedure TratamentoDaExcecao para tratarmos a exceção. Ela é utilizada em:
FMeuProcesso.OnTerminate := TratamentoDaExcecao;
Assim, quando terminar a execução da thread, verificamos se houve alguma exceção simplesmente vendo se TThread(Sender).FatalException é diferente de nil:
if Assigned(TThread(Sender).FatalException) then
Se for, é porque ocorreu alguma exceção que parou a execução da thread. Simples, não é?
Só com esse conhecimento já é possível realizar grandes coisas com as threads, mas vamos explorar ainda mais esse recurso.
Threads sem complexidade
Todas as formas vista aqui anteriormente foram feitas com a criação de classes que herdam de TThread. Muitas vezes, você gostaria que seu código fosse executado sem a complexidade de criação e gerenciamento de uma classe. Vamos imaginar que você deseje que o sistema crie um log, e resolve jogar isso para um processamento concorrente, para já liberar a thread principal para seguir seu caminho. Seu desejo é simplesmente que o log seja processado longe da main thread.
A TThread possui o método TThread.CreateAnonymousThread. Com ele, você pode simplesmente passar o seu bloco de código e dizer: “Executa isso para mim de forma concorrente!”. O CreateAnonymoThread internamente irá criar uma thread, executar o bloco de código e finalizar a thread.
Veja que aqui fizemos uso de um método anônimo.
O problema dos recursos compartilhados
Vamos imaginar uma casa com 4 irmãos. Cada irmão tem suas próprias necessidades, como estudar, trabalhar, viajar, etc, todavia, existe um único carro de utilização compartilhada.
Há que se prever que em algum momento, um dos irmãos queira o carro para ir estudar e outro para trabalhar. Pode ainda um terceiro irmão solicitar o carro para ir na farmácia, comprar medicamentos. O que fazer? Absolutamente nada. Apenas um dos irmãos terá a condição de pegar o carro enquanto os outros terão que esperar, ou dar outra solução. Parece lógico, certo?
Quando falamos de recursos computacionais, estamos falando da mesma coisa. Imagine que você possui um objeto instanciado para a conexão com o banco de dados. Quando uma das thread está utilizando o objeto para trocar dados com o banco, as outras threads precisam necessariamente esperar ele terminar. Imagine todos os irmãos dentro do mesmo carro dirigindo-o de forma compartilhada. Quando um vira para a esquerda, o outro logo em seguida vira para a direita. Eles só podem encontrar um poste em seu caminho. Na computação, os erros podem ser imprevisíveis.
Por isso, no logo no início, ressaltei a importância de “como” fazer as coisas, ao invés de qual tecnologia utilizar. A tecnologia é apenas um meio para chegarmos aos resultados, mas precisamos antes saber para onde vamos e como vamos.
O grande problema do desenvolvimento concorrente e paralelo é justamente preparar o sistema para trabalhar adequadamente. O desesenvolvedor, analista, arquiteto, precisa imaginar o funcionamento do sistema como processos independentes que podem estar continuamente em atividade e que utilizam recursos compartilhados.
E como trabalhamos com isso? Antes de tudo, devemos saber o que é um lock.
Lock
Literalmente traduzido como trava ou bloqueio. Um mecanismo de lock é um mecanismo de bloqueio, que gerencia quando um recurso está disponível ou não. Existem diferentes formas de bloqueio, cada qual com suas próprias características, mas todas são utilizadas com recursos compartilhados para evitar problemas.
TCriticalSection
Documentação: https://docwiki.embarcadero.com/Libraries/Tokyo/en/System.SyncObjs.TCriticalSection
Também conhecido como seção crítica. Uma sessão crítica é o mecanismo de lock que determina quem vai utilizar o carro. Imagine que na casa dos 4 irmãos exista um pai que gerencie quem pode e quem não pode utilizar o carro. Esse pai utiliza para isso um critério bem simples: quem solicitou o carro primeiro terá o acesso ao carro primeiro. Veja que ele não tem inteligência para analisar qual a prioridade de cada filho. O que ele faz é simplesmente criar uma fila de solicitações de utilização do carro e liberar o carro nessa mesma ordem. Assim, quando o primeiro terminar de utilizar o carro, o segundo passará a utilizá-lo, e assim por diante.
Para que isso funcione, o primeiro filho diz: “Pai, me empresta o carro?” Como o carro está livre, o pai libera. Nesse mesmo momento, o segundo filho diz: “Pai, me empresta o carro?” Como o carro já está sendo utilizado pelo primeiro filho, o pai diz: “Não posso, o filho 1 está usando. Mas estou com o seu nome aqui e assim que ele devolver vou liberar para você”. O terceiro filho diz: “Pai, me empresta o carro?” O pai novamente diz: “Não posso, o filho 1 está usando e o filho 2 já está na sua frente”.
Uma coisa que precisa ficar clara é que os filhos que solicitaram o carro mas não o tiveram logo em seguida, ficam aguardando parados até a liberação do carro. Embora cada filho tenha sua própria atividade, quando eles precisam de um recurso compartilhado, então precisam ser serializados, ou seja, um depois do outro, e isso faz com que suas atividades, nesse momento, não sejam mais concorrentes. Por isso, quando precisar utilizar algum dos recursos de locking, faça de forma que o lock dure o menor tempo possível, para minimizar a serialização dos processamentos das threads.
Analogias à parte, vamos à prática:
Considere o objeto acima como o nosso recurso compartilhado. Ele é um objeto simples, com a funcionalidade de disparar uma mensagem visual.
Até agora apenas codificamos a criação e destruição da seção crítica e do objeto de mensagem. Assim, quando o formulário abrir eles serão criados e quando o formulário for fechado eles serão destruídos.
O código acima cria a serialização das threads, para utilização do mesmo recurso, no caso, o objeto que dispara a mensagem.
Todas as threads foram criadas utilizando-se o método CreateAnonymousThread. Assim, não precisei me preocupar com a complexidade da criação das classes das threads. Outra coisa que é importante no exemplo, é que não temos como saber quais das threads será executada primeiro. Em meus testes, o código seguiu conforme a sequência, mas isso não é garantido, porque a segunda thread poderia executar antes da primeira, por exemplo.
O importante, indiferentemente de quem obtenha o recurso primeiro, é que a execução será serializada, e não mais concorrente, porque existe uma competição para utilização de um recurso compartilhado.
Faça seus testes e veja o resultado.
Obs: Existe um texto escrito por Eric Grange a respeito de falhas no TCriticalSection e de como ele poderia serializar a execução das threads de forma que fique pior do que se estivesse tudo sem thread. Você pode ler a respeito AQUI.
Aparentemente, o TCriticalSection, por ser um objeto muito pequeno e de alocação dinâmica, pode, em uma situação onde existam várias instâncias diferentes da classe TCriticalSection, compartilhar o mesmo cache dentro do CPU, gerando um conflito de cache.
Por sorte, existe uma pequena “dica” para contornar essa situação.
O que está sendo feito acima é justamente forçar o objeto a ter mais de 96 bytes, que no caso existem apenas por existir, sem funcionalidade nenhuma para a classe em sí, mas que força o objeto a ser maior do que o “cache line” dos processadores atuais (isso pode aumentar conforme a necessidade posteriormente). Ainda, segundo Eric, isso dá um ganho de performance de até 7% maior ao atual, visto algumas otimizações do FastMM (projeto de um gerenciador de memória que foi adota posteriormente pelo Delphi, mas que não segue o projeto como um todo. Sobre as configurações de gerenciamento de memória do Delphi, veja Aqui).
TMonitor
Documentação: https://docwiki.embarcadero.com/Libraries/Tokyo/en/System.TMonitor
Funciona semelhante ao TCriticalSection, mas com comportamento diferente. Você cria um objeto e passa o monitoramento desse objeto para a classe TMonitor e TMonitor fica responsável por serializar os acessos ao objeto: