Dicas valiosas de programação – Parte 02

Introdução

Continuando na linha de boas práticas e afins, vamos abordar nesse post mais uma dica de ouro, válida para qualquer aplicação que pretende ser escalável, porém com foco no AdvPL

Procure fazer transações curtas

Parece simples, e se olharmos bem, não é assim tão complicado. Primeiro, vamos conceituar uma transação. Na linguagem AdvPL, temos os comandos BEGIN TRANSACTION e END TRANSACTION, que por baixo utilizam recursos do Framework do ERP Microsiga, junto com recursos da linguagem AdvPL, para identificarmos e protegermos uma parte do código responsável por realizar inserção ou atualização de dados em uma tabela.

Quando utilizamos um Banco de Dados relacional no AdvPL, através do DBAccess, proteger a parte do fonte AdvPL que faz alterações no banco de dados é importante, pois evita que, caso ocorra um erro (de qualquer origem)  durante a execução deste trecho, e ele não chegue ao final da transação, nenhuma das operações realizadas com os dados desde o BEGIN TRANSACTION são realmente efetivadas no Banco de Dados, mas sim descartadas. Isso evita por exemplo dados órfãos na base de dados, ou registros incompletos ou inconsistentes.

Por que transações curtas ?

Cada alteração de um registro de qualquer tabela no AdvPL, exige que seja feito um LOCK (ou bloqueio) explícito no registro, para que o mesmo possa ser alterado, e este bloqueio, uma vez adquirido, somente pode ser solto no final da transação. Quanto maior (mais longa) for a duração da transação, por mais tempo este registro permanecerá bloqueado para os demais processos do sistema.

Erros comuns que devem ser evitados

  • “Não abrirás interface com o usuário no meio de uma transação”

Antes de abrir uma transação, a aplicação deve realizar todas as validações possíveis nos conteúdos que estão sendo manipulados. Se qualquer um deles não passar na validação, o programa nem abre a transação. O objetivo da transação é pegar os dados em memória e transferi-los para as tabelas pertinentes, sem interrupções. Ao abrir uma tela ao usuário no meio de uma transação, á transação somente continua depois que o usuário interagir com a interface. Se o operador do sistema submeteu uma operação, virou as costas e foi tomar café, os registros bloqueados pelo processo dele assim permanecerão até que ele volte. Isso pode prejudicar quaisquer outros processos que precisem também atualizar estes mesmos registros.

  •  “Evitarás processamentos desnecessários dentro da transação”

Eu adoro o exemplo do envio de e-mail. Imagine que, durante a gravação de um pedido, exista a necessidade de emissão de um e-mail para inciar um WorkFlow ou um aviso ao responsável pelo setor de compras que um item vendido atingiu um estoque mínimo. Como já visto anteriormente, a solução mais elegante é delegar o envio desse e-mail para um outro processo, preferencialmente notificado de forma assíncrona. Se, em último caso, você precisa que esse email seja disparado na hora, e isso impacta seu processo, não dispare esse email com a transação aberta, lembre-se do tempo que os registros da sua transação vão ficar bloqueados … Grave em um dos campos de STATUS que o envio do e-mail está pendente, e finalize a transação. Então, tente enviar o e-mail, e ao obter sucesso, bloqueie novamente o registro e atualize apenas o campo de STATUS.

  • “Evitarás DEADLOCK”

Ao realizar atualizações concorrentes (mesmo registro tentando ser atualizado por mais de um processo), procure estabelecer uma ordem de obtenção dos bloqueios dos registros, para não fazer a sua aplicação entrar em deadlock — duas transações, que vamos chamar de A e B, já tem cada uma um registro bloqueado de uma determinada tabela. A transação A têm o lock do registro 1, e a transação B têm o lock do registro 2, porém a transação A precisa do lock do registro 2, e a transação B precisa do lock do registro 1, e enquanto nenhuma delas desistir, uma fica esperando a outra e as duas transações “empacam” (efeito conhecido por DEADLOCK).

Se as duas transações ordenassem os registros por uma chave única em ordem crescente, e tentasse obter os locks nesta ordem , o primeiro processo a falhar ainda não teria conseguido pegar o bloqueio de nenhum registro de valor superior ao que o outro processo já tenha obtido, ele apenas esperaria um pouco e tentaria pegar o lock novamente, o que será possível assim que a transação que já tem o lock seja finalizada.

Conclusão

O arroz com feijão é apenas isso. Mas apenas isso já tira um monte de dor de cabeça de DBAs e Administradores de Sistemas, por que mesmo com todo o ferramental disponível, rastrear quem está segurando o lock de quem, em ambientes que passam de centenas de conexões, tome abrir monitor do DBAccess, do Protheus, do SGDB, e caçar a thread que está esperando uma liberação de registro, e a outra que está segurando o lock, não é uma tarefa fácil …

Desejo a todos TERABYTES de SUCESSO 😀

Referências

Entendendo e minimizando deadlocks

DEADLOCK. In: WIKIPÉDIA, a enciclopédia livre. Flórida: Wikimedia Foundation, 2018. Disponível em: <https://pt.wikipedia.org/w/index.php?title=Deadlock&oldid=51816852>. Acesso em: 15 abr. 2018.

 

 

 

 

Escalabilidade e Performance – Stored Procedures

Introdução

Em um tópico anterior sobre “Escalabilidade e performance – Técnicas”, um dos tópicos falava sobre Stored Procedures, inclusive sugerindo que seu uso deveria ser minimizado. Vamos entrar neste tema com um pouco mais de profundidade neste tópico. Vamos começar com o clone do tópico abordado, e esmiuçar ele dentro do contexto do AdvPL e Protheus.

Minimize o uso de Stored Procedures

Este é um ponto aberto a discussão, depende muito de cada caso. Não é uma regra geral, existem pontos em um sistema onde uma stored procedure realmente faz diferença, mas seu uso excessivo ou como regra geral para tudo impõe outros custos e consequências. O Princípio 1 diria: “use apenas stored procedures”. No entanto, esta decisão pode causar grandes problemas para o Princípio 2 devido à escalabilidade. As Stored procedures têm a vantagem de ser pré-compiladas, e não há nada mais perto de um dado no Banco de Dados.

Porém Bancos de Dados transacionais são especializados em quatro funções: Sort, Merge, gerência de Locks e Log. A gerência de lock é uma das tarefas mais críticas para a implementação de algoritmos distribuídos, e este é o real motivo de existirem poucos Bancos de Dados que possam implementar a escalabilidade horizontal. Se as máquinas de Banco de Dados têm dificuldade de escalar horizontalmente, ela é um recurso escasso e precioso. Temos então que otimizar seu uso para não consumir excessivamente seus recursos a ponto de onerar os demais processos do ambiente. Isto acaba adiando a necessidade de escalar horizontalmente o SGBD.

Abordando a questão de desempenho

Se o algoritmo para processamento de um grande grupo de informações pode ser escrito dentro de uma Stored Procedure no próprio Banco de Dados, esta alternativa tende fortemente a ser a mais performática. Num cenário onde o algoritmo é escrito usando um programa sendo executado dentro do servidor de aplicação da linguagem, cada processamento que dependa da leitura de grupos de dados e tenha como resultado a geração de novos dados vai ser onerado pelo tempo de rede de tráfego destes dados, na ida e na volta. Logo, com uma base de dados modelada adequadamente, e uma stored procedure bem construída, ela naturalmente será mais rápida do que um processamento que precisa trafegar os dados pra fora do SGDB e depois receba novos dados de fora.

Porém, este recurso não deve ser usado como solução mágica para tudo. Afinal, o SGDB vai processar uma Stored Procedure mais rápido, pois ele não vai esperar um processamento ser realizado “fora dele”, porém o SGDB vai arcar com o custo de ler, processar e gravar a nova informação gerada. Se isto for feito sem critério, você pode mais facilmente esgotar os recursos computacionais do Banco de Dados, ao ponto da execução concorrente de Stored Procedures afetar o desempenho das demais requisições da aplicação.

Outras técnicas pra não esgotar o SGDB

Existem alternativas de adiar um upgrade no SGDB, inclusive em alguns casos as alternativas são a solução para você não precisar comprar um computador da “Nasa” …risos… Normalmente estas alternativas envolvem algum tipo de alteração na aplicação que consome o SGDB.

Réplicas de leitura

Alguns SGDBs permitem criar nativamente réplicas da base de dados acessadas apenas para consulta, onde as cópias de leitura são sincronizadas em requisições assíncronas. Existem muitas partes da aplicação que podem fazer uma leitura “suja”. Neste caso, a aplicação pode ler os dados de uma base sincronizada para leitura, e os processos que precisam de leitura limpa são executados apenas na instância principal. Para isso a aplicação precisaria saber qual e o banco “quente” e qual é o espelho, para fazer as coisas nos lugares certos.

Caches

Outra alternativa é a utilização de caches especialistas, implementados na própria aplicação. Utilizando por exemplo uma instância de um “MemCacheDB” em cada servidor, cada aplicação que pode reaproveitar a leitura de um dado com baixo índice de volatilidade (dados pouco atualizados ou atualizados em momentos específicos), poderiam primeiro consultar o cache, e somente se o cache não têm a informação desejada, a aplicação acessa o banco e popula o cache, definindo uma data de validade. Neste caso, o mais legal a fazer é definir um tempo de validade do cache (Expiration Time). E, paralelo a isso, para informações de baixa volatilidade, a rotina que fizer update desta informação pode eliminar ela do cache no momento que um update for realizado, ou melhor ainda, pode ver se ela se encontra no cache, e em caso afirmativo, ela mesma poderia atualizar o cache 😉

Sequenciamento de operações

Operações de inserção ou atualização de dados que não precisam ser refletidas em real-time no SGDB podem ser enfileiradas em pilhas de requisições, e processadas por um processo dedicado. O enfileiramento de requisições não essenciais em tempo real limita o consumo de recursos para uma determinada atividade. Caso a pilha se torne muito grande, ou um determinado processo dependa do esvaziamento total da pilha, podem ser colocados mais processos para ajudar a desempilhar, consumindo mais recursos apenas quando estritamente necessário.

Escalabilidade Vertical

Devido a esta questão de dificuldade de escalabilidade de bancos relacionais horizontalmente, normalmente recorremos a escalabilidade vertical. Escalamos horizontalmente as máquinas de processamento, colocando mais máquinas menores no cluster e balanceando carga e conexões, e quando a máquina de banco começa a “sentar”, coloca-se uma máquina maior só para o SGDB, com vários processadores, discos, memória e placas de rede. Mas tudo tem um limite, e quando ele for atingido, a sua máquina de Banco de Dados pode ficar mais cara que o seu parque de servidores de processamento.

Dificuldade de Implementação

Usar caches e réplicas e pilhas não é uma tarefa simples, fatores como a própria modelagem da base de dados podem interferir negativamente em algumas destas abordagens. Não se pode colocar tudo em cache, senão não vai ter memória que aguente. O cache é aconselhável para blocos de informações repetidas constantemente requisitadas, e de baixa volatilidade. Também não é necessário criar pilhas para tudo que é requisição, apenas aquelas que não são essenciais em tempo real, e que podem ter um delay em sua efetivação.

Stored Procedures no AdvPL

O ERP Microsiga disponibiliza um pacote de Stored Proecures, aplicadas no SGDB em uso por um programa do módulo “Configurador” (SIGACFG). As procedures foram desenvolvidas para funcionalidades específicas dentro de cada módulo, normalmente aquelas que lidam com grandes volumes de dados, e foi possível criar um algoritmo que realize o processamento dentro do SGDB, trafegando menos dados “pra fora” do Banco de Dados. Normalmente um pacote de procedures é “casado” com a versão dos fontes do Repositório, pois uma alteração na aplicação pode envolver uma alteração na procedure. Os ganhos de performance são nítidos em determinados processamentos, justamente por eliminar uma boa parte do tráfego de informações para fora do SGDB durante os processos.

Conclusão

Dado o SGDB como um recurso “caro e precioso”, como mencionado anteriormente, a utilização de recursos adicionais como réplicas e caches, ajuda a dar mais “fôlego” pro SGDB, você consegue aumentar o seu parque de máquinas e volume de dados processados sem ter que investir proporcionalmente na escalabilidade do SGDB. E em tempos de “cloudificação” , SaaS e IaaS, quando mais conseguimos aproveitar o poder computacional que temos em mãos, melhor !

Desejo novamente a todos TERABYTES de Sucesso 😀

Até o próximo post, pessoal 😉

Referências

“Escalabilidade e performance – Técnicas”

Balanceamento de Carga no Protheus

Introdução

No ano passado, ajudei um colega que estava concluindo um mestrado, cuja tese envolvia diretamente a eficiência de mecanismos de balanceamento de carga e suas abordagens. E, uma vez absorvido algum conhecimento a mais a respeito, acho que podemos dar um mergulho no assunto, e aproveitar para conhecer mais de perto o balanceamento de conexões nativo Application Server para conexões do SmartClient.

Balanceamento de Carga

Também conhecido por “Load balancing”, é um mecanismo que distribui requisições de processamento para múltiplos recursos computacionais, como computadores, “Cluster” de computadores, links de rede, unidades de processamento ou unidades de disco. O objetivo é otimizar o uso de recursos, maximizando disponibilidade e resultados, minimizando tempos de resposta, e evitar sobrecarga de uso de recursos. A utilização de múltiplos componentes com balanceamento de carga ao invés de um único componente deve aumentar a escalabilidade e disponibilidade através da redundância. Normalmente este recurso envolve software e/ou hardware específicos.

Entre as técnicas mais comuns, podemos citar o Round-Robin de DNS (onde uma URL de um WebSite retorna um IP diferente para cada consulta ao DNS, direcionando as requisições para outros servidores), algoritmos de distribuição (normalmente round-robin ou escolha randômica) são usados para decidir qual servidor irá processar uma chamada qualquer. Algoritmos mais sofisticados de balanceamento podem usar como fatores determinantes informações como tempo médio de resposta, estado do serviço, tráfego atual, etc.). Existe também um outro recurso, chamado “Proxy reverso” (Reverse Proxy), que além de outras funcionalidades, também pode ser utilizado para distribuição de requisições e balanceamento.

Pontos comuns

Cada mecanismo atua sob princípios diferentes, e visando suprir as necessidades dos ambientes onde estão inseridos, mas de uma forma geral, todos aderem aos mesmos princípios de desempenho, escalabilidade e resiliência. Como eu já disse, dentre os vários mecanismos disponíveis, não existe um “melhor de todos”, existem aqueles que melhor atendem as suas necessidades. Um sistema complexo pode usar vários níveis de balanceamento usando abordagens diferentes, para partes diferentes do sistema.

Balanceamento de carga no Protheus

O Protheus Server (ou Application Server) do AdvPL possui um mecanismo de balanceamento de carga nativo para as conexões recebidas das aplicações Smartclient. Basicamente, você provisiona dois ou mais serviços do Application Server, com as mesmas configurações de ambiente, distribuídos em quaisquer máquinas, e configura um novo serviço, chamado de “Master” ou “Balance”, com as configurações de balanceamento de carga das conexões especificados na seção [servernetwork] do arquivo de configuração do servidor (appserver.ini).

Quando iniciamos uma aplicação AdvPL a partir de um SmartClient, normalmente o programa executado é responsável pela montagem das telas de interface e pela execução do processamento. Por exemplo, a inclusão de um pedido de venda pelo SmartClient, quando você finaliza a operação e confirma a inclusão, a função de inclusão de pedido é executada pelo mesmo processo de interface, mantendo a interface em stand-by até a conclusão do processo e o programa devolver uma mensagem para a interface e permitir você prosseguir com a operação.

Neste modelo, como o processamento é realizado pelo mesmo processo que atende a conexão, toda a “carga” do processo está atrelada a esta conexão, e a aplicação foi projetada sob o conceito de persistência de conexão. Logo, em se tratando das conexões de SmartClient, ao balancear uma conexão, automaticamente estamos balanceando a “carga”.

Requisitos e comportamentos

Ao configurar no “Master” todos os serviços “Slave” de balanceamento, devemos enumerar host/ip e porta de conexão TCP de cada serviço. Este IP e porta deve ser visível na rede usada pelos SmartClients para estabelecer a conexão. Se houver um firewall entre os smartclients e os application servers, os IPs e portas dos “Slave’s” também precisam ser “abertos”, não basta abrir o IP e porta do “Master”.

O serviço de balanceamento não atua como um “Proxy Reverso”, as conexões estabelecidas com ele são indiretamente redirecionadas ao “Slave” escolhido no momento que a conexão foi estabelecida. Se ele atuasse como um proxy reverso, ele se tornaria um gargalo de comunicação, e caso ele fosse derrubado, todas as conexões atualmente em uso de todo o ambiente cairíam junto.

Quando o Balance recebe a conexão de um SmartClient, ele decide na hora qual dos serviços cadastrados para balanceamento é o mais adequado a receber esta conexão, e retorna ao SmartClient uma instrução de reconexão, indicando ao SmartClient qual é o Serviço que ele deve se conectar. Logo, o Smartclient conecta com o “Master”, recebe a informação de qual o serviço adequado para conextar-se, desconecta do “Master”, e conecta-se diretamente com este serviço.

Toda a regra e controle de distribuição é realizado pelo “Master”. Como ele já possui pré-configurados os serviços que podem receber conexões, ele próprio mantém com cada serviço “Slave” uma conexão dedicada de monitoramento, através da qual ele “sabe” quantos processos estão em execução em cada um dos serviços, e se cada serviço está apto a receber novas conexões. Como cada conexão pode exercer uma carga variável durante seu uso, a uma métrica utilizada é direcionar a nova conexão ao serviço que têm o menor número de processos em execução no momento.

Cada serviço do Protheus, após a Build 7.00.090818, possui um processo interno dedicado ao monitoramento de memória de sua instância de serviço. Caso um limite de ocupação de memória da instância seja atingido, este serviço torna-se automaticamente bloqueado para aceitar novas conexões de SmartClient e subir novos Jobs. O Serviço “Master” se mantém atualizado sobre este status, e não direciona novas conexões para este “Slave” enquanto seu status for “bloqueado”. Veja as referências no final do artigo para as documentações da TDN que abordam em maior profundidade estes assuntos.

Não seria melhor usar outra regra ?

Muitas pessoas já me perguntaram isso, e a resposta é “neste modelo de processamento, não têm opção melhor”. Como a carga e consumo de memória e CPU depende da rotina que será chamada, e a rotina está atrelada a conexão, qualquer cenário de distribuição está sujeito a desequilíbrios, afinal as rotinas possuem consumo de memória, CPU e rede variáveis durante a sua execução, e mesmo se fosse possível transferir um processo para outro serviço “a quente”, se este processo também apresenta a mesma variabilidade de consumo de recursos, a transferência da conexão seria freqüente, tornando-se ineficiente e pesada.

Assumindo que cada conexão persistente possui características variáveis de consumo, o mais elegante é dividir pelo número de processos por serviço. Mesmo havendo um eventual desequilíbrio no consumo de recursos, uma vez atingida uma quantidade de memória acima do normal em um serviço, ele automaticamente “bloqueia-se” para não aceitar mais conexões. Quando ao consumo de CPU, quando sabe-se que determinadas rotinas vão demandar muito tempo e consumo de recursos, pode-se montar um ambiente de balanceamento secundário, com outros “Slave’s” dedicados, onde os usuários de rotinas mais pesadas pode alocar outros serviços, e alguns deles podem ser colocados em scheduler para serem executados após o expediente.

Configuração de Balanceamento no AdvPL

Basicamente, você deve criar uma nova seção no appserver.ini para cada “Slave” que você quer relacionar no balanceamento. Recomenda-se criar um identificador cujo nome esteja relacionado de alguma forma com a máquina servidora e o número do serviço utilizado. Vamos dar um exemplo de balanceamento de serviços em apenas uma máquina, subindo o servidor “Master” na porta 6000, e quatro outros serviços “Slave” nas portas seguintes ( 6001 a 6004 )

[servernetwork]
servers=SL_1,SL_2,SL_3,SL_4
[SL_1]
Server=172.16.10.201
Port=6001
Connections=20
[SL_2]
Server=172.16.10.201
Port=6002
Connections=20
[SL_3]
Server=172.16.10.201
Port=6003
Connections=20
[SL_4]
Server=172.16.10.201
Port=6004
Connections=20

A configuração acima deve ser inserida no appserver.ini do serviço eleito como “Master”, que neste exemplo está na mesma máquina que os “Slave’s””, mas na porta 6000. Quando o serviço é iniciado, ele verifica se existe a seção [servernetwork], e verifica quais são os serviços enumerados para balanceamento. O “Master” sobe um processo interno dedicado para monitorar cada um dos “Slave’s”, e saber em tempo real quantos processos estão sendo executados em cada um, e se eles não estão bloqueados para aceitar novas conexões. Caso um processo não consiga encontrar um “Slave”, ele fica tentando estabelecer uma conexão, e enquanto ele não sabe se o “Slave” está no ar ou não, ele não redireciona nenhuma conexão para aquele “Slave”.

Cada serviço que nós chamamos de “Slave”, não precisa de nenhuma chave especial, ele precisa apenas refletir as mesmas configurações de ambiente entre os servidores, e o mesmo ambiente tem que apontar para o mesmo RootPath, para o mesmo License Server, e todos os ambientes devem estar usando a mesma cópia do repositório.

Como os IPs e Portas de cada “Slave” precisam estar visíveis para o SmartClient conseguir conectar, todos os arquivos de configuração dos SmartClients do parque de máquinas deve estar configurado para apontar diretamente para o IP e porta do “Master”. Caso algum Smartclient não esteja configurado desta forma, e estiver apontando para um determinado “Slave”, isto não atrapalha o balanceamento, pois mesmo que a conexão não tenha sido redirecionada pelo “Master”, ele vai decidir o balanceamento baseado no número de processos em execução em cada “Slave”. Porém, ao apontar diretamente para um “Slave”, se ele estiver bloqueado ou indisponivel, a conexão não será direcionada para nenhum outro “Slave”.

Limitação de Balanceamento

Para cada “Slave”, devemos especificar um número na chave “connections”. Este número por padrão não é um número “absoluto” de conexões, mas sim um número de “peso” de distribuição. Por exemplo, ao configurarmos o número 20 na chave “connections” de todos os “Slave’s” para balanceamento, o “Master” vai entender que a carga deve ser distribuída igualmente entre todos os “Slave’s”. Quando configuramos um dos “Slave’s” com connections=40 e os outros três com connections=20, haverá um percentual de diferença nesta distribuição.

A fórmula é calculada primeiro somando todos os números de conexão:

40+20+20+20 = 100

Agora, calculamos o percentual de conexões a serem desviadas para cada “Slave” dividindo a quantidade de conexões pelo numero total, e multiplicando por 100:

40/100 * 100 = 40 %
20/100 * 100 = 20 %
20/100 * 100 = 20 %
20/100 * 100 = 20 %

Logo, se neste ambiente forem feitas 10 conexões, 4 vão para o “Slave”1, e 2 para cada um dos outros “Slave’s”.

Porém, como a distribuição é feita igualmente, a primeira vai pro “Slave” 1 , a segunda também, a terceira vai pra um dos demais “Slave’s”, a quarta também, a quinta também. Quando o balanceamento atinge uma posição de equilíbrio, a sexta conexão deve ir para o balance 1, a sétima também, e as três últimas serão distribuídas para os demais “Slave’s”.

Normalmente em um ambiente equilibrado, o número é o mesmo. Você pode determinar um comportamento de excessão e priorizar mais os serviços de uma determinada máquina, caso ela tenha mais memória e CPU por exemplo. Porém, o mais saudável neste caso é criar mais de um serviço “Slave” na mesma máquina, para haver uma melhor utilização de recursos. Mesmo que este número originalmente não seja um fator limitante, você deve preenchê-lo com um valor aceitável do número máximo de conexões que realmente é saudável fazer em apenas um serviço.

Existe uma configuração na seção ServerNetwork, chamada BALANCELIMIT. Uma vez habilitada, ela considera que o somatório de conexões ( no nosso exemplo, 100 ) é um fator limitante. Por default, se o ambiente passar de 100 conexões, o balanceamento vai continuar distribuindo as conexões usando a mesma regra para os “Slave’s”, até que os “Slave’s” que atingirem um pico de memória não aceitarem mais conexões. Quando habilitamos esta configuração, o Balance vai parar de distribuir conexões para todos os “Slave’s”, caso a soma do numero de processos rodando em todos os “Slave’s” mapeados atingir ou superar a soma de conexões estipuladas nas chaves “connections” de cada “Slave” mapeado no balanceamento. Quando isso acontece, o Balance não redireciona mais conexões, retornando erros de indisponibilidade de serviços para o Smartclient que tentou se conectar.

Desequilíbrio de balanceamento

Enquanto todas as pessoas estão entrando no sistema, a distribuição de conexões é sempre uniforme. Porém, quando os usuários começam a sair do sistema, os serviços podem ficar desbalanceados, onde coincidentemente os usuários que saíram estavam conectados em alguns serviços específicos. Em condições normais de uso, este desbalanceamento não interfere no poder de processamento dos usuários que ainda estão conectados — exceto se coincidirem vários processos consumindo em excesso um determinado recurso de uma máquina, pois neste caso os demais usuários conectados em serviços naquela máquina podem ser penalizados pelo consumo destes processos. De qualquer modo, mesmo temporariamente desbalanceada a quantidade de conexões, as novas conexões feitas no ambiente vão ser priorizadas pelo “Master”, para entrar justamente nos “Slave’s” com o menor número de processos.

Configurações adicionais

Por default, um servidor “Master” pode aceitar conexões vindas do SmartClient, caso não haja nenhum outro “Slave” disponivel para redirecionar a conexão. Este comportamento nem sempre é desejável em um ambiente, e pode ser desligado usando a configuração “Master”Connection=0 na seção [servernetwork].

E, existe uma configuração de diagnóstico, para colocar o mecanismo de balanceamento do “Master” em modo “Verbose”, onde cada conexão recebida por um SmartClient é registrada no log de console do Application Server (console.log), informando sua origem, e para qual “Slave” ela foi encaminhada. Basta colocar na seção ServerNetwork a chave Verbose=1

Balanceamento com SSL

Quando utilizamos SSL entre os SmartClients e o Application Server, cada serviço do Protheus, inclusive o “Master”, deve ser configurados com uma conexão SSL, em uma porta distinta. No momento de configurar cada seção de “Slave” no appserver.ini do serviço “Master”, devemos especificar a porta TCP original de cada serviço, e a porta SSL usando a configuração “SecurePort=nnnn”, onde nnnn é o número da porta reservada para conexão segura (SSL) com aquele “Slave”.

Internet e Intranet

Quando disponibilizamos um balanceamento de carga para SmartClients em um ambiente de Intranet, e queremos expandí-lo para a internet ou outra rede externa, a utilização do balanceamento de carga nativo do Application Server exige que os IPs e portas usadas pelos serviços estejam públicos, e também seja visíveis pelo mecanismo de balanceamento (Serviço “Master”), e criar dois serviços de balanceamento. Um IP e porta do “Master” de “Intranet” para serem usados pelos SmartClients dentro da sua rede interna, e um IP e Porta do “Master” para configurar os Smartclients vindos “de fora”.

Internamente, você pode fazer cada balance apontar para um grupo distinto de servidores dedicados ao acesso interno ou externo, ou colocar 2 IPs nas máquinas onde estão todos os seus serviços “Slave”, e pelo balanceamento apontar para todos os serviços disponíves na sua rede, lembrando que o “Master” de acesso interno precisa enxergar os IPs locais, e o “Master” para acesso externo tem que ser cofigurado com os IPs externos, e precisam acessar cada um dos serviços por estes IPs.

Posso usar “localhost” ?

Não, não pode. Se o “Master” retorna o IP e porta configurados para um “Slave”, na seção correspondente ao “Slave” no appserver.ini do “Master”, no ini do smartclient você apontou um IP e porta do “Master”, mas ao estabelecer a conexão, o “Master” verificou que o “localhost:6001” é a melhor opção, e vai devolver isso pro SmartClient … Logo, o smartclient vai tentar conectar com um serviço Protheus na própria máquina onde ele está instalado. Você pode usar uma máquina grande e colocar muitos serviços de Protheus nela, e fazer um balanceamento “local”, MAS os IPs e Portas usados na configuração devem ser visíveis aos SmartClients, ok ? Se você usar localhost, seu balanceamento somente vai funcionar para os SmartClients que você iniciar de dentro da própria máquina onde estão os serviços do Protheus.

Posso usar outro mecanismo de balanceamento ?

Sim, pode. Você pode usar um NLB, um Proxy reverso, qualquer outro mecanismo que não interfira no conteúdo dos pacotes trafegados e que mantenha a persistência da conexão com o “Slave” escolhido, sendo assim totalmente transparente para as aplicações envolvidas. Normalmente a única desvantagem deste mecanismo é saber se o “Slave” escolhido está realmente disponível para executar o programa. Quando um “Slave” está bloqueado, a porta TCP de conexão permanece no ar e aceita novas conexões, mas responde a elas com uma mensagem de erro e desconectam. Com este balanceamento, caso seja usada uma métrica qualquer, onde naquele momento este serviço esteja atendendo aquela métrica, uma nova conexão vai ser direcionada para ele, mas ele vai atender e retornar erro. Sem persistir a conexão, e a métrica sendo favorável para este “Slave”, você corre o risco de ninguém mais conseguir entrar no sistema, pois todas as conexões serão direcionadas para um “Slave” que está no ar, mas não está preparado para atender as conexões.

O mundo ideal

Um modelo muito interessante de distribuição de processos seria um MVC com o processamento realizado por um pool de processos dedicados, separados do processo de interface. Deste modo, o processo de interface teria um consumo de CPU e memória relativamente mais baixos, em razão apenas da quantidade de componentes de interface, e as requisições efetivas de processamento e iteração com os dados fossem distribuídos em um pool de serviços distribuídos (SOA – Service Oriented Architecture). Se as implementações de processamento mantivessem uma constante média de consumo de recursos por requisição, o mecanismo de balanceamento das requisições de processamento poderia utilizar métricas mais eficientes, direcionando um processo que consome mais CPU para um serviço onde a CPU não esteja “no gargalo”, e sabendo que um processo vai consumir mais memória que os demais, o mecanismo poderia verificar antes de distribuir quais os serviços disponíveis estão com a melhor ocupação, ou que aguentariam aceitar a requisição sem comprometer os processos em execução.

Isto atenderia tanto o cenário de desempenho quando o de resiliência. Com mais de uma máquina fisica configurada para atender mais de um tipo de requisição, caso um serviço ou uma máquina inteira apresentasse um problema ou indisponibilidade, até mesmo uma manutenção programada, cada máquina teria o seu próprio “orquestrador” de eventos, que ao perceber que uma máquina saiu fora do circuito, poderia direcionar para outro serviço em outra máquina. A sua alta disponibilidade teria um grau de tolerância de acordo com quantos porcento de máquina você tem de “sobra”.

Ao invés de configurar um cluster ativo-passivo, você determina qual é o mínimo de máquinas que precisam estar no ar para dar conta do recado, e coloca uma ou mais máquinas adicionais. Se o seu dia a dia ocuparia duas máquinas inteiras, com serviços espalhados nelas, você pode colocar uma terceira máquina. Logo, você vai ter uma máquina “de sobra”, mas utilizaria as três juntas para não bater 100% do seu uso de recursos. No pior cenário, se uma máquina inteira sair do ar por uma pane física ou uma janela de manutenção de hardware, o software redirecionaria toda a carga para os dois hardwares disponíveis, que em tese deveriam dar conta do recado enquanto essa terceira máquina não “volta”.

Se você coloca o dobro de poder computacional do que você realmente precisa, você pode “perder” momentaneamente até duas máquinas inteiras, que as duas restantes vão bater “na tampa” mas vão segurar o sistema no ar. Se neste cenário a terceira máquina ir pro vinagre, você tem ainda têm duas saídas de emergência: Priorizar os serviços importantes e tirar tudo que é supérfluo do ar naquele momento, limitando a quantidade de conexões e processos, pra a primeira máquina não sobrecarregar — é melhor alguns trabalhando do que tudo parado — e dependendo da granularização dos serviços, você pode pegar algum equipamento de “emergência” e colocar alguns “Slave’s” neles para operação de “contingência”, distribuindo os serviços essenciais que a maquina 1 não iria dar conta.

Uma outra vertente no balanceamento de carga seria a utilização dos “Slave’s” encadeados, onde os processos seriam distribuídos somente para um slave, até que um “threshold” (limite) seja atingido, então os novos processos passam a alocar o segundo slave, e assim por diante. Este cenário é muito atrativo quando pensamos em escalabilidade para ambientes “Cloud”, onde seria possível por exemplo determinar um número inicial ou mínimo de máquinas virtuais, e criar uma regra para, quando o último slave começar a receber conexões, uma nova VM é disponibilizada no ambiente, e automaticamente inserida no balanceamento. Estas máquinas virtuais adicionais, assim que todas as conexões forem encerradas, poderia ‘fechar-se’ e ser descartada.

Em um ambiente de processamento de múltiplos hardwares, isto também seria útil para fazer uma janela de manutenção de um Hardware. Por exemplo, alterando a priorização dos slaves, deixamos por último na fila todos os slaves de um determinado equipamento, e bloqueamos as conexões para estes slaves. Conforme os usuários fossem desconectando da interface, no final do dia a máquina não estaria com nenhuma conexão, podendo ser tirada inteira do ar para manutenção, se que nenhum usuário desse falta disso.

Conclusão

A busca por eficiência de processos deve ser sempre uma constante em qualquer sistema com previsão de crescimento. E já existem muitos recursos voltados para isso. Entender como a aplicação funciona é um passo a mais para abrir caminhos para mudanças, com mais certeza de sucesso nas mudanças planejadas. Espero que este post tenha dado alguma luz “a mais” neste mecanismo, e que vocês tenham TERABYTES de sucesso na utilização deles 😀

Até o próximo post, pessoal 😉

Documentação TDN

Balanceamento de carga entre serviços

Seção [ServerNetwork]

Processo de monitoramento e controle de memória

Configuração BALANCELIMIT

Referências

Load balancing (computing). (2015, October 16). In Wikipedia, The Free Encyclopedia. Retrieved 00:49, October 31, 2015, from https://en.wikipedia.org/w/index.php?title=Load_balancing_(computing)&oldid=686092130

Reverse proxy. (2015, October 23). In Wikipedia, The Free Encyclopedia. Retrieved 13:47, October 31, 2015, from https://en.wikipedia.org/w/index.php?title=Reverse_proxy&oldid=687157637

Acelerando o AdvPL – Lendo arquivos TXT

Introdução

Alguém me perguntou na sexta-feira, qual era o método mais rápido de ler um arquivo TXT, que utilizava apenas o código ASCII 13 (CR) como quebra de linha… Normalmente eu mesmo usaria as funções FT_Fuse() e FT_FReadLn() para ler o arquivo … mas estas funções não permitem especificar o caractere de quebra… elas trabalham com arquivos TXT onde a quebra pode ser CRLF ou apenas LF.

Como eu não lembrava quem tinha me perguntado, e não achei a pergunta no WhatsApp, e-mail, FaceBook, etc…. resolvi postar a resposta no Facebook mesmo, explicando duas alternativas rápidas de leitura, uma consumindo mais memória e lendo o arquivo inteiro, e outra para arquivos sem limite de tamanho, e consumindo menos memória. Como houve interesse no assunto e diversos comentários, resolvi fazer uma classe de exemplo da segunda abordagem, partindo das premissas do melhor desempenho versus o menor consumo de memória possível.

Fonte da Clase ZFWReadTXT

#include 'protheus.ch'
/* ======================================================================
 Classe ZFWReadTXT
 Autor Júlio Wittwer
 Data 17/10/2015
 Descrição Método de leitura de arquivo TXT
Permite com alto desempenho a leitura de arquivos TXT
 utilizando o identificador de quebra de linha definido
 ====================================================================== */
#define DEFAULT_FILE_BUFFER 4096
CLASS ZFWReadTXT FROM LONGNAMECLASS
  DATA nHnd as Integer
  DATA cFName as String
  DATA cFSep as String
  DATA nFerror as Integer
  DATA nOsError as Integer
  DATA cFerrorStr as String
  DATA nFSize as Integer
  DATA nFReaded as Integer
  DATA nFBuffer as Integer
  DATA _Buffer as Array
  DATA _PosBuffer as Integer
  DATA _Resto as String
  // Metodos Pubicos
  METHOD New()
  METHOD Open()
  METHOD Close()
  METHOD GetFSize()
  METHOD GetError()
  METHOD GetOSError()
  METHOD GetErrorStr()
  METHOD ReadLine()
  // Metodos privados
  METHOD _CleanLastErr()
  METHOD _SetError()
  METHOD _SetOSError()
ENDCLASS
METHOD New( cFName , cFSep , nFBuffer ) CLASS ZFWReadTXT
DEFAULT cFSep := CRLF
DEFAULT nFBuffer := DEFAULT_FILE_BUFFER
::nHnd := -1
::cFName := cFName
::cFSep := cFSep
::_Buffer := {}
::_Resto := ''
::nFSize := 0
::nFReaded := 0
::nFerror := 0
::nOsError := 0
::cFerrorStr := ''
::_PosBuffer := 0
::nFBuffer := nFBuffer
Return self

METHOD Open( iFMode ) CLASS ZFWReadTXT
DEFAULT iFMode := 0
::_CleanLastErr()
If ::nHnd != -1
 _SetError(-1,"Open Error - File already open")
 Return .F.
Endif
// Abre o arquivo
::nHnd := FOpen( ::cFName , iFMode )
If ::nHnd < 0
 _SetOSError(-2,"Open File Error (OS)",ferror())
Return .F.
Endif
// Pega o tamanho do Arquivo
::nFSize := fSeek(::nHnd,0,2)
// Reposiciona no inicio do arquivo
fSeek(::nHnd,0)
Return .T.
METHOD Close() CLASS ZFWReadTXT
::_CleanLastErr()
If ::nHnd == -1
 _SetError(-3,"Close Error - File already closed")
Return .F.
Endif
// Close the file
fClose(::nHnd)
// Clean file read cache 
aSize(::_Buffer,0)
::_Resto := ''
::nHnd := -1
::nFSize := 0
::nFReaded := 0
::_PosBuffer := 0
Return .T.
METHOD ReadLine( /*@*/ cReadLine ) CLASS ZFWReadTXT
Local cTmp := ''
Local cBuffer
Local nRPos
Local nRead
// Incrementa o contador da posição do Buffer
::_PosBuffer++
If ( ::_PosBuffer <= len(::_Buffer) )
 // A proxima linha já está no Buffer ...
 // recupera e retorna
 cReadLine := ::_Buffer[::_PosBuffer]
 Return .T.
Endif
If ( ::nFReaded < ::nFSize )
  // Nao tem linha no Buffer, mas ainda tem partes
  // do arquivo para ler. Lê mais um pedaço
  nRead := fRead(::nHnd , @cTmp, ::nFBuffer)
  if nRead < 0
    _SetOSError(-5,"Read File Error (OS)",ferror())
    Return .F.
  Endif
  // Soma a quantidade de bytes lida no acumulador
  ::nFReaded += nRead
  // Considera no buffer de trabalho o resto
  // da ultima leituraa mais o que acabou de ser lido
  cBuffer := ::_Resto + cTmp
  // Determina a ultima quebra
  nRPos := Rat(::cFSep,cBuffer)
  If nRPos > 0
    // Pega o que sobrou apos a ultima quegra e guarda no resto
    ::_Resto := substr(cBuffer , nRPos + len(::cFSep))
    // Isola o resto do buffer atual
    cBuffer := left(cBuffer , nRPos-1 )
  Else
    // Nao tem resto, o buffer será considerado inteiro
    // ( pode ser final de arquivo sem o ultimo separador )
    ::_Resto := ''
  Endif
 // Limpa e Recria o array de cache
 // Por default linhas vazias são ignoradas
 // Reseta posicionamento de buffer para o primeiro elemento 
 // E Retorna a primeira linha do buffer 
 aSize(::_Buffer,0)
 ::_Buffer := StrTokArr2( cBuffer , ::cFSep )
 ::_PosBuffer := 1
 cReadLine := ::_Buffer[::_PosBuffer]
 Return .T.
Endif
// Chegou no final do arquivo ...
::_SetError(-4,"File is in EOF")
Return .F.
METHOD GetError() CLASS ZFWReadTXT
Return ::nFerror
METHOD GetOSError() CLASS ZFWReadTXT
Return ::nOSError
METHOD GetErrorStr() CLASS ZFWReadTXT
Return ::cFerrorStr
METHOD GetFSize() CLASS ZFWReadTXT
Return ::nFSize
METHOD _SetError(nCode,cStr) CLASS ZFWReadTXT
::nFerror := nCode
::cFerrorStr := cStr
Return
METHOD _SetOSError(nCode,cStr,nOsError) CLASS ZFWReadTXT
::nFerror := nCode
::cFerrorStr := cStr
::nOsError := nOsError
Return
METHOD _CleanLastErr() CLASS ZFWReadTXT
::nFerror := 0
::cFerrorStr := ''
::nOsError := 0
Return

Como funciona

No construtor da classe (método NEW), devemos informar o nome do arquivo a ser aberto, e opcionalmente podemos informar quais são os caracteres que serão considerados como “quebra de linha”. Caso não especificado, o default é CRLF — chr(13)+chr(10). Mas pode ser especificado também apenas Chr(13) (CR) ou Chr(10) (LF). E, como terceiro parâmetro, qual é o tamanho do cache em memória que a leitura pode utilizar. Caso não informado, o valor default são 4 KB (4096 bytes). Caso as linhas de dados do seu TXT tenha em média 1 KB, podemos aumentar seguramente este número para 8 ou 16 KB, para ter um cache em memória por evento de leitura em disco de pelo menos 8 ou 16 linhas.

A idéia é simples: Na classe de encapsulamento de leitura, a linha é lida por referência pelo método ReadLine(), que pode retornar .F. em caso de erros de acesso a disco, ou final de arquivo (todas as linhas já lidas). A abertura do arquivo apenas determina o tamanho do mesmo, para saber quanto precisa ser lido até o final do arquivo. A classe mantém um cache de linhas em um array, e uma propriedade para determinar o ponto atual do cache. Ele começa na primeira linha.

Na primeira leitura, o array de cache está vazio, bem como um buffer temporário chamado “_resto”. A primeira coisa que a leitura faz é incrementar o ponteiro do cache e ver se o ponteiro não passou o final do cache. Como na primeira leitura o cache está vazio, a próxima etapa é verificar se ainda falta ler alguma coisa do arquivo.

Caso ainda exista dados para ler do arquivo, um bloco de 4 KB é lido para a memória, em um buffer de trabalho montado com o resto da leitura anterior (que inicialmente está vazio) mais os dados lidos na operação atual. Então, eu localizo da direita para a esquerda do buffer a ultima quebra do buffer lido, guardo os dados depois da última quebra identificada no buffer “_resto” e removo estes dados do buffer atual de trabalho.

Desse modo, eu vou ter em memória um buffer próximo do tamanho máximo do meu cache (4 KB), considerando a última quebra encontrada neste buffer. Basta eu transformá-lo em um array usando a função StrTokArr2, guardar esse array na propriedade “_Buffer” da classe, e retornar a primeira linha lida.

Quando o método ReadLine() for chamado novamente, o cache vai estar alimentado com “N” linhas do arquivo, eu apenas movo o localizador do cache uma unidade pra frente, e se o localizador ainda está dentro do array, eu retorno a linha correspondente. Eu nem preciso me preocupar em limpar isso a cada leitura, afinal a quantidade de linhas em cache vai ocupar pouco mais de 4 KB mesmo … eu somente vou fazer acesso a disco, e consequente limpeza e realimentação desse cache quando o cache acabar, e ainda houver mais dados no arquivo a ser lido.

Desempenho

Peguei um arquivo TXT aleatório no meu ambiente, que continha um LOG de instalação de um software. O arquivo têm 1477498 bytes, com 12302 linhas de texto. O arquivo foi lido inteiro e todas as linhas identificadas entre 39 e 42 milissegundos (0,039 a 0,042 segundos). Resolvi fazer um outro fonte de testes, lendo este arquivo usando FT_FUSE e FT_FREADLN. Os tempos foram entre 51 e 55 milissegundos (0,051 a 0,055 segundos). E, usando a classe FWFileReader(), os tempos foram entre 101 e 104 milissegundos (0,101 e 0,104 segundos).

Repeti o mesmo teste com um arquivo suficientemente maior, 50 mil linhas e 210 MB .. os tempos foram:

FWFileReader ...... 2,937 s.
FT_FreadLN ........ 1,233 s.
ZFWReadTXT ........ 0,966 s.

Conclusão

Ganhar centésimos de segundo pode parecer pouco … mas centésimos de segundos podem significar uma largada na “pole position”, ou largar na 5a fila … Em uma corrida de 60 voltas, um segundo por volta torna-se um minuto. A máxima do “mais com menos” permanece constante. Quanto menos ciclos de máquina você consumir para realizar um processamento, mais rápido ele será. Quando falamos em aplicações com escalabilidade “estrelar”, para milhões de requisições por segundo, onde temos um limite natural de número de processos dedicados por máquina, quanto mais rápido cada processo for executado, maior “vazão” de processamento pode ser obtido com a mesma máquina. Principalmente quando entramos em ambientes “Cloud”, mesmo tendo grande disponibilidade de processamento, normalmente você será tarifado proporcionalmente ao que consumir. Chegar ao mesmo resultado com menos consumo de recurso fará a diferença 😀

Nos próximos tópicos (aceito sugestões), vou procurar abordar as questões de “como fazer mais rápido” determinada tarefa ou processo, acredito que esta abordagem ajuda a aproximar todos os conceitos de escalabilidade e desempenho que foram abordados apenas na teoria nos posts iniciais sobre este tema !

Agradeço a todos pela audiência do Blog, e não sejam tímidos em dar sugestões, basta enviar a sua sugestão de post siga0984@gmail.com 😀 Para dúvidas pertinentes ao assunto do post, basta postar um comentário no post mesmo 😉

E, pra finalizar, segue abaixo o fonte de teste de desempenho utilizado:

#include 'protheus.ch'
#define TEXT_FILE '\meuarquivo.txt'
/* ======================================================================
Função U_LeFile1, 2 e 3()
Autor Júlio Wittwer
Data 17/10/2015
Descrição Fontes de teste comparativo de desempenho de leitura de arquivo TEXTO
U_LeFile1() - Usa ZFWReadTXT
U_LeFile2() - Usa FT_FREADLN
U_LeFile3() - Usa FWFileReader
====================================================================== */
User Function LeFile1()
Local oTXTFile
Local cLine := ''
Local nLines := 0
Local nTimer
nTimer := seconds()
oTXTFile := ZFWReadTXT():New(TEXT_FILE)
If !oTXTFile:Open()
  MsgStop(oTXTFile:GetErrorStr(),"OPEN ERROR")
  Return
Endif
While oTXTFile:ReadLine(@cLine)
  nLines++
Enddo
oTXTFile:Close()
MsgInfo("Read " + cValToChar(nLines)+" line(s) in "+str(seconds()-nTimer,12,3)+' s.',"Using ZFWReadTXT")
Return
User Function LeFile2()
Local nTimer
Local nLines := 0
nTimer := seconds()
FT_FUSE(TEXT_FILE)
While !FT_FEOF()
  cLine := FT_FReadLN()
  FT_FSkip()
  nLines++
Enddo
FT_FUSE()
MsgInfo("Read " + cValToChar(nLines)+" line(s) in "+str(seconds()-nTimer,12,3)+' s.',"Using FT_FReadLN")
Return
User Function LeFile3()
Local nTimer
Local nLines := 0
Local oFile
nTimer := seconds()
oFile := FWFileReader():New(TEXT_FILE)
If !oFile:Open()
  MsgStop("File Open Error","ERROR")
  Return
Endif
While (!oFile:Eof())
  cLine := oFile:GetLine()
  nLines++
Enddo
oFile:Close()
MsgInfo("Read " + cValToChar(nLines)+" line(s) in "+str(seconds()-nTimer,12,3)+' s.',"Using FWFileReader")
Return

Acelerando o AdvPL – Parte 03

Introdução

Continuando os tópicos de performance e escalabilidade direcionados ao AdvPL, vamos hoje unir o útil ao agradável: Vamos abordar detalhes algumas boas práticas, inclusive algumas já mencionadas na documentação da TDN. Inclusive, enquanto este artigo era redigido, encontrei uma documentação muito interessante no link http://tdn.totvs.com/pages/viewpage.action?pageId=22480352, que abrange assuntos como as convenções da linguagem, padrões de design, práticas e técnicas, e inclusive desempenho.

Macroexecução em AdvPL

Como o foco deste tópico é justamente acelerar o AdvPL, vamos abordar um dos tópicos de desempenho (http://tdn.totvs.com/display/framework/Desempenho). Um dos testes realizados e publicados nesta seção da TDN foi uma comparação entre duas abordagens de exemplo de chamada de função, uma usando a macroexecução e outra usando a função Eval(), onde o teste realizado mostrou um melhor desempenho na macroexecução.

Fonte de testes

Baseado no fonte de testes da TDN, eu criei um fonte que simula quatro formas diferentes da chamada de uma função de processamento de exemplo, onde existe a passagem de dois parâmetros. Vamos ao código:

#INCLUDE "PROTHEUS.CH"
#DEFINE ITERATION_REPEAT 800000 // Repetições de testes
User Function EvalTest()
TesEcom1() // Teste 01 com "&" 
TesEcom2() // Teste 02 com "&"
TesEvalC() // Teste com Eval()
TesDiret() // Teste com chamada direta
Return
/* ---------------------------------------------------------------
Teste TesEcom1()
Usando macro-substituição, passando os parâmetros por fora da macro.
--------------------------------------------------------------- */
Static Function TesEcom1()
Local nX, nSeconds, nTimer
Local cRet := ""
Local cBloco := "FunTesExec"
nSeconds := Seconds()
For nX := 1 To ITERATION_REPEAT 
 cRet := &cBloco.(nX,10)
Next nX
nTimer := Seconds() - nSeconds
ConOut("("+procname(0)+") Tempo de execucao ....: " +str(nTimer,6,2)+' s.' )
Conout("("+procname(0)+") Operacoes por segundo.: "+str(ITERATION_REPEAT/nTimer,10))
Return
/* ---------------------------------------------------------------
Teste TesEcom2()
Usando macro-substituição, passando os parametros DENTRO da macro.
--------------------------------------------------------------- */
Static Function TesEcom2()
Local nX, nSeconds, nTimer
Local cRet := ""
Local cBloco
nSeconds := Seconds()
For nX := 1 To ITERATION_REPEAT 
 cBloco := "FunTesExec("+cValToChar(nX)+",10)"
 cRet := &(cBloco)
Next nX
nTimer := Seconds() - nSeconds
ConOut("("+procname(0)+") Tempo de execucao ....: " +str(nTimer,6,2)+' s.' )
Conout("("+procname(0)+") Operacoes por segundo.: "+str(ITERATION_REPEAT/nTimer,10))
Return
/* ---------------------------------------------------------------
Teste TesEvalC()
Usando Code-Block 
--------------------------------------------------------------- */
Static Function TesEvalC()
Local nX, nSeconds, nTimer
Local cRet := "" // Retorno do Bloco de Codigo
Local bBloco := {|p1,p2| FunTesExec(p1,p2)}
nSeconds := Seconds()
For nX := 1 To ITERATION_REPEAT
 cRet := Eval(bBloco,nx,10)
Next nX
nTimer := Seconds() - nSeconds
ConOut("("+procname(0)+") Tempo de execucao ....: " +str(nTimer,6,2)+' s.' )
Conout("("+procname(0)+") Operacoes por segundo.: "+str(ITERATION_REPEAT/nTimer,10))
Return
/* ---------------------------------------------------------------
Teste TesDiret()
Usando Chamada Direta
--------------------------------------------------------------- */
Static Function TesDiret()
Local nX := 0
Local nSeconds := 0
Local cRet := ""
nSeconds := Seconds()
For nX := 1 To ITERATION_REPEAT
 cRet := FunTesExec(nX,10)
Next nX
nTimer := Seconds() - nSeconds
ConOut("("+procname(0)+") Tempo de execucao ....: " +str(nTimer,6,2)+' s.' )
Conout("("+procname(0)+") Operacoes por segundo.: "+str(ITERATION_REPEAT/nTimer,10))
Return
/* ---------------------------------------------------------------
Funcao de Teste FunTesExec()
Apenas encapsula a função StrZero
--------------------------------------------------------------- */
STATIC Function FunTesExec(nExpr, nTam)
Local cNum
DEFAULT nExpr := 1
DEFAULT nTam := 1
cNum := StrZero(nExpr,nTam)
Return cNum

Resultados do teste

No meu notebook, os resultados obtidos no console do TOTVS AppServer foram estes :

(TESECOM1) Tempo de execucao ....: 3.16 s.
(TESECOM1) Operacoes por segundo.: 253325
(TESECOM2) Tempo de execucao ....: 21.12 s.
(TESECOM2) Operacoes por segundo.: 37879
(TESEVALC) Tempo de execucao ....: 3.16 s.
(TESEVALC) Operacoes por segundo.: 253084
(TESDIRET) Tempo de execucao ....: 2.34 s.
(TESDIRET) Operacoes por segundo.: 341297

Refletindo sobre estes tempos

Inicialmente, vamos ver o tempo mais rápido. Naturalmente, é uma chamada direta da função de processamento. Demorou 2,34 segundos para fazer 800 mil iterações. São aproximadamente 340 mil execuções por segundo. Depois vem os tempos de execução via macro (01) e Eval(), 3,16 segundos, aproximadamente 253 mil execuções por segundo. O tempo ficou 35 % maior para ambos os casos. Agora, o tempo mais lento, do segundo teste de macro-execução, demorou 21 segundos, apresentando um desempenho de 37 mil execuções por segundo, que ficou 9 vezes mais lento que o melhor resultado.

Agora, vamos olhar com uma lupa o “miolo” dos dois casos de teste com macro-execução, que é o foco principal deste post:

No primeiro teste, a chamada da função é realizada com uma forma de macro-substituição, onde apenas o nome da função a ser chamada está dentro da variável cBloco, e os parâmetros são passados explicitamente em cada chamada por fora da Macro.

cBloco := "FunTesExec"
For nX := 1 To ITERATION_REPEAT 
 cRet := &cBloco.(nX,10)
Next nX

No segundo teste, a macro é montada dentro de uma string, onde os parâmetros da função são montados dentro da string. Como a variável nX é local, ela não pode ser especificada diretamente, pois a visibilidade de resolução da macro não consegue pegar as variáveis locais.

For nX := 1 To ITERATION_REPEAT 
 cBloco := "FunTesExec("+cValToChar(nX)+",10)"
 cRet := &(cBloco)
Next nX

No primeiro teste, o texto da macro ( FunTesExec ) não muda durante as interações, o que permite ao AdvPL fazer um cache da resolução da macro, e acelerar o processo. Já no segundo teste, a macro é alterada com novos parâmetros a cada nova execução, o que exige que a macro seja recompilada na memória a cada iteração. Isto dá uma grande diferença de desempenho entre os dois ciclos de iterações.

Conclusão

Existem casos especiais onde a cópia dos parâmetros para uma macro precisam realmente ser feitas na chamada, mas agora não lembro de nenhum exemplo assim, “de cabeça”. Mas, para a maioria dos outros casos, onde você pode precisa fazer dinamicamente apenas a chamada de uma função dinâmica, cujo nome está em uma variável caractere, é mais rápido passar os parâmetros por fora.

Desejo a todos excelentes otimizações, um desempenho fantástico, e um ótimo final de semana 😀

Abraços, e até o próximo post 😉

Acelerando o AdvPL – Parte 02 (ERRATA)

Pessoal, boa tarde,

Hoje eu estava lendo novamente o código do post anterior, referente ao exemplo de um cache em array, com tamanho limitado de elementos, e uma optimização para subir no array cada item pesquisado, para que os itens mais pesquisados sejam encontrados sequencialmente em primeiro lugar, e encontrei um erro de lógica em um ponto da rotina … vamos ver apenas este pedaço do código:

Function GetJurosPad( cTipoCtr )
Local nJuros := -1
Local cChave := xFilial('ZZ1')+cTipoCtr
Local nPos := ascan(aJurosPad,{ |x| x[1] == cChave })
Local aTmp
If nPos > 0 
 // Achou no cache. 
 If nPos > 1 
 // nao esta na primeira posição, sobe uma 
   aTmp := aJurosPad[nPos]
   aJurosPad[nPos] := aJurosPad[nPos-1]
   aJurosPad[nPos-1] := aTmp
 Endif
 // Incrementa cache HITS -- achei no cache, economia de I/O
 nJurosHit++
 Return aJurosPad[nPos][2]
Endif

Reparem que a busca foi feita na declaração da variável nPos, usando Ascan(). Porém, caso o item encontrado não seja o primeiro elemento do array, o elemento encontrado sobe uma posição (usando a variável aTmp para fazer o SWAP). E, no final das contas, o valor retornado é a segunda coluna do elemento após a troca. Neste caso, a função somente vai retornar o valor correto caso o valor buscado seja encontrado no primeiro elemento do array.

Para  corrigir isto, precisamos armazenar o resultado encontrado antes de fazer a troca no array, e retornar este valor, veja a correção abaixo:

Function GetJurosPad( cTipoCtr )
Local nJuros := -1
Local cChave := xFilial('ZZ1')+cTipoCtr
Local nPos := ascan(aJurosPad,{ |x| x[1] == cChave })
Local aTmp
If nPos > 0 
 // Achou no cache. 
 nJuros := aJuros[nPos][2]
 If nPos > 1 
   // nao esta na primeira posição, sobe uma 
   aTmp := aJurosPad[nPos]
   aJurosPad[nPos] := aJurosPad[nPos-1]
   aJurosPad[nPos-1] := aTmp
 Endif
 // Incrementa cache HITS -- achei no cache, economia de I/O
 nJurosHit++
 Return nJuros
Endif

Lembrem-se da importância de revisar e testar o código. Ser um bom programador e ter experiência não vai te livrar de cometer erros, uma revisão de código e um bom teste nunca é uma questão de desconfiança, mas sim procedimento.

Até a próxima, pessoal 😀

Acelerando o AdvPL – Parte 01

Introdução

Como já foi visto e frisado nos tópicos anteriores sobre escalabilidade e desempenho, a premissa de “fazer mais com menos” é uma boa pratica em qualquer linguagem de programação. Existem várias formas de escrever um algoritmo, e todas serão corretas se chegarem ao resultado esperado. Porém, para cada caso existe a maneira “ótima” de escrever o algoritmo, usando a lógica e as funções da linguagem AdvPL da forma adequada para chegarmos ao mesmo resultado usando uma quantidade menor de recursos, o que normalmente pode ser traduzido em economia de tempo.

Introdução aos Estudos de Caso

Cada questão requer um estudo de caso, e normalmente alguns testes. Normalmente uma aplicação AdvPL possui um código que engloba I/O (leitura ou gravação de dados do disco ou do Banco de Dados), e o processamento destes dados pelo programa. Em determinados momentos existe um uso maior de disco, em outros um uso maior de CPU, ou de rede, ou do Banco de Dados.

A abordagem acertiva acerca do desempenho de uma rotina é identificar quais são as operações que correspondem ao consumo de tempo de pelo menos 70 % da rotina (os maiores tempos), e verificar se estas etapas de processo podem ser melhoradas. Devemos sempre ter em mente a lei de Amdahl: “O ganho de desempenho que pode ser obtido melhorando uma determinada parte do sistema é limitado pela fração de tempo que essa parte é utilizada pelo sistema durante a sua operação – Gene Amdahl”. Logo, quanto maior forem as optimizações nas partes do código relevantes no processamento, mais serão percebidos os ganhos da otimização.

Estudo de caso – Processamento de arquivo TXT

Recentemente analizei um caso de processamento, onde um programa AdvPL precisava fazer a leitura de um arquivo TXT para uma integração, onde o arquivo poderia conter linhas maiores que 1 KB. Normalmente utilizamos as funções ft_fuse() e ft_freadln() para ler linhas de um arquivo TXT, porém estas funções são limitadas a ler linhas de tamanho máximo de 1 KB. E, para cada linha lida, também era necessário identificar as colunas de dados, que eram separadas por vírgula (formato CSV).

A parte de leitura de dados foi resolvida de forma simples, usando fOpen() com fREad() ou fReadStr(), lendo um pedaço do arquivo maior para a memória, e verificando neste pedaço lido se havia uma quebra de linha (CRLF / sequência de caracteres chr(13) + Chr(10) ).

Uma vez lida a linha, ela era submetida para uma função que separava os valores das colunas encontradas considerando a virgula como separador, usando uma função mais ou menos assim:

STATIC Function MYStrTok1( cBuffer, cDelimit )
Local cTexto := ''
Local nX := 0
Local aLinha := {}
If Len(cBuffer) > 0
  cTexto := ""
  For nX := 1 to Len(cBuffer)
    If Substr(cBuffer,nX,1) == cDelimit
      aAdd(aLinha, cTexto )
      cTexto := "" 
    Else
      cTexto += Substr(cBuffer,nX,1)
    Endif
  Next nX
  If !Empty(cTexto)
    aAdd(aLinha, cTexto )
  Else
    aAdd(aLinha, " " )
  Endif
Endif
Return( aLinha )

A função é relativamente simples. Criamos uma variável de uso temporária chamada cTexto, inicializada com uma string vazia (“”), e verificamos cada posição da string informada como parâmetro. Cada valor diferente do separador é inserido em cTexto, e no momento que o separador é encontrado, o valor de cTexto é inserido no array de retorno, e cTexto é inicializado novamente para uma string vazia.

Ao fazer um teste com um arquivo texto de 200 MB, contendo 50 mil linhas de texto, onde cada linha possuía 11 palavras de tamanho variável (entre 150 e 250 caracteres) separadas por vírgula. Considerando apenas o tempo de identificação dos dados e separação das colunas, a rotina demorava aproximadamente 55 segundos para processar o arquivo inteiro, com 550 mil palavras. A lógica da rotina não está errada, ela chega ao resultado esperado em 55 segundos.

Se dividirmos a quantidade de palavras processadas pelo tempo de processamento ( 550000 / 55 ), chegamos ao número estrelar de 10 mil palavras por segundo. Isso e bem rápido, certo ?

Analisando com uma lupa

Vamos olhar mais de perto a função lida com as informações: Cada letra da string é extraída usando a função substr(), atuando na linha inteira, caractere por caractere. Cada caractere é comparado com o delimitador, e caso seja igual, o conteúdo de cTexto é acrescentado no array e a variável é limpa, caso contrário o mesmo caractere é extraído novamente, e acrescentado na variável cTexto, que vai crescendo na memoria um caractere por vez.

Refatorando e otimizando

E, se ao invés de extrairmos os caracteres um a um, usarmos uma função que busca dentro da string a próxima ocorrência do delimitador (“,”), e caso seja encontrado, nós acrescentamos no array o primeiro pedaço da string até a posição encontrada, e removemos este pedaço do texto já identificado, inclusive com a vírgula, da variável cBuffer… O fonte ficaria assim:

STATIC Function MYStrTok2( cBuffer, cDelimit )
Local nX := 0
Local aLinha := {}
If Len(cBuffer) > 0
  while ( ( nX := At(cDelimit,cBuffer) ) > 0 )
    aAdd(aLinha, left(cBuffer,nX-1) )
    cBuffer := substr(cBuffer,nX+1)
  Enddo
  If !Empty(cBuffer)
    aAdd(aLinha, cBuffer )
  Endif
Endif
Return( aLinha )

Na instrução while, usamos a variável nX, fazendo uma atribuição em linha do resultado da função AT(), que retorna a primeira posição de cBuffer que contém o delimitador cDelimit, ou 0 caso o delimitador não seja encontrado. Enquanto houver um delimitador encontrado, acrescentamos no array aLinha a parte esquerda da string, da primeira posição até a posição do delimitador – 1 ( desconsiderando o delimitador ), e na linha de baixo reatribuimos o valor de cBuffer, usando a função Substr(), considerando como inicio do novo buffer o próximo caractere após o delimitador até o final da String. No final do loop, caso o buffer não esteja vazio, a linha não terminou com um delimitador, então o que sobrou no buffer é acrescentado no array de retorno.

Testando a velocidade

Repetindo o mesmo teste, com o mesmo arquivo, mas usando a nova função, o tempo cair de 55 segundos, para 4,5 s. (quatro segundos e meio). Com a função original, 10 mil palavras eram identificadas por segundo. Agora, com a nova função, aproximadamente 122 mil palavras são identificadas por segundo 😀

Isso apresentou um ganho de aproximadamente 12x de desempenho.

Em se tratando de uma rotina de transformação de dados, onde a entrada é um TXT, e a saída é um XML, por exemplo, considerando que o tempo de escrita dos dados identificados em um arquivo no disco gaste uns 20 segundos, a rotina original demorava 55 + 20 = 75 segundos, pouco mais de um minuto. Ao otimizar a identificação de palavras, a nova rotina passa a demorar 5 + 20 = 25 segundos, pouco menos que meio minuto.

De 75 segundos, esta etapa de processo foi reduzida para 25 segundos, apresentando um ganho de 3x, ou executando em apenas 1/3 do tempo da rotina original. Isto sim reflete em um ganho de tempo sensível e perceptível na operação.

A relatividade de Amdahl

Considerando apenas a etapa de processo de ler um TXT e gravar um XML, onde ler e identificar as palavras levava 55 segundos, o ganho nesta etapa foi de 12 vezes, ou seja, passou a ser executada em uma fração de 1/12 do tempo original. Quando consideramos o processo inteiro, inclusive a gravação — onde não houve nenhuma otimização — o ganho de desempenho foi de 3 vezes. Se este processamento tivesse mais etapas, que demoram mais tempo, o ganho continua existindo, mas ele não vai ser facilmente percebido, pois se as etapas posteriores representam mais 20 minutos de processamento, e você otimizou apenas um pedaço que consumia 1 minuto, no final das contas o tempo total do processo caiu de 20 para 19 minutos. A otimização realizada agiu sobre uma fatia de tempo muito curta em relação ao processo inteiro (apenas 5% do tempo total do processo foi otimizado).

Agradecimentos

Meus agradecimentos ao nobre leitor e desenvolvedor Eurai Rapelli, que me procurou para tirar uma dúvida, e contribuiu com um caso de uso interessante e sob medida para esta publicação 😀

Conclusão

Quanto mais etapas de processo você conseguir otimizar, mais perceptível será o ganho, ou em tempo, ou em carga do equipamento. E é preciso tomar cuidado com o excesso de otimizações, pois elas tendem a especializar as rotinas, e normalmente elas ficam mais complexas, e isso pode aumentar o custo de manutenção. Por isso, o foco em desempenho e performance deve ser direcionado para os casos onde realmente ele é necessário.

Espero que vocês gostem e tirem bom proveito deste post, comentem, tirem suas dúvidas, e para sugerir um assunto a ser abordado no Blog, é só mandar um e-mail para “siga0984@gmail.com”, com o assunto “Sugestão para o Blog” 😀

Até o próximo post, pessoal !!!