Desenvolvimento - C/C++

Programação multithread no Windows 32bits: como fazer 2 tarefas ao mesmo tempo

Uma das grandes novidades apresentadas com a arquitetura da familia Windows 32bits foi a possibilidade de processamento multithread, o que tornava possível a execução de várias tarefas por um mesmo programa. Entretanto, esta característica avançada é normalmente considerada uma arte “negra” por diversos programadores, sendo um recurso a ser explorado somente pelos programadores “gurus”. Neste artigo iremos explicar vários conceitos importantes para se utilizar esta técnica de forma apropriada e mostrar como pode ser simples executar uma função em paralelo ao programa principal.

por Adenilson Cavalcanti da Silva



Introdução

Uma das grandes novidades apresentadas com a arquitetura da familia Windows 32bits foi a possibilidade de processamento multithread, o que tornava possível a execução de várias tarefas por um mesmo programa. Entretanto, esta característica avançada é normalmente considerada uma arte "negra" por diversos programadores, sendo um recurso a ser explorado somente pelos programadores "gurus". Neste artigo iremos explicar vários conceitos importantes para se utilizar esta técnica de forma apropriada e mostrar como pode ser simples executar uma função em paralelo ao programa principal.

De fato, sincronização de múltiplos threads exige uma série de cuidados por parte do engenheiro responsável pela arquitetura do sistema e apresenta a necessidade de técnicas avançadas para analisar o problema de forma a evitar problemas como os famosos "deadlocks". Porém, é um recurso extremamente útil para diversos domínios de problemas, de acesso a banco de dados a processamento matemático pesado e pode contribuir muito para tornar seu programa mais responsivo ao usuário.

O artigo apresenta os conceitos em torno de multithreading e depois oferece uma aplicação simples onde se demonstra a técnica. O código aqui apresentado utiliza linguagem C para chamar funções da API do Windows, de forma a funcionar com qualquer compilador C sem depender de bibliotecas como VCL (Visual Components Library) ou MFC (Microsoft Foundation Class).

Conceitos

Antes de prosseguir ao código, deve-se apresentar algumas informações para compreensão do mecanismo de funcionamento de multithreading.

a) Multitarefa: característica de um Sist. operacional de fazer mais de uma tarefa ao "mesmo" tempo. Em computadores com apenas 1 processador, isto pode ser visualizado da seguinte forma: como uma roleta dividida em várias posições, cada uma representando um dos múltiplos programas ativos (cujos processos estejam em funcionamento). O tempo de processamento da CPU é dividido de forma a passar o foco durante alguns milissegundos para cada processo nesta "roleta" e avançando a seguir para a próxima divisão ou processo (ver figura 1), dando a impressão ao usuário que o computador está executando várias tarefas ao mesmo tempo (Reisdorph, 1999). Para computadores com 2 CPUs (ou mais) um mesmo processo pode rodar em mais de um processador ao mesmo tempo. Nesta situação, é necessário o suporte a processamento simétrico por parte do sistema operacional, característica presente no Windows a partir da versão 2000 e NT (Torres, 2000) e no Unix desde épocas remotas (Barclay, 1989).

Esquema com representação dos múltiplos processos em um sistema multitarefa

Figura 1: Esquema com representação dos múltiplos processos em um sistema multitarefa

Cada segmento do círculo pode ser considerado um processo, sendo transferido o foco do processador em intervalos de milisegundos para cada processo ativo no sistema. A prioridade de um processo pode ser visualizada como o perímetro do segmento, possibilitando processos com maior prioridade (na figura com maior arco) assumir o foco do processador por maiores períodos.

b) Processo: ao selecionar e executar um arquivo de programa, o programa é lançado na memória do computador e cria-se um processo para que possa ser feito algum processamento. Um processo não realiza processamento, ele é apenas um programa carregado na memória do computador. Todo processo possui uma variável HINSTANCE que representa a base do endereço de memória onde este foi carregado, sendo 10x00400000 ou 4.194.304. Portanto, o programa é carregado usualmente em torno da marca dos 4MB da memória principal, podendo seguir até a marca de 2GB. Assim, um programa pode ocupar 4GB - 4MB de memória stack, porém apesar do sistema operacional Windows teoricamente suportar esta faixa de valores, acredito que a sua configuração de hardware não suporte.J O valor específico onde seu programa será carregado pode ser setado nas configurações do compilador utilizado.

c) Thread: é quem efetivamente faz o processamento, sendo importante diferenciar o conceito de processo em relação aos "threads": um processo é criado quando um programa é inicializado, mas todo o processamento das tarefas do programa é feito no "thread". Toda aplicação têm pelo menos um "thread" criado juntamente com o processo desta. Graças a este conceito, é possível um mesmo programa possuir vários "threads" para realizar mais de uma tarefa ao mesmo tempo, sendo este conceito suportado desde épocas remotas pelo Unix (Barclay, 1989) e mais recentemente pelo Windows 32 bits (Demjén, 1998). Com estas definições, é possível prosseguir para algumas considerações extras antes do código em si......

Programação multithreading é como zen budismo: quanto mais você estuda, menos você sabe. Quando 2 (ou mais) threads precisam acessar e modificar um mesmo conjunto de dados (arquivos, estruturas de dados, etc) é preciso utilizar técnicas para sincronizar a ação destes. Abaixo segue um exemplo prático onde pode ocorrer esta situação:

Exemplo: imagine 2 usuários (A e B) acessando uma mesma tabela de banco de dados, e o usuário A paga um registro. O que acontece se neste exato momento o usuário B estava lendo este registro? E se ao invés de 2 usuário fosse um mesmo programa com 2 threads lendo o banco de dados? Nesta situação pode ocorrer uma das alternativas a) o programa simplesmente quebra b) o banco de dados fica estável c) o sistema todo entra em pane.

Para resolver este problema existem as seções criticas, faróis e Mutex. Estes funcionam como um mecanismo para regular a ação dos threads, permitindo somente a ação de 1 thread em um momento t. As capacidades de cada técnica e suas limitações deverão ser consideradas dependendo da necessidade de sincronização do problema estudado. As seções críticas são mais eficientes em velocidade, porém somente permitem a sincronização de threads pertencentes a um mesmo processo (ou programa). Os mutex e faróis podem sincronizar threads de processos distintos.No nosso exemplo, quando o usuário A está apagando o registro, o mecanismo de sincronização impedirá o usuário B de acessar a tabela (ou mesmo somente o registro). Após o primeiro usuário (ou thread) concluir a tarefa de exclusão, o usuário B poderá ler o conteúdo da tabela modificada.

De fato, não existem limites teóricos para criação de threads por um único programa. Entretanto, se a CPU for lenta ou a quantidade de threads exagerada, pode ocorrer um fenômeno conhecido como "thread starvation": um thread recebe o foco do processador e antes de começar a executar uma ação será obrigado a passar o recurso para o próximo thread. Este ciclo se repete sucessivamente, tendo como resultado prático nada ser efetivamente processado.

Quando um thread depende do resultado de outro, pode ocorrer um "deadlock", ou seja o primeiro thread ficar esperando ad eternum o segundo acabar o processamento (enquanto este último pode estar esperando acessar um recurso/arquivo que foi travado pelo primeiro). Nota-se aí como o uso de threads exige MUITO cuidado e planejamento (ref. MICRO) e a administração de recursos é uma área ampla de estudo (Milewski, 2000).

Com o uso de uma técnica de análise matricial (Redes de Petri), é possível identificar em quais condições pode ocorrer um deadlock em um programa multithreading. Entretanto, estes tópicos extrapolam o escopo deste artigo.

Implementação

É necessário primeiramente descrever o uso de cada item da função CreateThread:

Listagem 1: Função CreateThread

HANDLE CreateThread(
LPSECURITY_ATTRIBUTES lpThreadAttributes, //1)
DWORD dwStackSize, //2)
LPTHREAD_START_ROUTINE lpStartAddress, //3)
LPVOID lpParameter, //4)
DWORD dwCreationFlags, //5)
LPDWORD lpThreadId //6)
);
  1. Onde se define alguns parâmetros de segurança. A escolha desses é opcional;
  2. Define-se o tamanho de memória disponível para o thread. O default é o mesmo que o utilizado no thread principal do aplicativo;
  3. Onde definimos o nome da função que rodará dentro do thread. Esta deve obrigatoriamente ter a seguinte forma: DWORD CALLBACK nome_da_funcao(void* parametro_qualquer);
  4. Local onde pode ser passado um parametro para a função que vai rodar dentro do thread;
  5. Esta variável permite passar algumas opções para o sistema operacional;
  6. Aqui a função CreateThread recebe um ponteiro para uma variável onde o sistema operacional irá setar um numero de identificação do thread. Isto é obrigatório para o Windows NT.

Caso seja desejado usar os valores padrões para a chamada da função, basta passar o valor 0 (zero) em cada parâmetro. OK, agora vamos ver um exemplo: queremos uma aplicação que crie um thread e realize outro processamento enquanto o thread faz a tarefa.

A função do thread deverá fazer os procedimentos:

a) Tentar abrir um arquivo no path recebido como parâmetro de chamada; b) Ler este arquivo e fazer o somatório do primeiro valor presente em cada linha; c) Exibir o resultado.

Caso não seja possível abrir o arquivo, então deve exibir uma mensagem. A função principal após criar o thread irá chamar uma função para realizar um processamento (aqui, Proc_inutil).

O código fonte segue abaixo:

Listagem 2: Código fonte do programa

/***********************************************************************
Propósito: Programa windows que exemplifica o uso de threads

Funcionamento: O programa exibe mensagens sobre cada atividade realizada. Ele
  irá procurar por um arquivo texto chamado "teste.txt" localizado no drive C:
  e tentará le-lo, fazendo o somatório do primeiro número presente em cada linha
  do arquivo. Isto é realizado em paralelo, e enquanto se espera pela leitura do
  arquivo, o programa no thread principal chama uma função apenas para usar os
  recursos do processador.

Autor: Adenilson Cavalcanti da Silva
a.k.a. Savago (acsilva@esalq.usp.br)

Data: 2 de maio de 2002

Obs: testado no Borland C++ Builder 4, Visual C++ 6.0 e Dev-C++ 4.0 
(deve funcionar em qualquer compilador 32bits, no Windows).

**********************************************************************/
//---------------------------------------------------------------------
#include 
#include 
#include 
#include 
#include 

//A função que roda dentro do thread
//A função que roda dentro do thread
/*Ela recebe um path para um arquivo, abre o mesmo
e soma o seu conteúdo. O arquivo deve ter pelo menos
1 número em cada linha, como no exemplo:

20.3
54.6
40.7
73.2

a soma será 188.8
*/
DWORD CALLBACK Calcula(void* Parametro)
{
   char resposta[300];
   float linha = 0;
   float somatorio = 0;
   char* path = (char*)Parametro;
   ifstream fin(path);
   if(!fin)
   {
      MessageBox(NULL, "Impossível abrir o arquivo", "Thread", MB_OK);
      return 1;
   }

   while(!fin.eof())
   {
      fin>> linha;
      somatorio += linha;
   }
   sprintf(resposta, "A soma é: %f", somatorio);
   MessageBox(NULL, resposta, "Thread", MB_OK);
   return 0;
}
//Função chamada a partir do thread principal (o da
//aplicação) para consumir o tempo enquanto aguardamos
//a função do thread fazer o somatório
void Proc_inutil(void)
{
   for(unsigned long i = 0; i 

Apenas como experiência, apague o comentário na linha #define DEBUG e veja como a função irá mostrar a mensagem de falha ao abrir o arquivo (nota: estamos supondo realmente existir um arquivo chamado "C:\teste.txt"). Também, após ser exibido o resultado do somatório, espere alguns instantes para que apareça a outra mensagem onde o thread principal da aplicação exibe "Já esperamos bastante...", o que prova que o aplicativo está fazendo 2 coisas ao mesmo tempo.

Se houvesse mais uma função onde fosse desejado realizar o processamento em paralelo (vamos dizer Calcula2), para criar um thread bastaria invocar novamente a função CREATETHREAD com o nome da função extra. Novamente, em teoria não há limites para a quantidade de threads que um único programa pode manipular.

Caso seja necessário uma prova maior que o programa realmente foi capaz de realizar processamento em mais de um thread, sugiro ver a figura 2. Foi extraída uma imagem do programa Pview distribuído em conjunto com o Visual C++, onde se comprova que cada caixa de mensagem exibida pelo programa visto neste artigo está executando de dentro de um thread.

Programa PView

Figura 2: Programa PView

O programa Pview é um utilitário que permite examinar o número de threads de cada processo ativo no sistema. Em destaque à esquerda, o nome do programa. Na quarta coluna do Pview, o número de threads do programa.

Conclusões

Ao longo do artigo foram apresentados os conceitos necessário para compreender processamento multhread em programas e discutidos problemas relacionados ao uso da técnica. Foi apresentado um programa simples para demonstrar como utilizar a função CREATETHREAD da API Win32 para tornar possível a execução de várias tarefas ao mesmo tempo por um único programa.

A utilização de multithreading é uma técnica muito poderosa e pode incrementar de forma significativa o desempenho de aplicativos, porém exige cuidados no planejamento da estrutura do programa para evitar erros. Dúvidas? Mande e-mail!

Atenciosamente

Adenilson Cavalcanti

Para baixar o código deste artigo, clique aqui.
Adenilson Cavalcanti da Silva

Adenilson Cavalcanti da Silva - Adenilson (a.k.a. Savago) desenvolve sistemas há 10 anos, utilizando diversas linguagens de programação e sistemas operacionais. Tendo se especializado em C++, está sempre a procura de novos desafios com características multidisciplinares. Mestre pela USP, possui interesse especial por visão artificial, *nix, programação baixo nível.