Multithreading e Processamento Paralelo no Delphi (PPL) | Multi Thread | solução ou problema?

08-04-2022 18:56

Multithreading e Processamento Paralelo no Delphi (PPL)

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:

O código acima já deve ser auto-explicativo à essa altura.

Como pode ver, a utilização é bem semelhante. Uma das vantagens do TMonitor é que você não precisa ter uma classe instanciada do TCriticalSection, visto que TMonitor é um record.

Obs: Houve uma discussão no passado a respeito da diferença entre TCriticalSection e TMonitor, considerando a performance e outros pontos. Pode acompanhar os resultados Aqui e Aqui, e faça seu próprio julgamento. Nos exemplos aqui acima, pude notar que o código “ocorreu melhor concorrentemente” utilizando-se o TMonitor. Em cada execução, uma das thread veio primeiro, ao contrário do TCriticalSection, que sempre executava – nos poucos testes que fiz – a primeira thread primeiro. Isso é só um comentário, e não um estudo aprofundado do assunto, até porque não fui fundo o suficiente para entender o porquê desse comportamento.

 

TInterlocked

Documentação: https://docwiki.embarcadero.com/Libraries/Tokyo/en/System.SyncObjs.TInterlocked

Quando você precisa de alterações simples, como troca de valores entre variáveis – ou incrementar variáveis por exemplo – você pode (e deve) utilizar TInterlocked. Ele é mais performático que outros métodos de lock e sem a complexidade de ficar criando classes e gerenciando quem está com quem.

Imagine que você possua uma variável “Count” que deva ser incrementada em cada thread. Como cada thread ocorre paralelamente, ao incrementar o valor de uma variável simplesmente da forma convencional:

poderá ser um ponto de falha, porque em cada momento que a thread incrementa a variável,  ela precisa determinar o valor da própria variável. Imagine que Count tenha valor 10. Então a primeira coisa que a thread identifica é que a variável possui esse valor. Se no mesmo momento, outra thread alterou a variáve para 11, a primeira thread ainda “pensa” que a variável possui o valor 10, e seta novamente o valor dela para 11 (que a outra thread já havia feito), perdendo assim o valor do incremento. Esse é apenas um dos exemplos de problemas quando não utilizamos mecanismos de lock com multithreading.

Utilizando TInterlocked, poderíamos:

que fará o incremento da variável Count de forma segura. Simples não?

TInterlocked possui funcionalidades para incrementar, decrementar, adicionar, retirar, comparar, etc, etc. Vale a pena olhar a documentação e verificar onde sua necessidade se encaixa.

TMutex (Mutuamente Exclusivo)

Documentação: https://docwiki.embarcadero.com/Libraries/Tokyo/en/System.SyncObjs.TMutex

Semelhante a um semáforo binário, um mutex é um mecanismo que cria uma lista de solicitações de processos que tentam acessar o mesmo recurso e determina apenas um que seguirá adiante na utilização do recurso. Seu funcionamento é semelhante ao da seção crítica, mas com algumas diferenças. Mutex são tratados nominalmente a nível de Kernel, o que garante uma disponibilidade entre aplicações, por outro lado, seções críticas são bem mais performáticas.

A codificação de um mutex é:

 

TSemaphore

Documentação: https://docwiki.embarcadero.com/Libraries/Tokyo/en/System.SyncObjs.TSemaphore

O TSemaphore funciona semelhante ao TMutex, mas possui um contador interno, definido em sua criação. Conforme os acessos ao TSemaphore forem ocorrendo, eles vão sendo liberados enquanto o total de acesso não atingir o contador. Caso atinja, o semáforo barrará o recurso compartilhado.

Veja o exemplo:

Veja que troquei o tempo de espera para depois de executar a ação, para que fique mais claro visualmente o comportamento do semáforo.

Aqui nos meus testes, a primeira thread e a segunda executaram quase que juntas, enquanto a terceira teve que esperar as duas primeiras executarem.

Quando utilizar um semáforo? Sempre que você quiser limitar um recurso a determinado número de utilizações simultâneas. Um exemplo interessante que encontrei foi um no site Stackoverflow, onde ele fez a anologia de um semáforo a um segurança de uma boate. A boate tem um limite de X pessoas, e o segurança bloqueia quando chega nesse limite. Se alguma pessoa sai, o segurança libera o acesso daquela “vaga” que ficou livre.

Dentro de um ERP você poderia utilizar semáforos para limitar a quantidade de acessos simultâneos ao banco de dados, ou à um webservice, por exemplo.

Caso o contador seja definido para 1, ele se transforma em uma semáforo binário, e seu comportamento será semelhante ao do tipo TMutex.

Obs: Artigo sobre semáforo na EDN: https://edn.embarcadero.com/article/29908

TEvent

Documentação: https://docwiki.embarcadero.com/Libraries/Tokyo/en/System.SyncObjs.TEvent

Um evento é uma sinalização. Quando utilizado com thread, essa sinalização pode determinar o início, o término, ou outro comportamento de uma thread. Imagine que você queira que a thread fique parada até que determinado fato aconteça, como por exemplo, a finalização de uma outra thread. Uma forma de comunicar para essa thread que a outra já acabou o que tinha que fazer, é utilizar os eventos. Talvez você queira saber quando todas as thread acabaram? Utilizamos eventos para isso também. Como dito, o evento é uma forma de sinalizar para as outras threads que algo aconteceu.

O que fizemos aqui em cima foi zerar o evento:

FEvento.ResetEvent; 

Isso o configura para que possamos utiliza-lo. Também zeramos uma variável global:

FQtdNoEvento := 0; 

Essa variável tem o objetivo de contar quantas threads estão em execução. Depois, criamos três threads, cada uma com um tempo diferente de execução. Como primeiro passo, todas as threads incrementam a variável global, como indicativo de que ela está sendo executada. Antes do final, cada thread decrementa o valor da variável, para indicar que finalizou, ou seja, já fez tudo o que precisava. Por fim, em cada thread é testado:

if FQtdNoEvento = 0 then FEvento.SetEvent;

Assim, se todas as threads já finalizaram, FEvento recebe a informação de “disparo”. Isso libera a thread principal, que estava com a execução parada em:

FEvento.WaitFor(10000);

O comando WaitFor trava a execução da thread enquanto o evento ainda não foi disparado (SetEvent). 10000 é o tempo de timeout, ou seja, a thread principal irá aguardar por 10000 milesegundo (10 segundos) o retorno. Se não obtiver um retorno em 10 segundos, irá seguir em frente. WaitFor pode retornar 4 coisas:

wrSignaled – Quando tudo ocorreu bem e o evento foi disparado (SetEvent).
wrTimeout – Quando o tempo definido já foi ultrapassado sem que SetEvento tenha sido chamado.
wrAbandoned – Quando o objeto do evento tenha sido destruído antes do tempo de timeout.
wrError -Quando algum erro ocorreu enquanto estava aguardando.

Ao invés de definir um tempo de timeout, você pode utilizar a constante INFINITE, que faz com que a thread aguarde indefinidamente. Muito cuidado ao utilizá-la, porque caso algum problema tenha ocorrido, o programa irá travar indefinidamente, porque pode nunca haver um retorno. Como boa prática, é recomendável a utilização de um valor para o timeout.

TSpinlock

Documentação: https://docwiki.embarcadero.com/Libraries/Tokyo/en/System.SyncObjs.TSpinLock

Nos mecanismos de lock tradicionais e visto aqui anteriormente, o sistema operacional coloca as threads que “estão aguardando” em um estado de descanso. Isso faz com que aquelas threads não consumam tempo de CPU. É uma otimização inteligente realizada pelo sistema operacional para poupar processamento. Todavia, existem casos onde a performance é necessária. Existe uma forma de criar o lock sem colocar as threads em estado de descanso, fazendo com que as threads continuem em execução. Isso só é benéfico quando o tempo de duração do lock é muito curto, e quando performance é realmente necessária, pois de outra forma, as threads ficarão consumindo tempo de processamento desnecessariamente. Fique atento porque TSpinLock não interrompe a execução da thread, então pense bem antes de utilizá-lo.

Basicamente a thread fica em loop aguardando o momento em que ela consegue acesso ao recurso compartilhado.

TSpinLock possui como parâmetro de entrada de seu construtor uma variável booleana chamada EnableThreadTracking. Ele não permite que o lock entre duas vezes. Caso isso aconteça, quando EnableThreadTracking = True, irá ocorrer um erro. Se definido para false, poderá haver um deadlock. Assim, recomendo que utilize como true.

Ainda é possível utilizar o método TryEnter para checar quando a entrada no lock é possível (o método Enter também faz isso, mas ele aguarda por um tempo indeterminado – constante INFINITE – e entra quando possível). Assim, é possível fazer um loop que aguarde enquanto não é possivel entrar e tratar o comportamento do sistema.

Quer entender mais sobre esse mecanismo?

Os links possuem exemplos que não estão em Delphi, mas qualquer interessado com um pouco de empenho conseguirá entender:

Use com sabedoria.

TMultiReadExclusiveWriteSynchronizer

Documentação: https://docwiki.embarcadero.com/Libraries/Tokyo/en/System.SysUtils.TMultiReadExclusiveWriteSynchronizer

Um dos grandes problemas das threads é na utilização dos recursos compartilhados – se você leu até aqui, já deve ter percebido isso. Por se tratar de processos concorrentes, tudo o que é compartilhado precisa ser controlado para que não ocorram problemas. Porém, imagine um cenário onde poucos processos alteram os valores, mas muitos outros apenas utilizam a informação desses recursos compartilhados. Quando você possui um valor e esse valor precisa ser “lidos” em vários processos concorrentes, não há problema que todos esses processos o utilizem ao mesmo tempo, concorda? O problema reside se algum desses processos queira alterar o valor. Mas somente utilizar o valor que já está la? Não tem risco algum.

TMultiReadExclusiveWriteSynchronizer foi criado justamente para isso. O seu funcionamento é muito simples. Quando uma thread deseja utilizar o recurso, ela determina a sua intenção: ler ou escrever. Se ela deseja apenas ler, o seu acesso é liberado, desde que ninguém esteja escrevendo. Assim, você pode ter diversas threads utilizando o mesmo recurso, se todas forem apenas ler o conteúdo, sem travar nenhuma das threads. Porém, se alguma thread utilizar TMultiReadExclusiveWriteSynchronizer como escrita, todas as threads que tentarem o lock novamente serão travadas, mesmo que sejam apenas para leitura, enquanto o processo de escrita não tenha terminado.

Isso é um ótimo mecanismo para agilizar aqueles processos que utilizam o recurso apenas para leitura, e quando o processo de escrita não seja tão frequente.

A primeira thread foi projetada para escrita. As demais, apenas para leitura. Execute o código acima veja que enquanto elas estão apenas lendo, elas executam concorrentemente sem problemas, entratante, quando a primeira thread pega o lock, todas aguardam a sua liberação.

Existe ainda a interface IReadWriteSync que pode ser utilizada ao invés da classe, se você preferir.

TThread.WaitFor

Documentação: https://docwiki.embarcadero.com/Libraries/Tokyo/en/System.Classes.TThread.WaitFor

O recurso já foi utilizado anteriormente quando explicamos os eventos, e aqui a lógica é a mesma. A utilização do WaitFor trava a execução de uma thread enquanto “aguarda” por algo que ocorre em outra thread. Aqui também vale lembrar que aguardar indefinidamente pode fazer com que seu programa trave, então utilize de forma que isso não ocorra.

WaitFor aguarda a execução da thread e retorna um valor inteiro, informado pela thread que está sendo aguardada, através de SetReturnValue:

A thread precisa ser manipulada de forma que você tenha o controle da destruição dela, pois se ela se autodestruir (T1.FreeOnTerminate := False;), você não terá acesso no momento da checagem se ela retornou com algum valor (if T1.WaitFor = 0 then).

O uso do waitfor possibilita o desenvolvimento de sistemas mais complexos onde existe uma sincronia entre os processos. Com ele, você consegue saber exatamente quando um processo terminou e utilizar isso para dar sequência em outras threads.

PPL – Parallel Program Language

Documentação: https://docwiki.embarcadero.com/RADStudio/Tokyo/en/Using_the_Parallel_Programming_Library

O Delphi XE7 trouxe melhorias significativas para o desenvolvimento de softwares multiprocessados e disponibilizou recursos de paralelismo para o desenvolvimento de sistemas. Existe ainda toda uma sobrecarga de recursos com a criação e gerenciamentos das threads. A PPL, por outro lado, é feita para utilizar os recursos de hardware multiprocessados e dar o poder da computação paralela aos sistemas atuais.

Thread Pool

Documentação: https://docwiki.embarcadero.com/Libraries/Tokyo/en/System.Threading.TThreadPool

TThreadPool é a classe que cuida do gerenciamento automático das threads em execução. Ela diminui a sobrecarga da criação de todas as threads individualizadas e ainda possui a inteligência de lidar eficientemente com a carga de todos os processadores. Essa classe pode ser reescrita pelo desenvolvedor, mas somente por uma boa razão, pois a classe nativa já da conta do recado.

Obs: Muitos desenvolvedores preferem utilizar o FastMM, que possui seu próprio pool de threads.

TTask

Documentação: https://docwiki.embarcadero.com/Libraries/Tokyo/en/System.Threading.TTask

Uma tarefa é a execução de um trecho de código de forma paralela. Imagine que o software vá exportar um arquivo texto. Você pode simplesmente delegar isso a uma tarefa e deixar sua thread principal livre para responder aos comendos do usuário. A ideia aqui por trás é a criação de um mecanismo simples e fácil que execute paralelamenrte trechos de código, semelhante à chamada de TThread.CreateAnonymousThread.

Aqui também vale lembrar que tudo o que foi dito a respeito de como pensar sua aplicação para trabalhar com threads se aplica também para trabalhar com tasks e qualquer outro mecanismo de paralelismo.

TFuture

Documentação: https://docwiki.embarcadero.com/Libraries/Tokyo/en/System.Threading.TFuture 

https://docwiki.embarcadero.com/Libraries/Tokyo/en/System.Threading.IFuture

TFuture é uma task que retorna valor. Você pode pensar nessa classe como uma função que roda de forma paralela (ao contrário da task, que pode ser entendido como uma procedure que executa de forma paralela). Ela foi implementada de uma forma graciosa: o sistema aguarda enquanto o resultado ainda não foi processado.

Sua declaração é bem simples. Graças ao uso dos tipos genéricos (generics), você pode, no momento da criação de TFuture, definir qual o tipo de retorno desejado:

MinhaFuncao: IFuture<String>;

O código acima declarou uma variável do tipo IFuture<string>, que pode ser entendido como uma função que irá retornar uma string.

Para utilarmos, basta:

O fato mais legal aqui é que nesse momento o método anônimo já passa a ser processado, mas ainda não utilizamos o valor de retorno. Mas isso não importa, porque em algum momento vamos utilizá-lo, e quando isso acontecer, ele já vai estar calculado.

A origem do nome vem justamente dessa característica, porque criamos a função para utilizarmos seu valor futuramente. Assim, adiantamos o processamento para uso futuro.

Abaixo um exemplo completo de utilização:

Veja agora que vou adicionar um Sleep(5000) antes de fazer a chamada do retorno. Veja que o comportamento visual muda, porque o tempo de processamento das tarefas já foi realizado:

Com isso você pode perceber claramente a principal característica desse tipo de função paralela.

Você pode querer, em determinadas situações, se certificar de que todas as tarefas paralelas foram executadas antes de realizar algum procedimento. Para isso, existe o método WaitForAll:

WaitForAll aguarda a execução de todas as funções paralelas para prosseguir com o processamento do restante.

Talvez você queira esperar a execução de uma tarefa apenas, seja qual delas que execute primeira. Para isso, você pode utilizar WaitForAny, da mesma forma que utilizou WaitForAll. Saiba apenas que o método ira aguardar a execução de qualquer uma, sendo que aquela que terminar primeiro, já libera o processamento para seguir adiante.

Acredito que essa seja umas das melhores melhorias já feitas com processamento paralelo dentro do Delphi, porque dá poder ao desenvolvimento de sistemas e facilidade ao desenvolvedor.

Loop “For” paralelo

Documentação: https://docwiki.embarcadero.com/Libraries/Tokyo/en/System.Threading.TParallel.For

No lançamento do Delphi XE7, esse foi um dos recursos mais “aclamados”. O loop for paralelo é o famoso loop “for” que todos conhecemos, mas com a possibilidade de execução paralela. Assim, cada iteração do loop é executado de forma paralela, dando poder de processamento e desempenho.

Não pense que você pode simplesmente refatorar seu código de modo a simplesmente trocar a instrução “loop” padrão para o “loop paralelo”. Todas as regras vistas acima sobre recursos compartilhados e problemas de paralelismo e concorrência se aplicam ao loop “for paralelo” também, então saiba que a utilização desse mecanismo precisa ser muito bem planejada e sua migração é complexa.

Como regra geral, tente fazer com que o código a ser executado dentro do loop tenha a menor dependência possível com recursos compartilhados. De fato, tente não usar recursos compartilhados dentro dele, para que a execução seje 100% independente.

Nem sempre a migração para o paralelismo é uma coisa boa. A necessidade de controle e gerenciamento da concorrência e do paralelismo gera uma sobrecarga no sistema e, caso o design da sua aplicação não saiba aproveitar de forma eficiente esses recursos, com certeza o sistema ficará mais lento do que se não estivesse utilizando esses recursos.

Abaixo encontra-se a implementação básica do “loop paralelo”:

Temos na instrução TParallel.&For, que o código dentro de procedure(AIndex: Integer) será executado 10 vezes, indo de 1 à 10. O loop for paralelo trabalha criando uma task para cada iteração que será executada.

Veja que fiz com ele fosse executado dentro de uma Task. Poderia não ter feito isso. O código interno dentro do loop ainda seria executado de forma paralela, porque esse mecanismo simplesmente cria uma task para cada iteração do loop e a executa paralelamente, mas preferi fazer dessa maneira porque o controle do loop passa a não ser mais da main thread (thread principal) e sim da task externa, fazendo com que a tela fique responsível.

Não existe uma ordem de execução das iteração. Parece estranho, não é? Mas tenha em mente que o código será executado paralelamente. Isso significa que você não sabe quando as iterações irão ocorrer, e qual a ordem das iterações. Pode ser que a iteração 7 ocorra antes da iteração 3. Por isso o seu código interno de execução não pode utilizar essa ordenação como regra de negócio.

Mas e se eu precisar de algum tipo de tratamento quanto a isso?

TLoopState

Documentação: https://docwiki.embarcadero.com/Libraries/Tokyo/en/System.Threading.TParallel.TLoopState

Conforme a documentação oficial, o loop paralelo possui diversas sobrevaragas de métodos dando a possibilidade de “encaixe” em diferentes situações. Uma dessas possibilidades é a utilização da classe TLoopState, que armazena as informações do “estado” do loop.

TLoopstate possui os métodos “break” e “stop”, que são utilizados para a parada da execução do loop. Ainda, possui a propriedade “stopped” para identificar quando o loop não deve mais ser executado. Lembre-se que, quando trabalhamos com uma thread, elas são “notificadas” da parada, e não abruptamente paradas. Assim, o código interno do processamento paralelo deve prever essas situações e se utilizar desses recursos para tratar as situações de forma elegante.

Vamos declarar uma variável para a tarefa de forma global no relatório e uma para armazenar o resultado do loop:

Agora vamos criar uma procedure (em um TButton) para implementar o funcionamento:

O procedimento acima apenas cria um loop com 100 iterações de forma paralela, sempre verificando o status da execução do loop.

O comando:

TThread.Sleep(500);

existe apenas para nos dar tempo (como usuários do sistema de exemplo) para a parada do loop, para testarmos o funcionamento.

Em outro botão temos:

o código altera o “status” da execução para cancelado. Lembra que no código anterior verificamos o status de cancelamento?

Em outro botão, temos:

Aqui apenas estamos conferindo se o loop foi interrompido ou não.

A lógica de execução desse exemplo deve ser:

  1. Execute o loop
  2. Verifique o status da parada

Com esse fluxo, você verá que todas as iterações do loop foram realizadas e o status final foi “Completou com sucesso!”.

Agora, para exemplificarmos um cenário onde ocorreu uma parada:

  1. Execute o loop
  2. Execute a parada do loop
  3. Verifique o status da parada

Nesse cenário, o retorno foi “Houve uma parada!” Consegue perceber também que o loop não para no momento do cancelamento, pois ainda existem iterações que já foram disparadas e estão sendo processadas no exato momento do cancelamento, então tenha isso em mente quando for construir seus sistemas.

Podíamos ainda utilizar “Break” e “Stop” do TLoopState para pararmos as outras iterações, como por exemplo:

Tanto Break como Stop param a execução das iterações dos loops, mas cada um tem sua característica singular. Para entender a diferença, é necessário lembrarmos que a execução dos loops não possui uma ordem exata, ou seja, o “Index 90” pode executar antes do “Index 10”. Stop para qualquer nova iteração do loop, não importando qual seu index, sendo que Break para novas iterações onde o Index dessas iterações forem maior do que o index onde ocorreu o break, ou seja, se o break ocorre na iteração de index 50, todas as iterações onde o index for menor que 50, ainda ocorrerão, mesmo que ainda não foram executadas. Isso te dá o poder de trabalhar melhor o fluxo de suas aplicações, não é?

TLoopResult 

Documentação: https://docwiki.embarcadero.com/Libraries/Tokyo/en/System.Threading.TParallel.TLoopResult

Simplesmente armazena as informações do resultado do loop paralelo. Sua utilização já foi exemplificada no exemplo anterior, e utilizamos suas informações para determinar quando houveram paradas do loop.

Tratamento de exceções nas Tasks

 

Recomendo a seguinte leitura para o tratamento de exceções com as tarefas, do blog do Robert Love: https://robstechcorner.blogspot.com/2015/02/tpl-ttask-exception-management.html

 

Problemas comuns com processamento paralelo

 

Recomendações