artigo threads o problema dos leitores e escritores implementado em c# rafael oliveira vasconcelos
DESCRIPTION
This article aims to present the use of threads through the implementation of a problem using them. The problem used is the readers and writers, which shapes access to a database being requested for operations of reading and writing in order to follow certain criteria aimed at ensuring the integrity of the data base.It also approached some basic concepts of threads to a better understanding of the implementation of the solution proposed. In addition to the basic concepts, is also approached in this article, examples of stretch of code programmed in the programming language C#, using the available resources for the manipulation of threads as: create and start threads, synchronization, priority, label, wake and sleep, block, interrupt and resume or start over.Este artigo tem como objetivo apresentar a utilização de threads através da implementação de um problema que as utilizam. O problema utilizado é caso dos leitores e escritores, que modela o acesso a uma base de dados sendo requisitada para operações de leitura e escrita de forma a seguir alguns critérios visando a garantia da integridade dos dados da base.Abordam-se também alguns conceitos básicos de threads para um melhor entendimento da implementação e da solução do problema proposto. Além dos conceitos básicos, também são abordados neste artigo, exemplos de trechos de códigos programados na linguagem de programação C#, utilizando-se dos recursos disponíveis para a manipulação de threads como: criar e iniciar threads, sincronismo, prioridade, nomear, acordar e dormir, bloquear, interromper e resumir ou recomeçar. De forma prática e em conjunto com os recursos da linguagem já citada, mostra-se a implementação da resolução do problema dos leitores e escritores visando o estudo de threads não só na teoria.TRANSCRIPT
THREADS: O PROBLEMA DOS LEITORES E
ESCRITORES IMPLEMENTADO EM C#
Daniel Ramon Silva Pinheiro, Danilo Santos Souza, Maria de Fátima A. S.
Colaço, Rafael Oliveira Vasconcelos
RESUMO: Este artigo tem como objetivo apresentar a utilização de threads através da implementação de um problema que as utilizam. O problema utilizado é caso dos leitores e escritores, que modela o acesso a uma base de dados sendo requisitada para operações de leitura e escrita de forma a seguir alguns critérios visando a garantia da integridade dos dados da base. Aborda-se também alguns conceitos básicos de threads para um melhor entendimento da implementação e da solução do problema proposto. Além dos conceitos básicos, também é abordado neste artigo, exemplos de trechos de códigos programados na linguagem de programação C#, utilizando-se dos recursos disponíveis para a manipulação de threads como: criar e iniciar threads, sincronismo, prioridade, nomear, acordar e dormir, bloquear, interromper e resumir ou recomeçar. De forma prática e em conjunto com os recursos da linguagem já citada, mostra-se a implementação da resolução do problema dos leitores e escritores visando o estudo de threads não só na teoria.
PALAVRAS-CHAVE: Processo, Região Crítica, Sistema Operacional, Thread.
ABSTRACT: This article aims to present the use of threads through the implementation of a problem using them. The problem used is the readers and writers, which shapes access to a database being requested for operations of reading and writing in order to follow certain criteria aimed at ensuring the integrity of the data base. It also approached some basic concepts of threads to a better understanding of the implementation of the solution proposed. In addition to the basic concepts, is also approached in this article, examples of stretch of code programmed in the programming language C#, using the resources available for the manipulation of threads as: create and start threads, synchronization, priority, label, wake and sleep, block, interrupt and resume or start over. From a practical way and together with the resources of the language already quoted, it is shown in the implementation of the resolution of the problem of readers and writers seeking the study of threads not only in theory.
KEYWORDS. Critical Area, Operating System, Process, Thread
1. INTRODUÇÃO
Devido à evolução da tecnologia, principalmente no mundo
computacional, ocorreu a necessidade de novas formas de executar processos
nos sistemas operacionais, com o intuito de ganho em processamento.
A partir dessas evoluções nos processos nasceu um conceito
caracterizado como thread. Mas a principio o que seria processo? O que seria
thread? O que thread tem haver com processamento?
De forma básica um processo é um programa em execução e uma
thread é a execução de parte de um processo. Pelo fato de thread ser parte de
um processo, possui o mesmo espaço de endereçamento compartilhando uma
mesma região de memória podendo assim um processo ter uma ou várias
threads. É ai que entra o poder da thread com base no ganho de
processamento, principalmente em ambientes multiprocessados. Os processos
podem ser independentes, onde cada processo executa sem a necessidade de
compartilhamento de variável, e concorrente que ao contrário dos
independentes os processos compartilham uma ou mais variáveis, onde essa
região compartilhada se caracteriza como região critica. Vale ressaltar também
que vários processos podem tentar acessar a mesma região critica e o
resultado depender da ordem em que eles são executados, definindo a idéia de
condição de corrida. Exemplos e maiores detalhes das situações citadas
anteriormente serão abordados ao decorrer do trabalho.
O uso do compartilhamento de variável faz com que aconteçam alguns
problemas no uso das threads pelo fato das mesmas herdarem algumas
propriedades dos processos. Existem diversos problemas existentes no uso
das threads. Demonstraremos um problema especifico que é o caso do
problema dos leitores e escritores.
O problema dos leitores e escritores de forma simples é um problema
onde se tem uma região critica onde threads podem ler (somente querer saber
o que consta na região critica) ou escrever (querer alterar valor na região
critica), levando em considerações alguns critérios. O mesmo será descrito de
forma mais detalhada no trabalho com o uso de exemplos e apresentando a
solução do mesmo.
Estamos falando de thread, computador, processamento, processos,
mas como implementar threads no mundo computacional? Qual linguagem
utilizar?
Atualmente existem diversas linguagens com diversos recursos para
criarmos a idéia de thread no mundo computacional. Com ênfase na linguagem
C# alguns recursos serão demonstrados junto com exemplos de código e
formas de como usar esse recursos.
Demonstrado uma idéia prática de thread em conjunto com os recursos
da linguagem C# o caso do problema dos leitores e escritores foi implementado
na linguagem já citada, existindo assim um tópico exclusivo apresentando o
código da implementação e para facilitar o entendimento o uso de comentários
no código.
Enfim tudo isso citado junto com mais alguns complementos serão
apresentados neste artigo, com o objetivo de demonstrar o uso das threads
desde a parte teórica até a parte prática, finalizando o artigo com uma
conclusão retratando a opinião dos autores com relação ao tema abordado.
2. THREADS
Literalmente thread significa, em português, linha de execução.
Conceitualmente falando, thread é uma forma de um processo dividir-se em
duas ou mais tarefas que podem ser executadas simultaneamente. Podem
porque nos hardwares equipados com múltiplos núcleos, as linhas de execução
de uma thread, podem ser executadas paralelamente, uma em cada núcleo, já
nos hardwares com um único núcleo, cada linha de execução é processada de
forma aparentemente simultânea, pois a mudança entre uma linha e outra é
feita de forma tão rápida que para o usuário isso está acontecendo
paralelamente.
Para o progresso deste artigo que trata de threads é de fundamental
importância uma breve diferenciação das threads e dos processos, uma vez
que os dois são distintos, mas semelhantes. Então, o que seria um processo?
Processo, na área da computação é um módulo executável único que é
executado concorrentemente com outros módulos executáveis. Onde um
módulo executável é um conjunto de instruções de um programa que devem
ser seguidos para a conclusão do mesmo.
Para exemplificar, existem os sistemas operacionais multitarefa
(Windows ou Linux, por exemplo) que executam vários processos que rodam
concorrentemente com os outros processos para que tenham suas linhas de
código executadas pelo processador. Além de também poderem rodar
simultaneamente com outros processos interagindo para que a aplicação
ofereça um melhor desempenho e confiabilidade.
Processos são módulos separados e carregáveis. Threads, não podem
ser carregados, eles são iniciados dentro de um processo, onde um processo
pode executar várias threads ao mesmo tempo. Funcionam como se existisse
vários processos internos ao processo pai, o qual gera outros processos. Aos
processos gerados pelo pai, denominam-se processos filhos. Estes podem
rodar ao mesmo tempo seguindo algumas regras para que não ocorram
conflitos internos. Estes conflitos internos podem variar desde uma paralisação
total ou parcial do sistema até inconsistência de valiosas informações.
No universo dos modelos de processos existem dois conceitos
independentes de como enxergá-los, são eles o agrupamento de recursos e a
execução.
O primeiro é um modo de ver um processo, ele apresenta um espaço de
endereçamento que possui um código e os dados de programa, e talvez alguns
outros recursos alocados, como alguns arquivos abertos, informações entre
contabilidades, processos filhos, enfim, o que interessa é que agrupar todos
eles em forma de processos facilitará o gerenciamento destes recursos.
O segundo é denominado thread de execução, que normalmente é
abreviado simplesmente para thread. Ele possui um contador de programa, o
qual manterá o controle de qual instrução da thread deverá ser executada em
seguida pelo núcleo, possui registradores com suas variáveis de trabalho
atuais, possui uma pilha estruturada pelo conjunto de procedimentos
chamados, mas ainda não concluídos, a qual informa o histórico da execução.
Os dois conceitos citados acima são importantes, pois delimitam os
conceitos entre threads e processos, que apesar de semelhantes, são
conceitos diferentes. A característica que os threads acrescentam ao conceito
de processos é a permissão de múltiplas execuções ocorrerem em um mesmo
processo de forma independente uma das outras.
Existem sistemas que suportam apenas uma única thread em execução
por vez e os que suportam mais de uma por vez, denominados
de monothread e multithread respectivamente.Então, qual seria a diferença de
ter várias threads sendo executadas em um único processo (multithread), e
vários processos sendo executados em um computador?
No primeiro caso (multithread), os várias threads estão compartilhando
um mesmo espaço de endereçamento na memória, assim como os recursos
alocados pelo processo criador da thread. No segundo caso, os processos
compartilham um espaço físico de memória.
É importante citar que a existência de recursos compartilhados necessita
de um controle para que não haja nenhum tipo de conflito que possa
embaralhar tanto a vida do usuário como a integridade das informações
processadas.
2.1. TIPOS DE THREADS
Existem dois tipos de implementações de threads: thread usuário e
thread núcleo.
O primeiro, thread de usuário, como o próprio nome já diz, tem por
principal característica o fato de deixar todos os pacotes e controles de threads
no espaço do usuário, de forma que o núcleo não seja informado sobre eles,
logo as threads serão tratadas de forma simples (monothread). Mesmo que
existam vários núcleos, ou seja, vários processos sendo executados ao mesmo
tempo (multiprocessamento), onde somente os processos é que serão
executados paralelamente e não as threads, pois estas estão alocados dentro
dos processos.
Uma vantagem das threads de usuário está na sua versatilidade, pois
elas funcionam tanto em sistemas que suportem ou não o uso de threads. Uma
vez que sua implementação estará interna ao processo criador da thread, o
sistema operacional não poderá interferir nesta criação, desta forma o sistema
executará a thread como se fosse apenas mais uma linha de execução do
processo. De fato, o processo criador deverá possuir todas as características
de gerenciamento e confiabilidade de threads, que estão presentes nas tabelas
dos núcleos dos sistemas que ofereçam o suporte aos threads.
Um thread de usuário possui desempenho melhor, mas existem alguns
problemas, como por exemplo, uma chamada ao sistema de bloqueio. Se a
thread executar esta chamada, ela para todas as outras threads, porém com o
uso de threads chamadas desse tipo são muito comuns, pois elas permitem o
controle das threads. Seria contraditório realizar uma chamada de bloqueio,
que pare todos as threads para permitir que uma outra delas possa ser
executada. De fato nenhuma thread jamais seria executada, até que fosse
desbloqueada.
Outro problema com a utilização de thread é a posse do núcleo. Uma
vez que se inicie uma thread do tipo usuário, ela ficará sendo executada até
que, por uma linha de comando própria ela libere o núcleo para outras threads
do processo.
No entanto, existem soluções para os problemas mencionados acima,
porém são complicadas de serem implementadas, o que torna o código
bastante confuso.
O segundo tipo, thread de núcleo, é perceptível logo de início que o
núcleo sabe da existência das threads e que ele será o gerenciador das
mesmas. Neste caso, o processo não precisará de nenhuma tabela para
gerenciar as threads, o núcleo se encarregará de tudo, sendo necessário ao
processo apenas a realização das chamadas que quiser ao núcleo para a
manipulação de suas threads.
Estas chamadas ao sistema possuem um custo maior se comparadas
com as chamadas que um sistema de threads de usuário realiza. Para
amenizar este custo, os sistemas utilizam-se da ‘reciclagem’ de threads, desta
forma, quando uma thread é destruída, ela é apenas marcada como não
executável, sem afetar sua estrutura. Desta forma a criação de uma nova
thread será mais rápida, visto que sua estrutura já esta montada, bastando
apenas a atualização de suas informações.
Uma vantagem da thread de núcleo é que se uma thread de um
processo for bloqueado, as outras threads que forem gerados por este mesmo
processo poderão dar continuidade às suas linhas de execução, sem a
necessidade da primeira thread concluir suas linhas de execução.
Existem também as implementações de threads híbridas, neste caso,
tenta-se combinar as vantagens das threads de usuário e com as de núcleo.
2.2. COMUNICAÇÃO ENTRE THREADS
Com freqüência os processos precisam trocar informações entre si para
continuar suas linhas de execução. Quando se trata de threads isso é um
pouco mais fácil, pois elas compartilham um espaço de endereçamento
comum, entretanto ainda é necessário um controle para evitar embaraços entre
elas. Esses embaraços geralmente ocorrem quando elas acessam uma
variável compartilhada ao mesmo tempo, ou seja, dentro de uma região crítica.
2.2.1. Região crítica
Em poucas palavras, região crítica é uma região de memória
compartilhada que acessa um recurso que está compartilhado e que não possa
ser acessado concorrentemente por mais de uma linha de execução. A região
crítica, como o próprio nome já diz, por ser uma área crítica necessita de
cuidados para que não hajam problemas futuros devido à má utilização da
mesma.
Para entender melhor região crítica é bom ter em mente o que seria
Condição de disputa. Esta consiste em um conjunto de recursos que deve ser
compartilhado entre processos no qual, em um mesmo intervalo tempo, dois ou
mais processos tentem alocar uma mesma parte de um mesmo recurso para
poder utilizá-lo. Nesta hora que ocorre o problema do controle de disputa.
Para melhor entendimento deste problema, imagine duas threads, A e
B, e um recurso compartilhado que permita o acesso das duas threads ao
mesmo tempo, de forma que sempre que este recurso for utilizado seja emitido
um aviso informando que ocorreu tudo bem. Agora vamos supor que as
threads A e B entrem na região compartilhada para utilizá-la quase que ao
mesmo tempo. Então, a thread A chega primeiro e marca na região
compartilhada para ser a próxima a utilizá-la, mas antes que ela a utilize, o
sistema operacional tire a sua posse de núcleo e a passa para a thread B.
Então a thread B marca o recurso compartilhado como sendo ele o próximo a
utilizá-lo, o utiliza e recebe sua confirmação do recurso informando que ocorreu
tudo bem. Após isso a thread B libera o núcleo e o sistema o passa para a
thread A, que pensa ser a próximo a utilizar o recurso compartilhado e fica
aguardando a mensagem do recurso informando que ocorreu tudo bem.
Entretanto o processo A ficará eternamente esperando pela resposta do
recurso, mas ela nunca chegará.
Com base nisto é possível perceber o problema de condição de disputa
e também é caracterizar a região crítica. Que no caso do exemplo anterior seria
a área do recurso que aloca o próximo processo ou thread a utilizá-lo.
Para resolver estes tipos de problemas e muitos outros tipos que
envolvam regiões compartilhadas é preciso encontrar uma forma de bloquear
que outros processos usem uma área compartilhada que esteja sendo usada
até que ela seja liberada pelo processo que a esteja utilizando. A essa solução
denomina-se exclusão mútua e será o assunto abordado no próximo tópico.
2.2.2. Exclusão mútua
Como dito anteriormente, exclusão mútua é uma solução encontrada
para evitar que dois ou mais processos ou threads tenham acesso
simultaneamente a alguma região crítica de algum recurso que esteja
compartilhado.
Existem quatro condições que devem ser satisfeitas para que os
processos e threads concorrentes à mesma região crítica sejam executados de
forma eficiente e corretamente. São elas:
1) Nunca dois processos podem estar simultaneamente em suas
regiões críticas;
2) Nada pode ser afirmado sobre a velocidade ou sobre o número de
CPUs;
3) Nenhum processo executando fora de sua região crítica pode
bloquear outros processos;
4) Nenhum processo deve esperar eternamente para entrar em sua
região crítica;
Existem várias formas para se realizar a exclusão mútua de forma que
se um processo estiver utilizando a região crítica, nenhum outro processo
poderá utilizar esta região para que não ocorram problemas.
2.3. PROBLEMAS COM O USO DE THREADS
Como dito anteriormente, as threads apresentam alguns problemas.
Alguns deles já foram passados implicitamente com as abordagens anteriores,
como os da região crítica e os da dificuldade de implementação, por exemplo.
Entretanto existem mais um leque de problemas relacionados a threads e são
deles que este artigo tratará de explicar quais são eles agora.
Quadro 1. Pseudocódigo do funcionamento das variáveis de
travamento
Existem várias tentativas de contornar os problemas com os threads, um
deles é utilização de variáveis de impedimento (Lock), a qual está representada
no Quadro 1. Esta é uma solução via software do usuário e não pelo sistema
operacional.
De acordo com o Quadro 1, a variável Lock inicialmente contem o valor
0. Sempre que algum processo ou thread tenta entrar na região crítica ele testa
Lock //Variável compartilhada. Indica se a região crítica está
//liberada.
While (true) do {
If (Lock = 0) {
Lock = 1;
//Região_Crítica
Lock = 0;
}
}
se lock é 0. Caso afirmativo, o processo ou thread altera esta variável para 1 e
então entra na região crítica. Caso negativo o processo ou thread entrará em
um laço sem fazer nada até que a variável esteja contenha o valor 0.
Apesar de parecer uma boa solução, ela não consegue satisfazer
sempre as quadro condições da exclusão mútua. Para provar isso, suponha
duas threads, A e B, que queiram acessar uma mesma região crítica. A thread
A testa se a variável Lock é 0. Como é o estado inicial, Lock é 0. Mas
justamente neste ponto, o sistema operacional toma a posse do núcleo de A e
o entrega para B. Logo, B também verifica que a variável Lock permanece 0,
uma vez que A ainda não entrou e alterou para 1. Então como as duas threads
verificaram que Lock é 0, podem entrar na região crítica, e neste caso ocorrerá
problemas.
Outra suposição com os mesmos parâmetros de entrada é supor que a
thread A entre da região e antes que saia e modifique a variável Lock para 0,
de um erro e trave. Desta forma, como o thread B ainda não entrou na região
crítica, ele nunca entrará, pois a variável Lock permanecerá sempre como 1.
Este mesmo caso ocorre com outra tentativa de solução que se da através da
utilização de semáforos.
Existem vários outros problemas relacionados às threads. Este foi
apenas um deles e serviu como exemplo para que se possa entender a
complexidade da programação com a utilização de threads, pois apesar do
pseudocódigo do Quadro 1 ser uma solução ser simples, verifica-se que ele
não satisfaz as quatro condições da exclusão mútua, e continua sem resolver
os problemas complexos das threads.
Outros problemas de importante citação são o deadlock e o starvation.
O primeiro ocorre quando uma thread está aguardando a liberação de
um recurso compartilhado, que por sua vez, possui uma outra thread
aguardando a liberação de outro recurso compartilhado da primeira thread.
Desta forma elas ficarão eternamente paradas, uma esperando pela outra.
Uma analogia a este problema é imaginar uma rua estreita, na qual só entra
um carro por vez. Supondo que entrem dos dois lados duas fileiras enormes de
carros, um atrás do outro, eles ficarão travados, pois não conseguirão ir para
frente ou para trás.
O segundo, conhecido como inanição, ocorre quando um processo ou
thread nunca é executado, pois processos ou threads de maior importância
sempre tomam a posse do núcleo fazendo com que os de menores prioridades
nunca sejam executados.
A diferença entre o deadlock e o starvation é que o starvation ocorre
quando os programas rodam indefinidamente, ao contrário do deadlock, que
ocorre quando os processos permanecem bloqueados, dependendo da
liberação dos recursos por eles alocados.
Como já mencionado, é importante salientar a dificuldade de se
programar ao utilizar-se de thread devido a sua complexidade, uma vez que
uma simples desatenção do programador pode ocasionar vários problemas.
Ainda há a desvantagem do debug com threads, que é mais complicado, pois
como pode existir mais de uma thread em execução pelo programa, o
programador não saberá qual é a thread mostrada pelo compilador no modo
debug.
3. APRESENTANDO O PROBLEMA DOS LEITORES E ESCRITORES
As dependências de dados na execução de processos ou threads
caracterizaram diversos tipos de problemas. Um deles é o problema conhecido
como problema dos leitores e escritores.
O problema dos Leitores e Escritores modela o acesso a uma base de
dados, onde basicamente alguns processos ou threads estão lendo os dados
da região crítica, somente querendo obter a informação da região crítica, que é
o caso dos leitores, e outros processos ou threads tentando alterar a
informação da região crítica, que é o caso dos escritores.
Analisando uma situação de um banco de dados localizado em um
servidor, por exemplo, temos situações relacionadas ao caso do problema dos
leitores e escritores. Supondo que temos usuários ligados a este servidor
querendo ler dados em uma tabela chamada Estoque, a princípio todos os
usuários terão acesso a esses dados. Supondo agora usuários querendo
atualizar na mesma tabela de Estoque, informações de vendas realizadas, de
fato esses dados serão atualizados. Mas para organizar esses acessos tanto
de atualização, quanto leitura no banco de dados algumas políticas são
seguidas, o mesmo acontecerá no problema dos leitores e escritores.
As políticas seguidas no caso dos leitores e escritores para acesso a
região critica são as seguintes: processos ou threads leitores somente lêem o
valor da variável compartilhada (não alteram o valor da variável compartilhada),
podendo ser de forma concorrente; processos ou threads escritores podem
modificar o valor da variável compartilhada, para isso necessita de exclusão
mutua sobre a variável compartilhada; durante escrita do valor da variável
compartilhada a operação deve ser restrita a um único escritor; para a
operação de escrita não se pode existir nenhuma leitura ocorrendo, ou seja,
nenhum leitor pode estar com a região critica bloqueada; em caso de escrita
acontecendo, nenhum leitor conseguirá ter acesso ao valor da variável.
Continuando a análise do banco de dados e seguindo as políticas dos
leitores e escritores têm as seguintes situações: vários usuários consultando a
tabela Estoque sem alterá-la; para um usuário atualizar uma venda é
necessário que não se tenha nenhum usuário consultando a tabela de estoque;
quando um usuário estiver atualizando a venda, nenhum outro usuário pode
atualizar ao mesmo tempo; se o usuário iniciar uma consulta e estiver
ocorrendo uma atualização o mesmo irá esperar a liberação da atualização.
Por estarmos falando de um problema computacional, então como
resolvermos isto computacionalmente? Segue abaixo um pseudocódigo nos
quadros 2, 3 e 4 para se ter uma noção da solução:
Quadro 2.Variáveis do pseudocódigo
“semaphore mutex = 1; // controla acesso a região critica
semaphore db = 1; // controla acesso a base de dados
int rc = 0; // número de processos lendo ou querendo ler”
Tanenbaum [10]
Quadro 3. Procedimento do leitor
Procedimento do Escritor
void writer(void)
{
while (TRUE) { // repete para sempre
think_up_data(); // região não critica
down(&db); // obtém acesso exclusivo
write_data_base(); // atualiza os dados
up(&db); // libera o acesso exclusivo
}
}”
Tanenbaum [11]
“void reader(void)
{
while(TRUE) { // repete para sempre
down(&mutex); // obtém acesso exclusivo a região critica
rc = rc + 1; // um leitor a mais agora
if (rc == 1) down(&db); //se este for o primeiro leitor bloqueia a //base de dados
up(&mutex) // libera o acesso a região critica
read_data_base(); //acesso aos dados
down(&mutex); // obtém acesso exclusivo a região critica
rc = rc -1; // menos um leitor
if (rc == 0) up(&db); // se este for o último leitor libera a base de //dados
up(&mutex) // libera o acesso a região critica
use_data_read(); // utiliza o dado
}
}”
Tanenbaum [11]
Quadro 4.Procedimento do escritor
Fazendo uma análise relacionada ao problema, enfim levando em
consideração políticas, e a solução apresentada, conseguimos evitar a questão
da espera ocupada que é um dos maiores problemas na comunicação de
processos ou threads, tendo assim um bom processamento. Mas se pode notar
que pode ocorrer à situação de se ter um leitor e bloquear a região critica. Se
sempre chegarem leitores, aumentando assim o número de leitores, e existir
um escritor esperando para realizar sua operação de escrita, o escritor pode
chegar a não ser executado pelo grande número de leitores estarem sempre
com a região critica bloqueada levando a uma situação caracterizada como
starvation.
4. THREADS NO C#
A linguagem de programação C#, assim como as atuais linguagens de
programação de alto nível, oferece recursos que possibilitam a criação de
programas com processamento paralelo com uso das threads. O C# provê
recursos como criação de threads, sincronização e exclusão mutua.
4.1. CRIANDO THREADS E AS INICIANDO
Antes de começar a programar utilizando threads, é preciso adicionar o
namespace System.Threading. Feito isso, é possível dispor dos recursos
oferecidos pela linguagem C#.
Para criar uma thread basta informar uma nova variável do tipo Thread
passando no construtor o delegado que informa qual método será executado
pela thread. Feito isso, a thread já está pronta para ser iniciada, sendo preciso
chamar o método Start para começar a execução como mostrado nos quadros
5 e 6.
Quadro 5.Criando e iniciando uma thread
Quadro 6.Programa Olá Mundo
4.2. SINCRONIZAÇÃO ENTRE THREADS
A forma mais fácil de sincronizar a execução das threads é utilizando o
método Join. Este método faz o bloqueio do programa até que a thread seja
executada por completo, sendo então liberado para prosseguir com as demais
instruções.
class Ola_Mundo { static void Main() { Thread t = new Thread(new ThreadStart(Imprime)); t.Start(); } static void Imprime() { Console.WriteLine("Ola Mundo!"); } }
ThreadStart delegado = new ThreadStart(metodo);
Thread t = new Thread (delegado);
t.Start();
Quadro 7.Uso do método Join
O uso do método Join garante que só será informado o fim do programa
quando for impresso na tela a frase Ola Mundo pela thread. Este recurso é
muito útil quando o programa só pode continuar sua execução após o fim da
thread.
Esta é uma forma bastante simples da manter a sincronização entre
threads, porém anula a execução paralela, principal motivo para o uso de
threads. Outra forma de manter a sincronização entre as threads é utilizar
bloqueios, como lock, mutex ou semáforos.
A forma de bloqueio lock é a mais simples e permite bloquear um bloco
de código com exclusão mutua, evitando assim a condição de corrida. Por ser
a forma mais simples de realizar um bloqueio, também é mais rápida que as
demais. Caso outra thread tente realizar o bloqueio de um objeto que se
encontra bloqueado, a thread será bloqueada pelo sistema operacional e só
poderá continuar quando o objetivo for liberado, como mostrado no quadro 8.
class Ola_Mundo { static void Main() { Thread t = new Thread(new ThreadStart(Imprime)); t.Start(); t.Join(); Console.WriteLine("Fim do programa."); } static void Imprime() { Console.WriteLine("Ola Mundo!"); } }
Quadro 8.Bloqueando uma região do código
A classe mutex, mostrada no quadro 9, funciona parecida com o
bloqueio lock, entretanto por não realizar o bloqueio por blocos de código,
permite que tanto o bloqueio como o desbloqueio seja realizado em diferentes
regiões do código com uso dos métodos WaitOne e ReleaseMutex. A tentativa
de bloqueio de um objeto já bloqueado é análoga a forma anterior.
Quadro 9.Classe mutex para exclusão mútua
Os semáforos são uma forma mais completa de realizar bloqueio. A
classe Semaphore é uma extensão da classe mutex. Como principais
características permitem que um ou mais processos entrem na região crítica e
que o bloqueio realizado por uma thread possa ser desfeito por outra thread,
recurso que pode ser útil em determinados problemas, como no problema dos
leitores e escritores. Abaixo é mostrado como usar a classe Semaphore.
mutex.WaitOne();
contador++;
Console.WriteLine(contador);
mutex.ReleaseMutex();
lock (contador) {
contador++;
Console.WriteLine(contador);
}
Quadro 10.Exemplo de uso da classe semáforo
O C# oferece ainda a classe Monitor que provê outras funcionalidades
como sinalizar e esperar por uma sinalização de outra thread. Os comandos
são Wait e Pulse ou PulseAll. Outro recurso interessante é o TryEnter que
como o próprio nome diz, tenta obter o acesso ao objeto, caso não seja
possível retorna o valor false. O método Enter funciona de maneira semelhante
aos já mencionados. Vale mencionar que a classe Monitor, segundo Jeffrey
Richter, é 33 vezes mais rápida que a classe Mutex por ser implementada pela
Common Language Runtime (CLR) e não pelo sistema operacional, além disso
a classe Mutex permite sincronização entre processos.
4.3. OUTROS RECURSOS
Ainda é possível escolher a prioridade da thread, informar se a mesma é
uma thread de plano de fundo, dar um nome, adormecer por um tempo
determinado, suspender, retomar, interromper e abortar uma thread.
Assim como os processos do sistema operacional, as threads no C# têm
uma prioridade padrão, mas que pode facilmente ser alterada pelo
programador, bem como informar se a thread deve executar em segundo
plano, ou background. Os métodos são Priority e IsBackground. Vale lembrar
que os possíveis valores de prioridade de uma thread são Lowest,
BelowNormal, Normal, AboveNormal e Highest, sendo normal a prioridade
padrão.Esses valores são uma enumeração pertencentes ao namespace
System.Threading.ThreadPriority.
Os métodos para suspender, interromper e abortar uma thread parecem
confusos, contudo têm suas diferenças. Quando suspensa (Suspend), a thread
semaforo.WaitOne();
contador++;
Console.WriteLine(contador);
semaforo.Release();
é bloqueada, mas há a possibilidade da mesma ser retomada (Resume). Este
recurso deve ser usado com cautela, pois uma thread suspensa pode manter
bloqueado um objeto até que seja re-iniciada, em uma condição mais crítica
pode levar a um deadlock. Os métodos para interromper (Interrupt) e abortar
(Abort) a thread finalizam permanentemente a execução, lançando as
exceções ThreadInterruptedException e ThreadAbortException,
respectivamente.
A diferença básica entre interromper e abortar uma thread está no
momento em que a thread será finalizada. Interrompendo, a thread só será
finalizada quando for bloqueada pelo sistema operacional, já abortando será
finalizada imediatamente.
O problema que pode acontecer ao suspender uma thread, ou seja,
bloquear e não desbloquear, também pode ocorrer quando ela é abortada ou
interrompida. Isso acontece porque a thread é finalizada por meio de uma
exceção lançada que ocasiona o fim da thread. No quadro 11, caso a thread
seja finalizada dentro da região crítica, o objeto permanecerá bloqueado
mesmo após a execução da thread.
Quadro 11.O problema com threads abortadas
5. ESTUDO DE CASO: O PROBLEMA DOS LEITORES E ESCRITORES
Para um melhor entendimento de como usar threads utilizando a
linguagem de programação C#, é mostrado e comentado o código fonte do
programa desenvolvido para resolver o problema dos leitores e escritores
utilizando processamento paralelo com concorrência, contudo, devidamente
sincronizado.
mutexWrite.WaitOne(); //... //Região Crítica //Thread abortada, interrompida ou suspensa //... mutexWrite.Release();
Este clássico problema pode ser visto como diversos usuários, threads,
utilizando um banco de dados. É claro que nos diversos sistemas que utilizam
banco de dados, vários usuários fazem consultas (ver o histórico escolar,
consultar o saldo bancário, etc.), alguns outros usuários precisam escrever na
base de dados, seja para atualizar o endereço de e-mail ou até mesmo se
cadastrar na locadora perto de casa.
Visando facilitar o entendimento do programa, ele será dividido por
métodos, sendo comentados separadamente.
Figura 1. Tela do programa
Quadro 12.Declaração de variáveis do programa
São mostradas todas as variáveis globais do programa no quadro 12. Os
objetos mutexRead, mutexWrite e mutexListBox controlam o acesso as regiões
dos leitores, escritores e do listbox usado, respectivamente.
Os vetores threadReader e threadWriter armazenam todas as threads
manipuladas pelo programa, de tal modo que tenha controle sobre todas as
threads criadas caso seja preciso.
A variável dado é responsável por armazenar a ultima informação
armazenada por um leitor. Ela é do tipo StringBuilder pelo fato de strings no C#
serem estáticas.
Os objetos continuaLeitor e continuaEscritor são úteis para informar ate
quando cada as threads devem permanecer ativas. No momento desejado da
parada, as threads terminam as tarefas pendentes e depois chegam ao fim da
//variáveis utilizadas para mutex de leitura e escrita object mutexRead = new object(); Semaphore mutexWrite = new Semaphore(1, 1);
//variável utilizada para evitar a condição de corrida ao //ListBox Semaphore mutexListBox = new Semaphore(1, 1); //vetores de threads utilizados para adicionar o recurso de //vários leitores e escritores simultâneios Thread[] threadReader, threadWriter;
//dado compartilhado por leitores e escritores StringBuilder dado = new StringBuilder(16, 150);
//contador de leitores ativos int readerCounter = 0; //constantes para uso no sleep das threads const int minRandom = 500; const int maxRandom = 5000;
//variável usada para oferecer um número aleatório Random sorteio = new Random(minRandom);
//informa o momento de parada das threads bool continuaLeitor, continuaEscritor;
execução quando no comando while é testado o valor da variável
continuaLeitor no caso de uma thread leitora ou continuaEscritor caso seja uma
thread escritora.
Quadro 13.Código que inicia os leitores
O método buttonLeitor_Click cria o vetor de leitores com o tamanho
passado pelo usuário, então cria, nomeia e inicia cada thread.
private void buttonLeitor_Click(object sender, System.EventArgs e) {
//para evitar que novas thread sejam instanciadas buttonLeitor.Enabled = false;
continuaLeitor = true;
//alocando o vetor threadReader = new
Thread[Int32.Parse(textBoxValorLeitor.Text)];
//criando e iniciando a quantidade desejada de threads //e definindo um nome para cada thread for (int i = 0; i < threadReader.Length; i++) {
threadReader[i] = new Thread(new ThreadStart(Reader)); threadReader[i].Name = "Leitor " + (i + 1).ToString("D3");
threadReader[i].Start(); } }
Quadro 14.Criando os escritores
Da mesma forma como o método anterior, o método buttonEscritor_Click
cria o vetor que conterá as threads e as inicia dando um nome, seguindo a
mesma lógica já apresentada.
private void buttonEscritor_Click(object sender, System.EventArgs e) {
//para evitar que novas thread sejam instanciadas buttonEscritor.Enabled = false;
continuaEscritor = true;
//alocando vetor threadWriter = new
Thread[Int32.Parse(textBoxValorEscritor.Text)];
//criando e iniciando a quantidade desejada de threads //e definindo um nome para cada thread for (int i = 0; i < threadWriter.Length; i++) {
threadWriter[i] = new Thread(new ThreadStart(Writer)); threadWriter[i].Name = "Escritor " + (i + 1).ToString("D3"); threadWriter[i].Start();
} }
Quadro 15.Método executado pelos escritores
Por questão de simplicidade, o método para criar os escritores, quadro
15, é mostrado antes. Basicamente consiste em bloquear o semáforo de
escrita, escrever o dado e então liberar o semáforo. Usou-se do procedimento
Sleep para melhor intercalar as threads. Assim que bloqueia o acesso à escrita,
private void Writer() { int iteracoes = 1; while (continuaEscritor)
Thread.Sleep(sorteio.Next(minRandom,
maxRandom + minRandom));
//bloqueando os escritores mutexWrite.WaitOne();
mutexListBox.WaitOne(); listBoxInforma.Items.Add(Thread.CurrentThread.Na
me + " bloqueou os escritores"); mutexListBox.Release(); //somente será posto na variavel dado o nome do
escritor e sua iteração dado.Remove(0, dado.Length); dado.Insert(0, Thread.CurrentThread.Name + " na
iteração "+ iteracoes.ToString("D3") ); //fim do write_data();
mutexListBox.WaitOne();
listBoxInforma.Items.Add(dado.ToString() + " escreveu.");
mutexListBox.Release();
mutexListBox.WaitOne(); listBoxInforma.Items.Add(Thread.CurrentThread.Na
me + " liberou os escritores"); mutexListBox.Release();
//liberando os escritores mutexWrite.Release();
iteracoes++;
} }
a thread informa que obteve o bloqueio, atualiza o valor da variável dado,
informa qual o novo valor e então libera o acesso.
Quadro 16.Método executado pelos leitores
private void Reader() { int iteracores = 1;
//dado lido pela thread string dadoLido; while (continuaLeitor) {
//Adormecer por um tempo randômico para melhor //intercalar as threads Thread.Sleep(sorteio.Next(minRandom,
maxRandom));
//bloqueando leitores lock (mutexRead) {
//incrementando o contador de leitores ativos readerCounter ++; //caso seja o primeiro leitor, deve bloquear os
escritores if (readerCounter == 1) {
//bloqueando escritores mutexWrite.WaitOne();
mutexListBox.WaitOne(); listBoxInforma.Items.Add(Thread.CurrentThread.Name + " bloqueou os escritores"); mutexListBox.Release();
} }
//liberando leitores
Quadro 17.Continuação do quadro 16
O procedimento Reader foi implementado de forma diferente do Writer
propositalmente, para mostrar as várias formas de controlar a concorrência
entre threads. Sucintamente, o primeiro leitor bloqueia o acesso à escrita, lê o
dadoLido = dado.ToString();
//bloqueia leitores na RC e decrementa o valor do //contador de leitores lock (mutexRead) {
readerCounter --;
//caso seja o ultimo leitor, deve desbloquear
os escritores if (readerCounter == 0) {
mutexWrite.Release();
mutexListBox.WaitOne(); listBoxInforma.Items.Add(Thread.CurrentThread.Name + " liberou os escritores");
mutexListBox.Release();
}
}
//liberando leitores
//informar que a thread leitor leu um dado
mutexListBox.WaitOne(); listBoxInforma.Items.Add(Thread.CurrentThread.Na
me + " leu o dado: " + dadoLido); mutexListBox.Release();
//incrementa a quantidade de iteraçoes realizadas pela thread
iteracores++; }
}
dado, verifica se é o ultimo leitor, pois caso seja deve liberar o acesso à escrita,
e então usa a informação lida. Ao entrar no laço while, o leitor é adormecido
por um tempo randômico pelo motivo supracitado, posteriormente bloqueia o
acesso a região crítica que incrementa a quantidade de leitores, verifica se há
necessidade de bloquear o acesso à escrita e então libera a região. Feito isso,
o dado é lido, novamente a região crítica é bloqueada, é decrementada a
quantidade de leitores, caso não exista leitores, o semáforo de escrita é
liberado e esta ação é informada. Por fim o leitor usa a informação lida, que
neste caso é simplesmente informar que o dado foi lido.
Vale salientar que existe a necessidade de controlar o acesso à variável
listBoxInforma, pois é perfeitamente possível que 2 threads tentem acessar o
objeto ao mesmo tempo.
CONCLUSÃO
Ao fazer uma análise de todo o assunto abordado neste artigo,
encontramos recursos que auxiliaram de forma positiva na implementação do
problema dos Leitores e Escritores usando a linguagem de programação C#.
A linguagem C# contempla diversas ferramentas que auxiliam a
implementação de threads, tornando menos complexa a resolução do
problema. Estas ferramentas incluem desde simples bloqueios de regiões
específicas até a utilização de monitores e semáforos, dando ao programador
várias maneiras diferentes de implementações com o uso de threads.
Por ser uma linguagem moderna e que vem crescendo nos últimos anos,
há uma grande facilidade de encontrar material relacionado a este e a qualquer
outro tópico relacionado a C#.
Apesar de todas as facilidades oferecidas por esta e outras linguagens
de alto nível há uma maior complexidade na programação e depuração de
programas que utilizam threads, pois o programador precisa se preocupar com
questões relacionadas à utilização de threads. E sua depuração torna-se
menos intuitiva pelo fato do escalonamento realizado pelo sistema operacional
intercalar a execução das threads.
SOBRE OS AUTORES:
Daniel Ramon Silva Pinheiro é Graduando em Ciência da Computação pela
Universidade Tiradentes.
Danilo Santos Souza é Graduando em Ciência da Computação pela
Universidade Tiradentes.
Maria de Fátima A. S. Colaço é Mestre em Informática pela Universidade
Federal de Campina Grande
Rafael Oliveira Vasconcelos é Graduando em Ciência da Computação pela
Universidade Tiradentes.
Referência
1. ALBAHARI, Joseph; Threading in C#; Disponível em <
http://www.albahari.com/threading/>; Acessado em 13.09.2008
2. BIRRELL, Andrew D.; An Introduction to Programming with C# Threads;
Disponível em
<http://research.microsoft.com/~birrell/papers/ThreadsCSharp.pdf>;
Acessado em 13.09.2008
3. LEE, Edward A.; The Problem with Threads; Disponível em <
http://www.computer.org/portal/site/computer/menuitem.5d61c1d591162e4b
0ef1bd108bcd45f3/index.jsp?path=computer/homepage/0506&file=cover.x
ml&xsl=article.xsl>; Acessado em 13.09.2008
4. Luiz Lima Jr.; Processos e Threads; Disponível em
<http://www.ppgia.pucpr.br/~laplima/aulas/so/materia/processos.html>;
Acessado em 19.09.2008
5. MAILLARD, Nicolas; Threads; Disponível em <
http://www.inf.ufrgs.br/~nmaillard/sisop/PDFs/aula-sisop10-threads.pdf>;
Acessado em 13.09.2008
6. MSDN Library; Threading (C# Programming Guide); Disponível em <
http://msdn.microsoft.com/en-us/library/ms173178.aspx>; Acessado em
13.09.2008
7. OUSTERHOUT, John; Why Threads Are A Bad Idea (for most purposes);
Disponível em < http://home.pacbell.net/ouster/threads.pdf>; Acessado em
13.09.2008
8. RICHTER, Jeffrey. CLR via C#, Segunda Edição. Microsoft Press, Março de
2006
9. SANTOS, Giovane A.; Programação Concorrente; Disponível em
<http://www.ucb.br/prg/professores/giovanni/disciplinas/2004-
1/pc/material/giovanni/threads.html>; Acessado em 18.09.2008
10. SAUVÉ, Jacques Philippe; O que é um thread?; Disponível em
<http://www.dsc.ufcg.edu.br/~jacques/cursos/map/html/threads/threads1.ht
ml>; Acessado em 13.09.2008
11. TANENBAUM, Andrew. Sistemas operacionais modernos, 2ª Edição. Rio
de Janeiro: LTC. 1999
12. Wikipedia; Thread (ciência da computação); Disponível em
<http://pt.wikipedia.org/wiki/Thread_(ci%C3%AAncia_da_computa%C3%A7
%C3%A3o)>; Acessado em 13.09.2008