Clique aqui para ler todos os artigos desta edição
Identificando problemas em aplicativos com o LoadGenerator
por Brian Otto
Para criar um aplicativo confiável, é preciso entender como os diversos componentes agirão sob cargas pesadas e ter certeza de que o sistema se comportará apropriadamente. Os componentes de servidor para Web sites muito visitados precisam atender a milhões de solicitações diariamente. Os aplicativos desktop e as intranets corporativas só precisam tratar de algumas centenas ou de alguns milhares de solicitações por dia. Muitas empresas de desenvolvimento menores acreditam não ter tempo nem dinheiro para dedicar recursos à criação de ambientes que realizem testes de resistência rigorosos, apesar de grande parte do sucesso de um aplicativo ser atribuída à freqüência com que ele atende a seus usuários. É importante você despender parte do seu tempo tentando descobrir como o aplicativo responderá a usos extremos e falhas, evitando, assim, um possível fracasso.
O teste de resistência deve ser realizado em todos os estágios de desenvolvimento, incluindo o estágio anterior à incorporação dos componentes individuais em um aplicativo maior, para evitar problemas maiores no ciclo de desenvolvimento. Para agilizar e facilitar essa tarefa de execução do conjunto inicial de testes tenha as ferramentas apropriadas à sua disposição.
Introdução
O Microsoft® .NET Framework fornece uma excelente base para a criação de uma ferramenta para teste de resistência reutilizável e extensível que facilite o teste do seu aplicativo. O aplicativo deste artigo usa a reflexão (reflection) para carregar dinamicamente uma classe personalizada em um assembly criado por você. A classe executa uma série de tarefas que representam um teste desejado. Você controla quantas instâncias da classe são carregadas e quantas vezes o aplicativo chama cada instância durante a simulação. Cada instância da classe é instanciada em um thread à parte. Como as tarefas reais estão sendo executadas em um assembly personalizado criado pelo usuário, existe a flexibilidade de criar componentes de trabalho (worker components) que sobrecarreguem praticamente qualquer camada de aplicação, seja um Web Service, um banco de dados ou um componente atendido pelo COM+. A ferramenta de teste de resistência pode ajudá-lo a verificar se o seu aplicativo cumpre os requisitos de desempenho e concorrência, além de ajudar a identificar como ele responde a cargas pesadas.
Figura 1 LoadGenerator
A Figura 1 mostra o formulário principal do aplicativo. No frame Test Setup, você pode configurar como a simulação será realizada. A configuração Workers identifica quantas instâncias (threads) serão criadas para a execução do teste. O número de iterações para cada instância é definido por meio do valor Cmds/Worker. Think time representa o retardo entre as iterações sucessivas de cada instância. Ao realizar um teste de resistência, também é importante controlar o tempo de aquecimento (warm up). Na maioria dos sistemas, para que um trabalho seja executado, é necessário que seja feita alguma inicialização (por exemplo, as páginas ASP.NET são compiladas na primeira vez em que são acessadas). Durante o período de aquecimento, a fim de minimizar a distorção dos resultados do teste, nenhum dado de desempenho é coletado. As informações no frame Statistics fornecem um meio de monitorar a simulação. Quando o teste é concluído, um formulário de status é exibido com algumas estatísticas para cada instância (veja a Figura 2).
Figura 2 Estatísticas de desempenho
Caso você tenha marcado a caixa Show Detailed Progress (Mostrar andamento detalhado) durante a simulação do formulário principal, esse formulário estará visível durante o teste. Como a atualização desse formulário durante o teste consumirá recursos, ela deve ser executada de forma moderada quando estiver sendo feita a simulação de uma carga pesada.
Criando uma thread de trabalho
Para ajudar a manter um melhor controle sobre a execução da simulação, o aplicativo não usa o pool de threads quando gera threads de trabalho. Cada thread é criado e gerenciado manualmente. Se eu tivesse usado o pool de threads, novas solicitações poderiam ter sido enfileiradas caso a atividade da CPU estivesse alta ou caso não houvesse threads livres no pool de threads. Como esta é uma ferramenta de teste de resistência, gostaria de manter controle direto sobre a quantidade de atividade gerada com base nas opções de configuração do teste.
A criação de uma thread de trabalho envolve a criação de uma classe em um assembly que implementa a interface LoadGenerator.Interfaces.IWorker, como mostrado aqui:
public interface IWorker: IDisposable
{
void Initialize(long instanceID);
void Process();
}
Como você pode concluir pela definição, sua classe precisa implementar a interface IDisposable. O método Dispose será chamado no final da simulação para dar à sua classe a oportunidade de executar tarefas de limpeza. O método Initialize é chamado somente uma vez, antes da execução dos testes. O parâmetro instanceID representa o número de instância exclusivo do thread de trabalho relativo a todas as threads de trabalho que estão sendo criados. É no método Process que deve residir o código que executa o trabalho para a simulação. Esse método será chamado repetidamente, conforme definido pelas configurações de Test Setup do formulário principal.
Para este exemplo, criarei um componente de trabalho que sobrecarregue (estresse) um procedimento armazenado no banco de dados Northwind. Assumo que o SQL Server™ e o Northwind devem estar instalados na mesma máquina que o aplicativo LoadGenerator. Em um teste real, porém, o SQL Server e o Northwind residiriam em uma máquina à parte. Em um sistema de CPU única, a execução do LoadGenerator e do SQL Server na mesma máquina pode sobrecarregar o computador muito rapidamente e pôr a perder o objetivo do teste. Além disso, é perfeitamente viável executar um cliente de teste em várias máquinas.
Comece criando um novo assembly de biblioteca em C# chamado DBWorker no Visual Studio® .NET. Inclua uma referência no assembly LoadGenerator.Interfaces e altere a declaração de classe padrão para a seguinte linha de código:
public class MyWorker : LoadGenerator.Interfaces.IWorker
No final da linha, o Visual Studio deverá solicitar que você pressione a tecla Tab, o que ocasionará a criação automática de stubs para todos os métodos necessários da interface IWorker.
A Listagem 1 mostra as implementações dos métodos Initialize, Process e Dispose. No nível de classe no método Initialize, é criado um objeto Command que é reutilizado nas chamadas ao método Process. A redução do overhead desnecessário em cada execução do método Process ajuda a aumentar a eficiência e a eficácia do teste. O método Initialize configura o objeto Command para chamar o procedimento [employee sales by country] no banco de dados Northwind com duas datas como argumentos. O método Process abre uma conexão de banco de dados em pool, executa o comando e libera a conexão de volta para o pool. Um teste mais realista provavelmente faria o método Process alterar os argumentos no procedimento nas execuções subseqüentes.
Listagem 1 A classe DBWorker implementando a interface IWorker
public void Initialize(long lInstance)
{
//Initialize a command object
_cmd = new SqlCommand();
_cmd.CommandText="[employee sales by country]";
_cmd.CommandType = CommandType.StoredProcedure;
_cmd.Connection = new SqlConnection(
"Integrated Security=SSPI;
Persist Security Info=False;Initial
Catalog=Northwind;Data Source=(local)");
_cmd.Parameters.Add("@Beginning_Date", "1/1/1997");
_cmd.Parameters.Add("@Ending_Date", "1/1/1998");
}
public void Process()
{
try
{
_cmd.Connection.Open();
_cmd.ExecuteNonQuery();
}
finally
{
_cmd.Connection.Close();
}
}
public void Dispose()
{
if (_cmd != null)
{
_cmd.Connection.Close();
_cmd.Dispose();
_cmd = null;
}
}
O procedimento armazenado é executado sem levar em consideração o resultset que o procedimento retorna. Caso deseje testar o impacto de desempenho da carga do resultset em um DataTable, compare este teste com o que preencheu um DataTable. Só fique atento à quantidade de trabalho de cliente que está sendo executado para cada teste, pois isso pode consumir tempo de CPU e limitar a capacidade do cliente de executar testes adicionais.
Agora você pode compilar o assembly e copiá-lo no diretório do aplicativo. O diretório do aplicativo contém um arquivo de configuração do aplicativo que diz ao aplicativo qual assembly e classe carregar ao realizar um teste. Abra o arquivo LoadGenerator.exe.config e inclua o nome do seu assembly e da sua classe:
Após atualizar o arquivo de configuração, você está pronto para executar a simulação. Inicie o aplicativo LoadGenerator e altere o número de threads de trabalho para um, a fim de poder verificar se tudo está funcionando; em seguida, clique no botão Iniciar. Se ocorrerem erros ao ativar seu componente ou executar os testes, eles serão exibidos no campo Status do formulário de status. Se tudo tiver funcionado, você está pronto para executar mais instâncias e alterar os períodos de think time e de warm-up para variar a carga sobre o sistema. Isso é tudo o que é necessário para fazer essa ferramenta funcionar. Para obter o máximo desse exemplo, o banco de dados Northwind deve residir em outro computador. Componentes de trabalho mais sofisticados podem executar diversas tarefas para testar a resistência dos seus aplicativos.
Por dentro do aplicativo LoadGenerator
O aplicativo divide grande parte do trabalho de simulação em duas classes. A classe WorkerState inclui o código que cria uma thread individual e fornece acesso para iniciar e terminar sua execução, assim como para relatar informações de estado. Essa classe também carrega a classe personalizada e chama os métodos da interface IWorker. Para cada instância de uma thread de trabalho definida na configuração do teste, é criada uma instância da classe WorkerState. A classe WorkerCollection é o container de todos os objetos WorkerState criados para determinada simulação. Essa classe controla o início e o fim da simulação e expõe um evento chamado TestComplete, que notifica os formulários sobre a conclusão de uma simulação.
Para realizar uma simulação, o aplicativo primeiro inicializa o objeto WorkerCollection e, em seguida, chama o WorkerCollection.Start. Grande parte da inicialização é feita assincronicamente a partir da thread do aplicativo principal, do qual é acessado o WorkerCollection. Por causa disso, o método Start é bloqueado até que a inicialização se complete, a fim de garantir que nenhum teste será realizado enquanto houver threads ainda sendo inicializadas. A inicialização do objeto WorkerCollection inclui a criação e a inicialização de objetos WorkerState para cada thread de trabalho definida pela simulação. A inicialização de cada objeto WorkerState cria uma nova thread e carrega o assembly personalizado. Depois que todos os threads tiverem carregado o assembly personalizado, o método WorkerCollection.Start interromperá o bloqueio e cada thread terá permissão para prosseguir com o ciclo de aquecimento e começará a fazer chamadas no assembly personalizado para realização dos testes. As seções a seguir tratam da inicialização, da execução e da conclusão de tarefas.
Inicializando uma simulação
Quando uma simulação é iniciada com o clique do botão Iniciar no formulário principal, o formulário dispara o evento TestComplete do objeto WorkerCollection e chama os métodos WorkerCollection.Initialize e WorkerCollection.Start. Como você verá um pouco mais adiante, o método Start pode levar 30 segundos para retornar. Para manter o aplicativo mais responsivo ao usuário, o método Start é chamado assincronicamente:
_workerCollection = new WorkerCollection();
_workerCollection.TestComplete +=new
TestCompleteEventHandler(_workerCollection_TestComplete);
_workerCollection.Initialize(Convert.ToInt32(txtInstances.Text),
Convert.ToInt32(txtWarmup.Text),
Convert.ToInt32(txtInterval.Text),
Convert.ToInt64(txtMaxCmds.Text));
StartDelegate _startSimulation = new
StartDelegate (_workerCollection.Start);
StartDelegate.BeginInvoke(null,null);
O método WorkerCollection.Initialize toma como argumentos todos os parâmetros de configuração de teste especificados no formulário principal:
void Initialize(int totalWorkers, int warmUpTime,
int thinkTime, long maxCommands)
Esse método examinará então o arquivo de configuração do aplicativo a fim de recuperar a classe que estará fazendo todos os verdadeiros testes de resistência e o passará a cada objeto WorkerState. Os objetos WorkerState são criados para controlar a execução de cada thread e para recuperar as informações de estado (veja a Listagem 2).
Listagem 2 Obtendo informações de estado
WorkerState workerState = new WorkerState();
workerState.InstanceID = index;
workerState.IWorkerAssemblyName = assemblyName;
workerState.IWorkerTypeName=typeName;
workerState.ThinkTime = thinkTime;
workerState.MaxCommands = maxCommands;
workerState.WarmUpDelay = (int)(((double)_warmUpTime/_totalCount) *
(double)index * 1000);
workerState.Initialize(_syncStart,
new WorkerCallBack(WorkerInitialized),
new WorkerCallBack(WorkerStarted),
new WorkerCallBack(WorkerComplete));
_workerCollection[index] = workerState;
Após definir os parâmetros de execução do objeto WorkerState, o método WorkerState.Initialize é chamado. A thread criada por esse método aguardará ser sinalizada pelo aplicativo, antes de realizar os testes de resistência. O argumento _syncStart é um ManualResetEvent que será sinalizado depois que todas as instâncias do objeto WorkerState tiverem concluído suas tarefas de inicialização. Os argumentos restantes para o método Initialize são delegates de callback do objeto WorkerState para o objeto WorkerCollection. A chamada desses delegates indicará ao WorkerCollection quando cada thread de trabalho completou sua inicialização, iniciou e concluiu a realização de testes. Como você verá, o objeto WorkerCollection usa esses callbacks para controlar o andamento da simulação.
O método WorkerState.Initialize cria e inicia o thread de trabalho, especificando qual método a thread deve executar:
ThreadStart ts = new ThreadStart(ProcessCmd);
_thread = new Thread(ts);
SetActionState(ActionEnum.Initialize);
_syncStart = startWaitHandle;
_thread.Start();
As ações das threads de trabalho são controladas pela variável _actionState, cujo tipo é uma enumeração chamada ActionEnum. Essa enumeração ajuda a identificar a tarefa que a thread está executando e qual tarefa a thread deve executar. Se a simulação for interrompida por um clique no botão Stop do formulário principal, o objeto WorkerCollection definirá a variável como ActionEnum.Stop. Como a thread prossegue apesar de suas tarefas, ela verifica regularmente o valor dessa variável. Se o valor enumerado for Stop, a thread sairá normalmente. Como esse valor é atualizado pela thread de trabalho e pela thread do aplicativo principal, todas as atualizações dessa variável estarão sincronizadas e serão executadas por um método chamado SetActionState.
Uma vez que a Thread.Start é chamado, o método WorkerState.ProcessCmd começa a ser executado. Esse método cria uma instância da classe personalizada especificada no arquivo de configuração. O método ProcessCmd indica, então, a conclusão das tarefas de inicialização, emitindo um callback para o WorkerCollection e aguarda um sinal do _syncStart ManualResetEvent:
Assembly workerAssembly = Assembly.Load(_IWorkerAssemblyName);
objWork = (IWorker)workerAssembly.CreateInstance(_IWorkerTypeName, true);
_initCallBack(_instanceID);
_syncStart.WaitOne();
O método chamado no objeto WorkerCollection pelo delegate _initCallBack sinalizará o indicador de espera _syncInitialized quando todas as threads de trabalho tiverem completado seu trabalho de inicialização:
lock(this)
{
if (++_initCount == _workerCollection.Length)
_syncInitialized.Set();
}
Dessa forma é concluída a fase de inicialização da simulação. Nesse ponto, cada thread criou uma instância do componente de trabalho e agora está servindo o _syncStart.WaitOne. A sinalização desse indicador de espera, que ocorre no método WorkerCollection.Start, iniciará a simulação.
Executando a simulação
O método WorkerCollection.Start é chamado do formulário principal depois que o método WorkerCollection.Initialize retorna. Como muitas das etapas de inicialização são executadas por threads de trabalho individuais assíncronas para o método Initialize, o método Start aguarda todas as threads terminarem suas tarefas de inicialização. Esse método aguarda 30 segundos na sub-rotina de espera _syncInitialized. Como já visto, essa sub-rotina de espera é sinalizada depois que o último thread chama o delegate _initCallBack. Se o WorkerCollection pára (time out) enquanto aguarda todas as threads de trabalho serem inicializadas, a simulação é interrompida e todas as threads saem, como você pode ver no código da Listagem 3.
Listagem 3 Aguardando
public bool Start()
{
try
{
if (_syncInitialized.WaitOne(30000,false) == false)
throw new Exception("Timeout waiting for initialization.");
_syncStart.Set();
return true;
}
catch (Exception err)
{
Stop();
return false;
}
}
Se o _syncInitialized é sinalizado antes do timeout, o método Start, por sua vez, sinaliza o ManualResetEvent _syncStart. É por isso que o indicador de espera de cada uma das threads de trabalho está aguardando. Depois de sinalizada, a thread de trabalho prossegue além do estado Initialized e inicia o ciclo de aquecimento:
_syncStart.WaitOne();
if (_actionState == ActionEnum.Stop) return;
DelayForWarmUp();
objWork.Initialize(_instanceID);
_startCallBack(_instanceID);
SetActionState(ActionEnum.Process);
Após ser sinalizada para iniciar o retardo (delay) de aquecimento, a thread de trabalho verifica se a simulação não foi interrompida e é suspenso (sleep) o tempo necessário para acomodar o período de aquecimento. Cada thread possui um diferente período (delay) de aquecimento (warm-up) a ser utilizado em estado de suspensão (sleeping). Esses retardos (delays) são atribuídos para garantir que as threads comecem a fazer testes de resistência (stress tests) a intervalos regulares com todas as threads ativas no final do período de aquecimento. Conforme mencionado, não são capturadas medidas de desempenho durante esse tempo de aquecimento. Se o período de aquecimento for de 10 segundos e houver 10 threads, 1 thread iniciará um teste a cada segundo. O primeiro thread terá um _warmUpDelay de 0, e a décima thread terá um _warmUpDelay de 9 segundos. As threads não permanecem suspensas (asleep) por todo o período de retardo do aquecimento (warm-up delay). O método DelayForWarmUp é executado em um loop, sendo suspenso (sleeping) por intervalos curtos de não mais de 500 milissegundos até que o tempo de aquecimento tenha transcorrido:
private void DelayForWarmUp()
{
DateTime startTime = DateTime.Now;
TimeSpan totalDelay = TimeSpan.Zero;
int remainingTime = _warmUpDelay;
while(remainingTime > 0 && _actionState != ActionEnum.Stop){
Thread.Sleep(remainingTime > 500 ? 500 : remainingTime);
totalDelay = DateTime.Now.Subtract(startTime);
remainingTime = (int)(_warmUpDelay -
totalDelay.TotalMilliseconds);
}
}
A suspensão (sleeping) dessa maneira permite que a thread fique responsiva a uma solicitação de interrupção do usuário, sem precisar recorrer ao uso do método Thread.Interrupt. O método Thread.Interrupt injeta uma exceção na thread de suspensão e a faz retornar à atividade. Essa abordagem é um tanto custosa quando a simulação tem algumas centenas de threads. Se a variável _actionState for atualizada para Stop, DelayForWarmUp sairá do loop e retornará ao ProcessCmd, que permitirá que a thread saia normalmente.
Terminado o retardo do aquecimento (warm-up delay), o método IWorker.Initialize é chamado para permitir que a classe do trabalho se prepare para os testes. O delegate _startCallBack é então chamado, informando ao WorkerCollection que a thread está iniciando os testes. A variável _actionState é atualizada de acordo.
No objeto WorkerCollection, o método de callback chamado pelo delegate _startCallBack só precisa rastrear o número de threads que se moveram além de seu respectivo retardo de aquecimento e que começaram a executar testes de resistência. A classe Interlocked no namespace System.Threading oferece um mecanismo muito simples e eficiente para a sincronização do incremento de variável. O método chama Interlocked.Increment(ref _activeCount) para incrementar o número de threads ativas. Esse valor será consultado pelos formulários a fim de mostrar o andamento. Com a classe personalizada inicializada, a thread de trabalho prossegue com a execução de testes de resistência:
while(_iterations < _maxCmds && _actionState == ActionEnum.Process){
_iterations++;
objWork.Process();
if(_thinkTime > 0) Thread.Sleep(_thinkTime);
}
Enquanto o estado de ação atual é Process e há mais iterações a serem feitas, o método IWorker.Process é chamado, e a thread é suspensa (sleeps) durante o tempo de reflexão (think time) necessário entre cada chamada. Quando sai do loop, o código no bloco finally é executado para limpar as instâncias, como mostrado aqui:
finally
{
_completeCallBack(_instanceID);
if (objWork != null) objWork.Dispose();
objWork = null;
}
Dessa forma é chamado um delegate que por sua vez retorna a chamada para o objeto WorkerCollection a fim de notificar a conclusão dos testes. IWorker.Dispose é então chamado para permitir que o componente personalizado execute quaisquer tarefas de limpeza antes de ser liberado.
Completando a simulação
Quando a última thread tiver chamado o delegate _completeCallBack, o evento TestComplete será chamado no objeto WorkerCollection (veja a Listagem 4). O evento TestComplete é chamado em seja qual for o último thread a ser concluído. Os formulários são disparados a esse evento para permitir que eles exibam as informações finais e sejam redefinidos (reset) para a próxima simulação. Para o formulário principal e o formulário de status, o código de conclusão precisa ser chamado na thread que contém o formulário:
private void _workerCollection_TestComplete(object sender,
TestCompleteEventArgs e)
{
testCompleteEventHandler d = new
testCompleteEventHandler(this.TestComplete);
this.Invoke(d,new object[] {e});
}
As partes do progarma de formulário que tratam do evento WorkerCollection.TestComplete criam um novo delegate que aponta para um método privado na classe de formulário TestComplete, que é então chamado na mesma thread que controla o formulário. O formulário principal se redefine (reset) para um novo teste, e o formulário de status é exibido com os tempos de execução individuais e o número de iterações para cada thread.
Listagem 4 O teste está completo?
bool bComplete=false;
WorkerState workerState = (WorkerState) sender;
lock(this)
{
if (workerState.ThreadError) _errorCount++;
if (_activeCount > 0) _activeCount—;
if (++_completeCount == _totalCount) bComplete = true;
}
if (bComplete)
{
TestCompleteEventArgs args = new TestCompleteEventArgs("",
_duration.TotalSeconds);
TestComplete(this, args);
}
Conclusão
Este artigo não estaria completo se não mencionássemos a importância da segurança durante o carregamento de assemblies late-bound. Apesar de provavelmente você não encontrar assemblies de terceiros que implementem minha interface IWorker, é importante entender como carregar assemblies late-bound de maneira segura. O aplicativo LoadGenerator simplifica as tarefas de realização de testes de resistência básicos, fornecendo os principais artifícios necessários para a simulação da alta concorrência. Os componentes criados por você que implementam a interface IWorker não precisam se preocupar com esse artifício, permitindo que você se concentre em entender o comportamento do seu aplicativo sob altas cargas. Seus componentes podem testar qualquer número de situações, incluindo a eficiência de um novo algoritmo, o ritmo de transferência de um Web Service ou a concorrência de uma procedure de banco de dados. O que você fará o componente fazer é de sua escolha.