Buscar

Intro_Prog_Concor

Prévia do material em texto

Introdução à Programação Concorrente
para a Internet
Dilma Menezes da Silva
Departamento de Ciência da Computação
Instituto de Matemática e Estatística
Universidade de São Paulo
dilma@ime.usp.br
http://www.ime.usp.br/~dilma/
Resumo
A popularização de novas categorias de aplicações, em particular a
disponibilidade de programas que executam a partir de browsers,
muitas vezes obriga o desenvolvedor a lidar com aspectos de
concorrência antes restritos a comunidades bem específicas. Os
conceitos básicos da área em geral são apresentados como parte de
um curso de sistema operacional ou banco de dados, mas neste mini-
curso enfocamos o assunto tendo como motivação seu uso para o
desenvolvimento de programas concorrentes que executam na
internet como Applets Java.
Abstract
The increasing popularity of new categories of applications,
particularly applications in the internet that are accessed by
browsers, many times requires that the developer deal with
concurrency aspects so far usually restricted to specific computer
science communities. The basic concepts in the area are usually
presented as part of an operating systems or data base course, but in
this short course we focus the subject through its use in the
development of concurrent programs that execute as Java Applets.
1. Introdução e motivação
A área de programação concorrente vem sendo explorada há várias décadas, sob
vários enfoques. Em muitos desses enfoques o trabalho foi bem sucedido:
problemas básicos foram definidos e solucionados, aspectos teóricos caracterizados
e estudados, aspectos de implementação largamente experimentados e analisados.
Em geral, o impacto dos resultados obtidos por esses estudos estavam confinados a
comunidades específicas, estando o desenvolvedor de sistemas razoavelmente
“protegido” dos desafios e problemas da área, a menos que desenvolvesse software
básico (sistemas operacionais, gerenciadores de bancos de dados) ou atuasse
diretamente em áreas específicas como computação paralela ou sistemas de tempo
real. Mas novas categorias de aplicações, com crescente impacto na área de
computação, muitas vezes obrigam o desenvolvedor a enfrentar diretamente as
questões relativas à concorrência. Conceitos, mecanismos e implementações antes
restritos a livros acadêmicos passaram a exercer um papel importante em livros
técnicos direcionados aos desevolvedores de software. Vários textos recentes
[Magee-Kramer 1999, Butenhof 1997, Lea 1997] apresentam formas de lidar com
concorrência do ponto de vista de tecnologias específicas, enquanto outros textos
abordam a questão de forma totalmente conceitual, sem abordar diretamente
tecnologias recentes e bem difundidas atualmente [Milner 1995, Andrews 1991]. O
objetivo deste texto é aliar os dois extremos, isto é, apresentar uma introdução à
área de concorrência que contemple tanto seu uso imediato no desenvolvimento de
aplicativos (em especial, aplicativos para a internet) quanto a formação conceitual
básica comum às várias vertentes da Ciência da Computação relacionadas a
programação concorrente.
Muitos dos conceitos e problemas apresentados aqui fazem parte dos currículos
usuais de graduação na área de Ciência da Computação, aparecendo em geral
como um item entre vários a serem abordados nas disciplinas de sistemas
operacionais, banco de dados, computação paralela ou computação distribuída.
Este mini-curso se diferencia por enfocar os problemas fora dos contextos e
requisitos específicos destas disciplinas. O mini-curso foi elaborado de forma a
contemplar um público que se encaixe de alguma forma nas seguintes premissas:
• O “aluno” está interessado no desenvolvimento de aplicativos para a internet
(isto é, programas que possam ser utilizados a partir de browsers) e disposto a
aprender conceitos e mecanismos úteis para melhorar a qualidade de tais
aplicativos;
• O “aluno” já estudou conceitos de concorrência (por exemplo, no contexto de
sistemas operacionais) e gostaria de aplicá-los no desenvolvimento de
aplicativos gerais;
• O “aluno” está interessado em entender o que há por trás do pacote de Threads
fornecido pela linguagem de programação Java, ou qual é seu propósito geral;
• “aluno” gosta de desafios, e está disposto a lidar com programas onde a
dificuldade de depuração e análise é bem maior do que a usual.
Além do conteúdo idealizado para o público alvo acima descrito, foi incluído no
texto material introdutório sobre modelagem de sistemas. Esta parte é apresentada
de forma relativamente independente do restante do material, e foi incluída para
exemplificar um caminho que sirva de fundação para uma abordagem disciplinada
e metódica de programação concorrente.
Este mini-curso assume que o aluno tem uma maturidade em programação no nível
de segundo anistas de um curso de graduação em computação, e que ele se sente
confortável com a utilização de estruturas de dados básicas como pilha, fila e listas
ligadas. Na quase totalidade dos exemplos é utilizada a linguagem Java; como não
é assumido que o aluno domine esta linguagem, os programas são documentados
pesadamente, de forma a facilitar a compreensão. Todos os exemplos estão
disponíveis na home page deste texto (http://www.ime.usp.br/~dilma/jai99/). Neste
sítio1 você também encontrará atualizações do texto e um endereço para envio de
correções, sugestões ou dúvidas. Mais importante: os exemplos lá apresentados são
mais gerais e ligados à programação concorrente para internet de que os aqui
desenvolvidos, em função do espaço que ocuparia a inclusão, com documentação
detalhada, de programas completos cuja funcionalidade fosse um pouco mais
interessante.
O restante desta seção define informalmente a noção de concorrência e descreve o
tipo de aplicação que motiva o restante do nosso texto A seção 2 apresenta os
conceitos básicos e definições da área. Na seção 3 abordamos os aspectos práticos
da programação concorrente, focalizando no suporte oferecido pela linguagem
Java. A seção 4 lida, superficialmente, com os problemas de corretude e
desempenho. A seção 5 apresenta aspectos sobre applets, concorrência e
distribuição, mas as aplicações são bem simples (exemplos mais interessantes
estão disponíveis na página deste texto). A última seção do texto descreve
brevemente aspectos de modelagem de sistemas concorrentes.
1.1. O que é concorrência ?
Sistemas concorrentes devem lidar com atividades separadas que estão
progredindo ao mesmo tempo. Informalmente, dizemos que duas atividades são
concorrentes se em algum momento ambas tenham sido iniciadas mas não
terminadas, ou seja, estão em algum ponto intermediário da execução.
Programação concorrente é a atividade de construir um programa contendo
múltiplas atividades que progridem em paralelo. Atividades podem progredir em
paralelo de duas maneiras:(1) sendo realmente executadas em paralelo, cada uma
de posse de um processador diferente ou (2) tendo o processador se revezando
entre as atividades, de forma que cada atividade possa fazer uso do processador
 
1 Tradução para “site”.
durante um período de tempo e depois espere sua próxima chance de avançar na
computação.
A programação concorrente lida com atividades que, embora executem
separadamente, estejam relacionadas de alguma forma. Em alguns casos, as
atividades estão relacionadas por terem um objetivo comum, por estarem
cooperando para solucionar um problema. Em outros casos, a relação entre as
atividades é mais frouxa, estas podendo ser totalmente independentes em seus
propósitos, mas precisando compartilhar os recursos pelos quais competem por
estarem executando em um ambiente comum.
1.2. Por que concorrência é relevante ?
Em muitos cenários, concorrência não pode ser evitada porque é uma parte
inerente do sistema a ser desenvolvido, pois o sistema manuseia estímulos que
podem ocorrer simultaneamente no mundo externo ao sistema computadorizado.
Por exemplo:
• vários clientes de um banco podem solicitar transações bancárias ao mesmo
tempo;
•vários visitantes de uma livraria virtual (isto é, visitantes de sua página na
internet) podem ao mesmo tempo solicitar buscas de informação ou compras de
livros;
• um sistema de monitoramento de segurança de um prédio deve lidar ao mesmo
tempo com as várias imagens e demais informações sendo coletadas.
Mas os requisitos de concorrência não aparecem apenas nas aplicações que lidam
com estímulos externos inerentemente concorrentes. Com o avanço da tecnologia
das últimas duas décadas, é cada vez mais viável a implementação de sistemas de
forma distribuída sobre uma rede de computadores. Implementações distribuídas,
por oferecerem em geral várias portas de entrada para o uso do sistema, são
obrigadas a lidar com a concorrência das atividades que executam simultaneamente
nos diferentes nós da rede. Algumas vezes as razões que levam à opção por uma
implementação distribuída ao invés de centralizada servem como argumentos para
que se empregue concorrência mesmo em uma implementação centralizada do
sistema.
Outra motivação para o uso de concorrência é a busca por soluções que permitam
que várias partes de um mesmo problema sejam atacadas em paralelo. Com este
enfoque, se a plataforma de execução dispor de muitos processadores (o que já é
possível com estações de trabalho de custo relativamente baixo), o problema
poderá ser resolvido em menos tempo, ou pelo menos soluções parciais úteis ao
usuário poderão ser rapidamente fornecidas, e incrementalmente refinadas. A
divisão de uma tarefa a ser executada em várias atividades concorrentes que
colaboram para atingir o resultado esperado introduz muitas dificuldades
algoritmicas e de implementação. As dificuldades básicas são abordadas com
detalhe neste texto (Seções 2.1 e 2.2).
Para algumas novas categorias de aplicações, que se tornam cada vez mais
populares, os aspectos de concorrência se tornaram particularmente relevantes:
• aplicações multímidia. O desenvolvimento de aplicações que manuseiam som
e imagem de várias fontes vem sendo muito explorada. Aplicações como vídeo-
fone, vídeo-mail e vídeo-conferência operam com requisitos de tempo real, isto
é, uma solução deve não só obter resultados computacionais corretos, como
também respeitar restrições temporais (chegar aos resultados dentro dos limites
impostos pela aplicação). Sistemas de tempo real podem ser de dois tipos: (1)
hard, em que o não atendimento a uma restrição temporal significa falha total
do sistema ou (2) soft, em que se procura respeitar as restrições temporais, mas
a falha em uma ou outra restrição de tempo ocasional não tem conseqüências
sérias, e portanto não constitui uma falha completa do sistema. A área de tempo
real vem sendo estudada há muito tempo, e nela é essencial que o
gerenciamento de tarefas simultâneas seja feito de forma eficiente, escolhendo
as tarefas sempre de forma a garantir que limites de conclusão das várias tarefas
pendentes sejam respeitados. A popularização do desenvolvimento de
aplicações multimídia implica na necessidade de ambientes computacionais
eficientes e flexíveis no gerenciamento de concorrência, de forma que o
desenvolvedor da aplicação multimídia ganhe “de graça” o controle das
restrições temporais de sua aplicação. Muitos trabalhos recentes investigam a
construção de tais ambientes computacionais [Smart 1997, Eclipse 1998, Rosu
1997], mas o problema está longe de ser resolvido.
• interfaces baseadas em janelas. Aplicações com interface via janelas gráficas
tornam a concorrência ainda mais evidente para o usuário, já que estas
oferecem vários serviços ao usuário via componentes como botões e menus que
podem ser acionados quase que “ao mesmo tempo”, causando a execução
simultânea dos serviços solicitados. Por exemplo, um usuário que utilize uma
interface gráfica para receber e enviar e-mails pode solicitar leitura de novos e-
mails a qualquer momento, inclusive enquanto utilize a interface para
compor/enviar novos e-mails ou ler e-mails antigos. A implementação deste
aplicativo de e-mail deve ter sido projetada de forma a lidar com as tarefas de
envio e recebimento de e-mail executando simultaneamente e competindo por
recursos (por exemplo, pelo repositório de mensagens recebidas).
• servidores de informação na Teia2. Cada usuário da Teia pode operar
sequencialmente, isto é, visitando uma página de cada vez. Mas o servidor
(programa que gerencia o envio de páginas solicitadas) poderá receber vários
pedidos concorrentes de páginas. Conforme o uso da Teia vá aumentando,
veremos cada vez mais grandes bancos de dados acessíveis via browsers,
aumentando o potencial para acesso concorrente dos mesmos. Mais
recentemente, vem aumentando a presença de animação nas páginas da Teia;
processos concorrentes poderiam ser utilizados do lado do cliente de forma a
agilizar a animação e permitir que o usuário interaja com a página
(preenchendo formulários, por exemplo) enquanto a animação progride.
• middleware. Um enfoque amplamente utilizado para lidar com
heterogeneidade em sistemas distribuídos é construir uma camada de software
acima dos sistemas operacionais heterogêneos de forma a apresentar uma
plataforma uniforme sobre as quais aplicações distribuídas possam ser
executadas. Esta camada – o middleware – em geral é responsável por
gerenciar a execução concorrente dos vários componentes do sistema
distribuído.
1.3. Aplicação Motivadora
Como já mencionado, existem vários tipos de aplicações que nos levam a lidar com
concorrência. Neste mini-curso o objetivo é lidar com um tipo tão corriqueiro e
desvinculado de comunidades específicas quanto possível. Em outras palavras,
nosso objetivo é aprender sobre concorrência a fim de desenvolver pequenos
programas que possam ser executados a partir de browsers na internet, e que
mantenham algum estado simples, do tipo que pode ser facilmente manipulado
com alguns poucos arquivos, não necessitando do controle de um gerenciador de
banco de dados. Mais especificamente, queremos desenvolver applets que lidem
com a possibilidade de vários usuários utilizarem tais programas ao mesmo tempo.
No contexto deste curso, foram desenvolvidos dois pequenos programas que
envolvem os principais aspectos de programação concorrente (de pequena escala,
ou seja, em que não estejamos instalando um banco de dados e integrando-o com
nosso applet) para a internet. Os exemplos e suas documentações se encontram
disponíveis em http://www.ime.usp.br/~dilma/jai99.
O primeiro programa tem como objetivo dar apoio a uma brincadeira usual da
época de festividades de fim de ano: o “amigo secreto”. Este programa permite que
sejam definidos os partipantes. A partir daí, o programa sorteia quem é amigo
secreto de quem, e torna disponível a troca de bilhetes entre os participantes.
Também é possível cadastrar pseudônimos para as trocas de bilhetes. Os bilhetes
podem ser lidos através do página da internet de acesso ao programa, ou o usuário
 
2 World Wide Web.
pode especificar que deseja ter seus bilhetes automaticamente enviados para seu e-
mail. Bilhetes podem ser enviados para participantes ou para pseudônimos
cadastrados, sendo analogamente assinados por participantes ou por pseudônimos.
O programa lida com aspectos de segurança, isto é, não torna disponível o
repositório de informações sobre o amigo secreto.
O segundo programa ajuda na organização de uma festa, permitindo que as pessoas
confirmem sua presença ou ausência, verifiquem quem já confirmou a ida a festa, e
especifique qual será sua contribuição para a festa (que bebida ou comida, de uma
lista disponibilizada pelo promotor da festa).
2. Conceitos Básicos e Definições
Os elementos ativos que constituem uma aplicação em execução são referenciados
na literatura em computação por vários termos: atividades, processos, tarefas ou
threads. Estes termos podem assumir diferentes significados dependendo do
contexto ou da tecnologia em questão. Adota-se o termo clássico processo para
denotar uma entidade abstrata que executa um programa(seqüência de instruções3)
em um processador [Bacon 1998]. Historicamente, a abstração de processo captura
a execução dinâmica em um computador, com as operações executadas em um
processo sendo feitas estritamente uma por vez. Hoje esta visão já não faz sentido,
pois é comum que um mesmo programa ou aplicativo venha a requerer a existência
de várias atividades que progridam ao mesmo tempo. Usaremos o termo processo
para designar a ativação inicial de um programa. Se este programa é composto por
várias atividades separadas, chamaremos estas atividades de threads (linhas de
execução que compõe o processo). Uma outra forma de denotar esta composição é
chamar as várias atividades a serem executadas de processos, e o conjunto das
atividades de programa. A escolha da nomenclatura depende bastante do contexto.
Por exemplo, no UNIX originariamente a unidade de execução era o processo,
estando disponível na interface para o programador primitivas para a criação e
remoção de processos; assim aplicativos que empregavam concorrência eram
vistos como programas compostos por vários processos. Mais recentemente as
diversas implementações do UNIX disponibilizam também pacotes de threads 
também conhecidos como “processos leves” (lightweight processes), por
implicarem em custos de criação e manutenção menores  , isto é, interfaces
através das quais threads podem ser criados e adicionados/removidos de um
processo. Neste texto nos referiremos às atividades concorrentes que compõem o
aplicativo como threads, já que este é o termo dos ambientes de programação
concorrentes utilizados nos exemplos.
 
3 Mais formalmente, a descrição de uma computação em uma linguagem formal
[Hansen 1973].
Threads (ou processos em programas concorrentes em geral) devem colaborar
entre si por dois motivos: (1) para que a funcionalidade esperada do programa seja
atingida e (2) para que eles possam se coordenar quanto ao uso de recursos
compartilhados. Esta colaboração é feita através de comunicação. Por exemplo, se
uma aplicação de busca de um usuário chamado “Jassei Java” em uma dada rede
de computadores é composta por várias threads, em que cada thread busca o
usuário em um dado subdomínio da rede, se um dos threads encontra o usuário ele
deve comunicar este fato para os outros threads, de forma que estes cessem as suas
buscas. Se a busca é pelo número de usuários com o sobrenome “Java”, as
quantidades encontradas pelos vários threads devem ser somadas para formar o
resultado final da aplicação. Por outro lado, se a aplicação exige que toda vez que
um usuário com sobrenome “Java” seja encontrado lhe seja enviado um FAX
oferecendo um curso de Java de graça, vários threads podem requerer
simultaneamente o uso da placa de FAX, e devem colaborar para que todos
consigam acessoao recurso compartilhado.
Comunicação se dá através do uso de variáveis compartilhadas ou do envio de
mensagens. Para que a comunicação ocorra, é necessário que os envolvidos
estejam prontos em seus postos: um thread pronto para dar a comunicação, outro(s)
para receber. Em outras palavras, comunicação exige sincronização. Duas formas
de sincronização ocorrem em programas concorrentes [Andrews 1991]: exclusão
mútua e sincronização condicional.
Exclusão mútua tem como objetivo sincronizar threads de forma a garantir que
determinadas partes do código sejam executadas por no máximo um thread de cada
vez, servindo assim para evitar que recursos compartilhados sejam acessados
simultaneamente por vários threads. O desenvolvedor do aplicativo deve identificar
quais são as partes do código que exigem este tipo de sincronização (isto é, quais
são as regiões críticas do programa) e utilizar alguma primitiva de comunicação
entre threads disponível em seu ambiente para delinear o início e o fim da região
crítica onde se deseja exclusão mútua.
Sincronização condicional permite que o programador faça com que um thread
espere uma dada condição ser verdadeira antes de continuar sua execução. Com
esta forma de controle do andamento de um thread é possível orquestrar o
andamento da computação, por exemplo garantindo que um thread espere pela
informação computada por um outro thread antes de progredir com sua
computação. O desafio para o desenvolvedor está em identificar onde colocar
sincronizações condicionais de forma a dirigir corretamente o andamento coletivo
dos threads, e também escolher as condições lógicas adequadas a cada
sincronização condicional.
Na seção 4 abordaremas as primitivas disponíveis para criação e sincronização de
threads em Java e em C/UNIX, e como programar com elas. A fim de esclarecer
como o suporte para threads nestes ambientes funciona, estudaremos como
algumas primitivas de sincronização “baixo nível” funcionam e foram
implementadas. O objetivo não é cobrir detalhes, e sim evidenciar as dificuldades
encontradas em implementar e utilizar tais suportes para sincronização.
2.1 Suporte para Sincronização Condicional: Wait e Signal
Conforme já discutido, threads que cooperam precisam sincronizar uns com os
outros. Para isso eles precisam ser capazes de esperar (WAIT) um pelos outros e
avisar (SIGNAL) uns aos outros que já é possível prosseguir, ou seja, que o
encontro de sincronização terminou. Os threads que competem por recursos devem
conseguir esperar (WAIT) para adquirem o recurso compartilhado e avisar
(SIGNAL) da liberação de recursos.
Muitos sistemas operacionais oferecem primitivas que permitem especificar espera
por um evento e aviso da existência (ou criação) de um evento:
• WAIT (e): a execução aguarda até que seja avisada da ocorrência do evento e
• SIGNAL(e): sinaliza que o evento e foi gerado.
Por exemplo, ao especificar a espera por uma interrupção de hardware, indica-se
uma sincronização com um hardware. Já com interrupções de software, pode-se
especificar sincronização entre programas ou atividades de um programa.
Estes mecanismos podem ser generalizados de forma a cobrir os casos em que se
espera qualquer evento oriundo de uma dada atividade, ou um evento específico
oriundo de qualquer atividade. As duas primitivas são úteis para expressar
sincronização condicional, mas dependendo da forma com que são implementadas,
seu uso pode se tornar inviável, como exemplificamos a seguir.
Considere uma aplicação formada por duas atividades: (1) um thread que acha
números primos, isto é, consecutivamente procura o próximo inteiro que seja
primo e (2) um thread que notifica por e-mail o chefe do laboratório de pesquisa
em primos quando um novo primo é encontrado. Os dois threads precisam
sincronizar, isto é, o que busca primos deve notificar que um primo foi encontrado,
e o thread que envia e-mail deve aguardar por tal notificação (esperar até que uma
condição, o encontro de um número primo, seja verdadeira).
Thread Procura Thread Informa
while ( ) { // executa para sempre while ( ) { // executa para sempre
// busca próximo primo WAIT(Procura);
acha_próximo_primo(); envia_email_para_chefe();
// avisa ao outro Thread }
SIGNAL (Informa);
}
A aplicação principal simplesmente criaria os dois threads. Suponha que o thread
Procura foi criado e iniciado antes do thread Informa, e que no momento em que
Procura executa “SIGNAL (Informa)”, o thread Informa ainda não executou
WAIT(Procura). Dependendo da implementação (e este é o caso em muitas das
implementações disponíveis em ambientes Unix), o sinal enviado por Procura para
Informa é perdido (neste exemplo, consequentemente o primeiro primo encontrado
não causaria o envio de um e-mail). Outra situação análoga surge quando o
SIGNAL de Procura faz com que Informa continue sua execução e execute
envia_email_para_chefe(), mas enquanto esta rotina é processada por Informa
vários outros primos são encontrados em Procura, com os respectivos SIGNAL
(Informa) sendo perdidos, já que Informa ainda não está atingiu novamente
WAIT(Procura). Estes problemas podem ser evitados se a implementação de
WAIT/SIGNAL garantir que os sinais que cheguemadiantados ao comando WAIT
sejam acumulados e entregues para a aplicação que os espera, mas isto tornaria as
primitivas mais custosas (trabalho extra de gerenciar que sinais já foram tratados e
quais devem ser entregues no próximo WAIT).
2.2. Suporte para Exclusão Mútua
A sincronização via exclusão mútua tem como objetivo garantir que regiões
críticas do código (por exemplo, acesso a recursos compartilhados) sejam
executadas “sequencialmente”, isto é, no máximo um thread deve estar ativo na
região crítica por vez. Como a colaboração entre threads muitas vezes é feita
através do acesso à memória compartilhada, é particularmente importante que as
partes de código que manipulem variáveis compartilhadas sejam protegidas da
interferência entre threads. Arquiteturas convencionais permitem que assumamos
que:
• A operação de leitura de uma posição de memória é indivisível (ou atômica),
isto é, ela não pode ser interrompida no meio com o processador passando a
atender outro thread;
• A operação de escrita em uma posição de memória também é atômica.
Se dois threads estão executando concorrentemente, potencialmente qualquer
entrelaçamento das instruções dos dois threads pode ocorrer. Por exemplo,
considere os threads SomaUm e SomaDois com os seguintes códigos:
SomaUm SomaDois
x = x + 1; x = x + 2;
Cada um desses threads têm um único comando, mas três instruções : (1) LD x (lê
x da memória), (2) adiciona constante, e (3) e ST x (escreve o novo valor de x na
memória). As duas execuções a seguir de um programa composto por estes dois
threads são válidas. A descrição das execuções é apresentada como uma progressão
no tempo que indica a ordem em que as instruções foram executadas. A fim de
tornar explícito a quem pertence a instrução executada, a mesma é escrita na
coluna correspondente ao seu thread.
Histórico da Execução 1:
SomaUm SomaDois
LD x
Incrementa em 1
ST x
LD x
Incrementa em 2
ST x
Histórico da Execução 2:
SomaUm SomaDois
LD x
Incrementa em 1
LD x
Incrementa em 2
ST x
ST x
O primeiro histórico corresponde a execução sequencial (sem atividades
concorrentes) de SomaUm seguida da execução sequencial de SomaDois. O
segundo histórico corresponde a uma execução concorrente dos dois threads em
que SomaUm começa a execução e é interrompido após duas instruções.
Assumindo que a variável x como valor inicial zero, ao final da execução 1 ela
armazena o valor 3, enquanto que ao final da execução 2 x tem o valor 2. Em
outras palavras, o programa formado pelos dois threads não tem um resultado
determinístico, já que diferentes escalonamentos dos threads (isto é, diferentes
escolhas sobre qual thread deve progredir em sua execução) resultam em valores
finais diferentes. O mesmo problema ocorre se o programa for executado em uma
plataforma com vários multiprocessadores, pois como os processadores podem ter
diferentes velocidades de execução ou de acesso à memória, não se sabe a priori a
ordem relativa entre as instruções dos dois threads. Este indeterminismo pode ser
evitado se o acesso à variável compartilhada x for feito com exclusão mútua, isto é,
se forem impossibilitadas execuções em que mais de um thread esteja executando
alguma parte do acesso ao recurso compartilhado (isto é, estiver na região crítica).
A região crítica do exemplo acima é bem curta (um comando em linguagem de alto
nível apenas), mas se o recurso compartilhado for uma estrutura de dados
complexa (como uma B-árvore ou grafo dirigido), a seção do código que manipula
a estrutura pode ser longa e de execução demorada.
O problema a ser resolvido é como fornecer a um thread acesso exclusivo a uma
estrutura de dados para um número arbitrário de leituras e gravações. As subseções
a seguir discutem soluções clássicas para o problema. Observe que além de
garantir o acesso exclusivo, é desejável que soluções para o problema tenham as
seguintes características:
• Se dois ou mais threads estão tentando obter acesso para a estrutura de dados,
então pelo menos um deles vai ter sucesso. Em outras palavras, a execução do
programa não fica “congelada”: não ocorre deadlock4.
• Se um thread T está tentando obter acesso exclusivo a um recurso ( como uma
estrutura de dados, por exemplo) e os outros threads estão ocupados com
tarefas que não se relacionam com o recurso compartilhado, então nada impede
que o thread T obtenha o acesso exclusivo. Isto fica mais claro se pensamos em
uma solução em que esta característica não é atingida: suponha o esquema de
acesso a uma placa de FAX compartilhada por dois threads em que o acesso
exclusivo é obtido com a seguinte regra: durante os 30 primeiros minutos de
 
4 Dizemos que um conjunto de threads está em deadlock se todos os threads do
conjunto estão aguardando eventos que só podem ser gerados por threads
pertencentes ao conjunto. Em outras palavras, eles estão bloqueados esperando por
algo que não pode ser fornecido por elementos externos ao conjunto.
qualquer hora só o thread 1 pode usar a placa, e nos últimos 30 minutos só o
thread 2 tem acesso a este recurso compartilhado. Com este esquema, temos
que se o thread 2 quer acesso às 14:10, ele terá que esperar (pelo menos 20
minutos) mesmo se o thread 1 não estiver utilizando o recurso.
• Se um thread está tentando conseguir acesso ao recurso, alguma hora ele
consegue.
2.2.1. Com suporte do hardware:Test-and-Set
Os vários threads competindo por uma região crítica (isto é, pelo acesso a um
recurso compartilhado) podem adotar a convenção de que sempre antes de entrar
na região crítica eles testam o valor de uma variável que indica se a região está
livre ou ocupada. No exemplo da aplicação formada pelos threads SomaUm e
SomaDois, o desenvolvedor faria:
SomaUm SomaDois
Se (flag == 0) { Se (flag == 0) {
flag = 1; flag = 1;
x = x + 1; x = x + 2;
flag = 0; flag = 0;
} }
O código acima obviamente continua com problema, uma vez que o teste do valor
da variável flag e sua mudança para 1 não são atômicas. Em uma plataforma com
vários processadores, existe uma chance de que os dois threads fossem executados
exatamente ao mesmo tempo, e portanto ambos achassem que a região crítica
estava livre. Mesmo em processadores com um único processador, o problema
continua: é possível que o primeiro thread carregue da memória o valor de flag,
compare com 0 e seja interrompido exatamente antes de ter a chance de escrever 1
na variável (com isso indicando que a região crítica está ocupada). Assim, o
segundo thread ao carregar o valor de flag também obteria 0, e teríamos os dois
threads ativos na região crítica.
Muitos computadores têm alguma instrução que executa atomicamente leitura,
teste de valor e escrita. Isto é, a instrução executa em um único ciclo, a operação de
(1) verificar se a região crítica está livre e (2) marcá-la como ocupada se este é o
caso não pode ser interrompida. Um exemplo de tal instrução é a Test-And-Set, que
tem tipicamente a seguinte forma:
se a booleana indica que a região está livre {
mude a booleana para indicar região ocupada;
pule a próxima instrução;
}
senão
execute a próxima instrução; // em geral uma instrução que desvia o
// fluxo para fazer o teste de novo
Outra forma de enxergar as implementações de Test-And-Set disponíveis nos
hardwares usuais é como uma instrução que dadas duas posições de memória,
atomicamente copia o conteúdo da segunda na primeira e coloca 1 na segunda. A
entrada na região crítica ficaria então:
valor_lido = 1;
flag = 0;
while (valor_lido == 1) {
Test-And-Set (valor_lido, flag); // atômico
if ( valor_lido == 0 ) { // se estava zero antes de eu pedir para colocar
< código da região crítica >
flag = 0;
}
}
Este tipo de solução, que envolve repetidamente testar se a região crítica ficou
livre, é chamada de espera ocupada (busy wait ou spin lock), já que enquanto o
thread espera para entrar na região crítica ele continua “gastando” a CPU com
comparações e chamadas do Test-And-Set. Alternativamente, o threadpoderia ser
bloqueado, isto é, sair da CPU por algum tempo, voltando para tentar novamente
mais tarde (preferencialmente, nos momentos em que a região crítica foi
desocupada e portanto há chance de sucesso na tentativa de obter acesso).
Outras instruções de hardware (por exemplo, Compare-And-Swap atômico) tornam
possível o uso de uma variável para indicar se a região crítica está livre ou
ocupada. Observe, no entanto, que este tipo de solução exige que a variável faça
parte do espaço de endereçamento de todos os threads envolvidos, isto é, que as
atividades concorrentes possam compartilhar posições de memória. Em ambientes
com vários processadores, o compartilhamento de uma variável como o que foi
feito com o Test-And-Set no exemplo acima pode causar tremendos prejuízos de
desempenho, pois a cada vez que se escreve 1 em flag (o que é feito toda fez que
Test-And-Set é executada, estando a região crítica livre ou não) as entradas dos
caches correspondentes à variável em cada processador podem ter que ser
invalidadas.
Outra deficiência de uma solução baseada em Test-And-Set é a questão de justiça:
nada impede que um thread consiga entrar sucessivamente na região crítica,
enquanto outro persiste na tentativa sem ter sucesso.
Em ambientes com apenas um processador, uma técnica comum para proibir que a
execução seja interrompida é simplesmente desabilitar as interrupções durante a
execução da região crítica, tornando impossível que outro thread tenha a chance de
executar5. Esta solução se torna inaceitável se a região crítica for longa, pois
interrupções importantes (como, por exemplo, as relacionadas à entrada e saída)
poderiam ser perdidas.
2.2.2. Sem suporte de hardware
O problema de obter exclusão mútua sem usar instruções específicas de hardware e
sem apelar para desabilitação de interrupções (já que isto não resolve o problema
em máquinas com vários processadores) recebeu a atenção de cientistas
importantes da área de computação e matemática. O problema começou a ser
discutido por Dijkstra em 1965 e o matemático Dekker publicou a primeira solução
correta para lidar com a disputa por dois threads. Dijkstra então generalizou a
solução para funcionar para um número arbitrários de threads que competem pela
região crítica. Por algum tempo o problema foi considerado de pouco interesse
prático, já que em máquinas com um único processador o enfoque descrito na
seção 2.2.1 pode funcionar bem. Mas com o tempo o interesse na construção de
máquinas com vários processadores a partir da conexão de computadores com
memória e processador padrões trouxe novamente a atenção para o problema,
tentando-se obter soluções alternativas práticas para obter exclusão mútua através
de memória compartilhada. Este interesse resultou em muitas soluções alternativas
e versões cada vez mais eficientes sendo propostas nas décadas de 70 e 80.
Os algoritmos que solucionam o problema da exclusão mútua não são simples de
entender. Uma descrição detalhada e bem formalizada de soluções presentes na
literatura pode ser encontrada em [Andrews 1991]. Nesta seção os algoritmos são
discutidos superficialmente; suas implementações em Java estão disponíveis, de
forma detalhada e discutida, em http://www.ime.usp.br/~dilma/jai99/.
O algoritmo de “senha” (Ticket algorithm) se baseia no procedimento adotado em
grandes açougues, confeitarias ou centrais de atendimento a fim de garantir que os
clientes sejam atendidos na ordem de chegada sem que se forme uma fila física. Há
um dispositivo que libera cartões numerados sequenciamente (“senhas”). O cliente
ao chegar se dirige ao dispositivo e retira um número, e fica esperando até que seu
 
5 Pois a troca de threads é baseada no uso de interrupções.
número seja chamado. Se pensamos nesta solução em um cenário em que apenas
um atendente está disponível, vemos os clientes competindo pelo atendente da
mesma forma em que threads competem pelo acesso à região crítica. Uma
implementação dessa idéia pode ser feita utilizando-se duas variáveis
compartilhadas: número e próximo, ambas tendo 1 como valor inicial. Cada thread
então teria a seguinte estrutura algoritmica:
int minha_vez; // variável interna ao thread,
// não compartilhada.
minha_vez = número; número ++; // ATÔMICO !
while (próximo != minha_vez) ; // espera ocupada
executa_região_crítica(); // aqui está o código a ser protegido
// de execução simultânea
próximo++; // ATÔMICO: passa posse da região
// crítica pro seguinte
Os números distribuídos aos threads que desejam entrar na região crítica não se
repetem, e se a obtenção de um número por um thread e a geração do número
seguinte forem feitas de forma indivisível, então há garantia de que no máximo um
thread por vez tem acesso ao código executa_região_crítica(). Cada cliente (cada
thread) é atendido assim que chega a sua vez e há garantia de que todo thread
pleiteando acesso vai conseguir alguma hora. Mas a solução tem um problema: as
variáveis próximo e número crescem ilimitadamente; na prática se o programa
rodar por muito tempo, as variáveis atingiriam o limite máximo, em muitas
arquiteturas voltando para zero. Isto seria um problema se o número de threads
esperando pela sua vez excedesse ao maior inteiro armazenável, pois dois threads
estariam esperando com o mesmo número de atendimento.
Outra questão importante é que esta solução exige que alguns trechos (delimitados
por um retângulo no código acima) sejam executados atomicamente, podendo ser
implementados em máquinas que tenham uma instrução atômica de Fetch-And-
Add6 ou através da desabilitação temporária de interrupções nestes pequenos
trechos de código.
O algoritmo da Padaria (Bakery Algorithm), proposto por Lamport em 1974,
aproveita a idéia da distribuição de senhas, com a vantagem de não exigir um
gerador atômico do próximo número e nem a manutenção de uma variável
 
6 Fetch-And-Add carrega o valor de uma variável e imediatamente incrementa-a.
próximo. O algoritmo é mais complicado, mas ilustra uma idéia bem útil: quebrar
empates quando dois threads estão com senhas com o mesmo número. A idéia do
algoritmo também vem do mundo real, como no caso do algoritmo com
distribuição de senhas. Quando a gente vai a uma padaria ou açougue mais
simples, em geral não encontramos um dispositivo para a gente pegar um número
de atendimento. Simplesmente a gente olha pros lados, observa quem está
esperando para ser atendido, e se situa sobre quando seremos atendidos (só quando
os que já estavam lá tiverem sido atendidos). Em outras palavras, o thread escolhe
para si um número que seja maior do que qualquer outro número que esteja em
posse de algum thread esperando pelo acesso, e o thread sabe que está na hora de
executar quando o seu número for o menor entre todos que estejam esperando. O
seguinte pseudo-código descreve o que cada thread faz para entrar na região
crítica, utilizando-se uma vetor de inteiros vez[], onde vez[t] armazena 0 se o
thread t não está interessado em entrar na região crítica ou armazena um número >
0 que representa seu número de atendimento.
// início da tentativa de entrar na região crítica
// t é um identificador número do thread
vez[t] = máximo(vez) + 1; //atribuição de número de atendimento
enquanto (vez[t] > mínimo_não_nulo(vez)) ; // espera pela sua vez
executa_região_crítica();
vez[t] = 0; // não está mais interessado na região crítica
Obviamente, se as ações identificadas por retângulos acima não forem executadas
com exclusividade pelo thread, mais de um thread pode acabar escolhendo o
mesmo número de atendimento. O algoritmo permite que isto aconteça, ou seja,
que mais de um thread receba um mesmo número de atendimento, mas impõe um
desempate entre os que têm o mesmo número, de forma que apenas um deles acabe
entrando na região crítica.O desempate se dá através do número identificador de
cada thread. Ou seja, ao invés de comparar vez[a] > vez[b], comparamos (vez[a],
a) > (vez[b],b) onde esta expressão é verdadeira se vez[a] > vez[b] ou (vez[a] =
vez[b] e a> b). O algoritmo fica:
// início da tentativa de entrar na região crítica
// t é um identificador número do thread
vez[t] = máximo(vez) + 1; // atribuição de número de atendimento
// OK se números repetidos forem fornecidos
para todo thread j que não seja este thread t {
enquanto (vez[t] ≠ 0 e (vez[t], t) > (vez[j], j) ; // espera pela sua vez
executa_região_crítica();
vez[t] = 0; // não está mais interessado na região crítica
Para perceber porque o algoritmo funciona, isto é, porque não existe uma situação
em que dois threads executam ao mesmo tempo executa_região_crítica() é preciso
entender como funciona o critério de desempate contido no retângulo pontilhado
acima. A forma de desempate entre vários threads que por acaso receberam o
mesmo número de atendimento se baseia no algoritmo de Petersen (também
conhecido como Tie-Breaker), que também é um algoritmo que pode ser usado
para o problema da exclusão mútua (já que se quer desempatar entre vários threads
que desejam acesso à região crítica). A idéia é um desempate em fases: se existem
n threads no programa, o algoritmo tem n fases. Em uma primeira fase, n threads
executam vez[t] ≠ 0 e (vez[t], t) > (vez[j], j), mas mesmo que todos estejam
empatados no número de atendimento vez[], um deles perde para todos os outros7,
resultando em que no máximo n-1 threads passam para a segunda fase do
algoritmo de desempate. Nesta segunda fase, o mesmo código “enquanto (vez[t] ≠
0 e (vez[t], t) > (vez[j], j)” é executado, e pelo menos um dos n-1 threads fica
preso no loop, resultando em no máximo n-2 threads passando da fase 2 para a fase
3. O mesmo raciocínio se aplica nas outras fases, e temos que na fase n-1 no
máximo (n – (n-1) = 1 thread consegue sair do trecho de desempate e entrar na
região crítica.
De uma forma geral, as idéias das soluções para o problema da exclusão mútua não
fazem parte do arsenal diário de um desenvolvedor de programas concorrentes, já
que este se utiliza das primitivas já disponíveis em seu ambiente a fim de proteger
recursos compartilhados (regiões críticas) do acesso concorrente. Mas o
entendimento de como e porque realmente os algoritmos funcionam é bem útil na
formação do desenvolvedor, pois os argumentos exercitam as situações mais
comuns de erros na programação concorrente: uma troca de contexto em um
momento crucial, ou uma hipótese sobre o estado de uma variável compartilhada
 
7 Porque um deles tem o menor identicador de thread.
que não se mantém válida durante toda a execução. Outra razão para não
ignorarmos estes algoritmos é que em alguns casos uma forma mais relaxada (e
particular para a aplicação) de exclusão mútua é necessária, e dificilmente tal
solução ad hoc estará disponível no ambiente de programação. Soluções
particulares podem ser obtidas com pequenas alterações nos algoritmos clássicos.
2.3. Semáforos
O conceito de semáforos apareceu em 1968, no trabalho de Dijkstra, como um
mecanismo para sincronização de atividades concorrentes. Um semáforo é um tipo
de variável, ou seja, denota não só como uma determinada informação é
armazenada, mas também como esta informação pode ser manipulada, ou seja, que
operações estão disponíveis para uso da variável. Ao invés de termos, como nas
soluções para o problema de exclusão mútua já descritos, uma variável “normal”
sendo usada de forma complexa para expressar sincronismo, teríamos disponível
um tipo específico de variável útil apenas para lidar com sincronização. As
operações disponíveis para este tipo encapsulariam os detalhes complexos de como
o sincronismo é obtido, tornando o projeto de programas mais fácil de ser
entendido e de ser verificado como correto. O conceito de semáforo foi uma das
primeiras (e até hoje mais importantes) ferramentas para desenvolvimento
disciplinado de programas concorrentes. Outro aspecto importante dos semáforos é
que sua forma de obter sincronização não se baseia em espera ocupada (como é o
caso das soluções nas seções 2.2), e sim em permitir que o thread seja posto de
lado, parando de consumir recursos, até que chegue o momento dele sincronizar. O
tipo semáforo é em geral representado nas implementações através um número
inteiro e de uma fila que descreve quais threads estão aguardando para sincronizar.
O conceito de semáforo (e obviamete o próprio termo) surgiu da utilização de
semáforos no mundo real a fim de evitar colisões de trens. Um semáforo na linha
ferroviária é um sinalizador que indica se o trilho a frente está disponível ou
ocupado por um outro trem. Nos semáforos de nossas cidades a idéia é semelhante:
há um indicador de que a parte do cruzamento está disponível para o usuário ou
ocupada por ter sido cedida a usuários do cruzamento que vêm de outra direção.
Tanto semáforos de linhas ferroviárias quanto de caminhos viários tem como
objetivo coordenar o acesso a um recurso compartilhando, permitindo também
sincronização condicional (um trem fica parado até que a condição de linha
ocupada mude).
O tipo semáforo oferece os seguintes usos:
• Um semáforo pode ser criado recebendo um valor inicial, que indica o número
máximo de threads que o semáforo deve deixar “passar” em direção ao recurso
compartilhado. Por exemplo, quando inicializado com o valor 1 o semáforo
pode ser utilizado para prover acesso exclusivo a algum recurso compartilhado;
• Um semáforo pode ser utilizado através de sua operação wait, com o seguinte
significado: se o valor do inteiro armazenado no semáforo for maior que zero,
então este valor é decrementado e o thread que utilizou a operação pode
prosseguir normalmente em sua execução. Se o valor é zero, então o thread que
chamou a operação é suspenso e a informação de que este thread está
bloqueado neste semáforo é armazenada na estrutura de dados do próprio
semáforo.
• Um semáforo pode ser utilizado através da operação signal, que tem a
seguinte funcionalidade: se não há thread algum esperando no semáforo, então
incrementa o valor do inteiro armazenado no semáforo. Se há algum thread
bloqueado, então libera um thread, que terá sua execução continuando na
instrução seguinte ao wait que ocasionou a espera. É uma questão de
implementação se o thread liberado é o primeiro da fila ou não.
No trabalho original de Dijkstra, a operação wait era chamada de P, e a operação
signal de V.
O uso de semáforos para proteger uma região crítica de código pode então ser feita
através do uso da seguinte variável acessível a todos os threads competidores:
Semáforo lock = new Semáforo(1); // cria variável s do tipo Semáforo,
// inicializando o semáforo com 1
Cada thread usaria o semáforo da seguinte forma:
<comandos quaisquer que não façam uso de recursos compartilhados>;
lock.wait(); // o thread chama a operação wait do semáforo lock, ou
// seja, dependendo do inteiro armazenado em lock (se este
// é 0 ou não), o thread pode bloquear.
executa_região_crítica(); // se a execução chegou neste ponto, o semáforo
// indicava que a região crítica estava liberada
lock.signal(); // libera a região crítica. Se há outros threads bloqueados
// aguardando no semáforo, um deles volta a executar.
<comandos quaisquer que não façam uso de recursos compartilhados>;
A escolha do nome lock para o semáforo não foi por acaso. É muito comum se
referir às variáveis que protegem as regiões críticas como locks, já que funcionam
como cadeados protetores que impedem a entrada indesejada. Em alguns
ambientes, suporte para exclusão mútua é oferecido através de rotinas lock() e
unlock(), em que se obtem permissão de acesso e depois libera-se o acesso para
outro thread. Locks são um caso particular de semáforos, pois referem-se a
semáforos inicializados com o valor 1.
A implementação dos semáforos (isto é, das operações wait e signal) deve ser feita
de forma que a execução de uma dessas operações (onde se consulta e altera o
valor de um inteiro, e às vezes se manipula a fila de processosbloqueados) seja
atômica, já que um número arbitrário de operações podem ser requisitadas no
mesmo semáforo concorrentemente. Ou seja, a própria implementação dos
semáforos (que servem para proteger regiões críticas) envolve lidar com regiões
críticas, por exemplo através das soluções vistas na seção 2.2.
Textos tradicionais em sistemas operacionais contém muitos exemplos da solução
de problemas com o uso de semáforos[Tanenbaum 1992, Silberschatz-Peterson
1988]. Como os ambientes em que desenvolveremos nossos exemplos não incluem
diretamente suporte para semáforos, não nos aprofundaremos no assunto.
2.4. Monitores
O uso de semáforos permite diferenciar o que está sendo usado para controlar a
concorrência, mas como semáforos são globais a todas os threads que queiram
utilizá-los, seu uso a fim de proteger uma única estrutura de dados, por exemplo,
pode estar disperso por todo o programa. Para entender como a estrutura é acessada
ou verificar que ela está corretamente protegida de acesso concorrente, tem-se que
vasculhar o código de todos os threads. Além disso, não há associação sintática
entre semáforos e os códigos que estes protegem, portanto nada impede que o
programador utilize vários semáforos para proteger a mesma estrutura de dados,
tornando difícil entender que todos os vários trechos do programa são relacionados.
Analogamente, várias regiões críticas totalmente independentes podem ter sido
protegidas com o mesmo semáforo, potencialmente diminuindo
desnecessariamente o potencial para concorrência. Monitores, inicialmente
propostos por Hoare [Hoare, 1974] e Hansen [Hansen 1973a], são módulos de
programas que têm a estrutura de um tipo abstrato de dados. Estes módulos contêm
dados encapsulados (isto é, disponíveis apenas dentro da implementação do
módulo) que são manipulados pelas operações definidas no módulo. Há exclusão
mútua entre as operações, isto é, em nenhum momento duas operações poderão
estar sendo executadas ao mesmo tempo. A implementação dos monitores deve
assegurar a exclusão mútua de execução de operações. Um compilador poderia
implementar monitores associando a cada monitor um semáforo; a chamada de
uma operação implicaria em um wait() e o término de execução da operação um
signal().
Mas, como vimos desde o início desta seção, exclusão mútua não é suficiente para
programação concorrente, pois para muitos problemas a sincronização condicional
é essencial para que os threads colaborem. Para este fim, os monitores proveem um
novo tipo de variável: variável de condição. Estas variáveis podem ser usadas
pelo programador para expressar condições de interesse para os threads. Se um
thread quer, por exemplo, esperar até que uma determinada variável assuma o valor
0, ele poderia ficar continuamente monitorando o valor da variável até que sua
condição desejada seja verdadeira, mas este enfoque tem as desvantagens usuais de
uma espera ocupada. Se o thread tem disponível uma variável de condição cond, o
thread pode bloquear a si mesmo executando a operação cond.wait(), e
permanecerá assim bloqueado até que algum outro Thread execute cond.signal().
Assim, para esperar até que uma determinada variável assuma o valor 0, pode ser
criada uma variável de condição, por exemplo, espera_zero, e o thread começa a
esperar executando espera_zero.wait(). Quando outros threads alterarem o valor da
variável, eles devem colaborar com o thread bloqueado executando
espera_zero.signal(), que acordará o thread bloqueado, que pode então ver se a
variável ficou com 0 e até continuar esperando novamente se for o caso. Observe
que mesmo este exemplo simples já mostra como o uso de condições é suscetível a
erros: se algum thread falha em fazer a sua parte esquecendo de acionar signal(),
algum outro thread pode ficar bloqueado indefinidamente. As operações
wait/signal apresentam alguma semalhança com o que foi apresentado na seção
2.1, mas como as operações só podem ser utilizadas de dentro de um monitor,
sabemos que não seria possível termos vários wait/signal ocorrendo
simultaneamente, e portanto a implementação destas primitivas fica simplificada.
Mas ainda é possível ter a situação em que um signal() é executado sem haver um
correspondente wait() esperando-o, e neste caso o signal() não tem efeito algum.
Na seção 2.1 discutimos esta situação no contexto de um thread Procura,
responsável pela tarefa de, usando algoritmos matemáticos, encontrar primos, e o
thread Informa, que era responsável pelo envio de e-mails quando novos primos
eram encontrados, apontando as falhas na seguinte solução:
Thread Procura Thread Informa
while ( ) { // executa para sempre while ( ) { // executa para sempre
// busca próximo primo WAIT(Procura);
acha_próximo_primo(); envia_email_para_chefe();
// avisa ao outro Thread }
SIGNAL (Informa);
}
Utilizando monitores, o programa pode ser estruturado de forma a conter os dois
threads que realizam o trabalho, mas evidenciado que a colaboração entre os dois
threads deve ser sincronizada pelo encontro de novos primos. Uma forma possível
de estruturar este programa seria termos um monitor e dois threads, como descrito
a seguir:
Monitor Primos {
variável-de-condição primo_encontrado;
operação procura_próximo() {
acha_próximo_primo();
primo_encontrado.signal();
}
operação informa_próximo() {
primo_encontrado.wait();
envia_email_para_chefe();
}
} // fim do monitor
Thread Procura {
while (true) {
Primos.procura_próximo();
}
}
Thread Informa {
while (true) {
Primos.informa_próximo();
}
}
Com esta solução, nenhuma notificação deixa de ser enviada, mas observe que o
potencial de concorrência também foi diminuído: mesmo com dois processadores
disponíveis, não seria possível que o trabalho de envio de e-mail fosse feito ao
mesmo tempo que a busca por primos. Em geral, soluções com monitores colocam
dentro do monitor apenas a condição de sincronização, e não o acesso aos recursos,
a fim de permitir que controlemos acesso concorrente aos recursos compartilhados.
A implementação de variáveis de condição tem um problema potencial. Suponha
que o exemplo acima tivesse sido programado da seguinte forma:
Monitor PrimosComLoop {
variável-de-condição primo_encontrado;
operação procura() {
 while () {
acha_próximo_primo();
primo_encontrado.signal();
 }
}
operação informa() {
 while (){
primo_encontrado.wait();
envia_email_para_chefe();
 }
}
} // fim do monitor
O problema está na operação procura: após executar o signal, a operação ainda
está ativa, com mais coisas a serem executadas, e a operação informa também
estaria ativa por ter sido desbloqueada pelo signal. Mas, por definição, apenas uma
operação do monitor pode estar ativa a cada momento ! Uma solução é obrigar o
signal a ser a última operação da rotina. Na seção 3 discutiremos como o modelo
de concorrência de Java e o do Pthreads abordam a questão.
3. Programação com Threads
Já vimos que uma aplicação concorrente é composta por vários threads de
execução, e que um aspecto chave é a colaboração entre os vários threads. O uso
de threads requer que o programador lide explicitamente com o gerenciamento de
concorrência e sincronização. Como vimos na seção 2, muitos problemas podem
estar envolvidos se tivermos que começar do zero, nos apoiando em instruções de
hardware atômicas de forma a conseguir algum controle da sincronização.
Tradicionalmente estes problemas só interessavam os desenvolvedores de software
básico, mas, como argumentado na seção 1, a gama de aplicativos que exigem a
utilização de vários threads vem aumentando. Programar com threads é
considerado demasiadamento complexo por alguns [Ousterhout 1996], enquanto
outros acreditam que na maior parte dos cenários de aplicação, um uso disciplinado
de threads pode ser aprendido e empregado [Ruddock-Dasarathy 1996].
As duas formas mais populares hoje em dia de programação com threads são o uso
da linguagem Java (que é uma linguagem de programação concorrente!) e o uso
uma biblioteca de threads padrão, a pthreads.
Pthreads oferecem ao programador primitivaspara administração de threads
(criação, cancelamento, ajuste de prioridade, etc), para acesso exclusivo a regiões
críticas (“mutexes”) e variáveis de condição [Bacon 1998]. A definição completa
pode ser encontrada no padrão IEEE POSIX 1003.4a, e exemplo de implementação
são o Solaris Pthreads e DECthreads (Multi-threading Run-time Library para
OSF/1, da Digital). Uma visão geral do uso de bibliotecas de threads é fornecida
em [Kleiman 1995, Norton 1996, Butenhof 1997].
Como nossa intenção é criar programas concorrentes para aplicações acessíveis via
internet, apresentaremos aqui o modelo de threads da linguagem Java.
A linguagem Java, além de definir uma sintaxe e semântica para especificação de
programas, oferece uma boa gama de “pacotes” prontos, isto é, implementa
diversos tipos abstratos de dados muito úteis no desenvolvimento de programas.A
unidade fundamental de programação em Java é a classe [Arnold-Gosling 1996].
Classes fornecem a estrutura para os objetos (as variáveis interessantes do seu
programa, isto é, que não sejam de tipos primitivos como inteiro, real, etc.), a
forma de criar objetos a partir das definições das classes e a lista de métodos
(operações) disponíveis para manipular os objetos da classe. Do ponto de vista do
programador, a essência do suporte para concorrência da linguagem Java é
oferecida em duas classes: a classe java.lang.Thread e java.lang.Runnable. Estas
classes permitem que definamos threads para as nossas aplicações e gerenciemos
seus estados de execução e a colaboração entre os threads.
A classe java.lang.Runnable é na verdade uma interface, isto é, ela declara um tipo
que consiste apenas de métodos abstratos (não implementados) e constantes,
permitindo que o tipo possa receber diferentes implementações. A interface
java.lang.Runnable contém apenas um método abstrato, o método run(), que
representa o código a ser executado pelo thread. Qualquer classe que queira
implementar a interface Runnable deve fornecer o código para run(). Ao criarmos
um novo thread a partir de uma classe que implementa a interface Runnable,
quando o thread começar a trabalhar, será este método run que será acionado.
A classe java.lang.Thread representa um thread de execução, e oferece métodos
que permitem executar ou interromper um thread, agrupar threads, e especificar a
prioridade de execução do thread (isto é, que preferência se tem por este thread na
hora de escolher um próximo thread para ocupar a CPU).
3.1 Criação de Threads em Java
Essencialmente, a diferença entre a classe Thread e a interface Runnable é que o
Thread existe para representar como um thread de execução roda (sua prioridade,
seu nome, como ele pode sincronizar com outros threads), enquanto que Runnable
define o que o thread executa [Farley 1998]. Vejamos como criar, em ambos os
casos, um tipo de thread bem simples, que imprime “oi!” e termina, e um programa
composto por alguns desses threads.
Com a classe Thread, simplesmente utilizamos esta classe já existente alterando o
método run a fim de que ele execute o que desejamos, isto é, definimos uma nova
classeNossoThread como subclasse de Thread:
public class NossoThread extends Thread {
public void run () {
System.out.println(“ Oi!”);
}
Para criar o programa que usa este thread basta definir o programa principal (rotina
main) de forma que esta crie um objeto do tipo NossoThread, e inicie sua
execução, através do método start() (que simplesmente chama o método run):
public class UsaNossoThread {
public static void main ( String[] args) {
new NossoThread().start();
}
Obviamente as coisas ficam mais interessantes quando o programa é formado por
vários threads. Se todos imprimem a mesma mensagem “Oi!”, não distinguiríamos
qual fez que parte olhando para a saída. Mas como a classe Thread faz com os
threads tenham nomes (um nome default atribuído automaticamente, ou o nome
escolhido pelo programador, fornecido na hora da criação do objeto), poderíamos
melhorar as definições das classes acima da seguinte forma:
class NossoThread extends Thread {
public NossoThread (String nome) {
// passa o nome recebido pelo construtor da subclasse para o
// a construtor da superclasse
super(nome);
}
public void run () {
System.out.println(“Executando thread ”+ getName() + “- Oi!”);
}
public class UsaNossoThread {
public static void main ( String[] args) {
// cria três threads, fornecendo nomes escolhidos para cada um
new NossoThread(“Um”).start();
new NossoThread(“Dois”).start();
new NossoThread(“Três”).start();
}
Este mesmo exemplo, utilizando a interface Runnable, seria definido da seguinte
forma:
class NossoDizOi implements Runnable {
public void run() {
System.out.println(“Executando thread ”
+ Thread.currentThread().getName() + “- Oi!”);
}
}
public class UsaNossoThread {
public static void main(String[] args) {
// primeiro, criamos os três objetos que contêm o código a
// ser rodado
NossoDizOi a = new NossoDizOi();
NossoDizOi b = new NossoDizOi();
NossoDizOi c = new NossoDizOi();
// agora criamos threads, fornecendo no construtor além do
// nome que queremos, o objeto que tem o que código a ser rodado.
new Thread(a, “Um”).start();
new Thread(b, “Dois”).start();
new Thread(c, “Três”).start();
}
}
Observe que o código que faz o trabalho do thread mudou um pouco: na versão a
partir da classe Thread, para obter o nome do thread que iria imprimir, bastava
chamar getNome(), já que o objeto que chama a função herdou a operação de
Thread. Já no exemplo com Runnable, primeiro temos que descobrir qual é o
thread que está executando run (através da rotina Thread.currentThread()), e aí
chamar getNome() para este objeto thread.
Há boas razões tanto para escolhermos definir threads a partir de Thread quanto a
partir de Runnable. Há uma regra que tem se mostrado útil na prática: Se a classe
que você está criando como código do thread precisa ser derivada de alguma
outra classe, então utilize Runnable8. Para programas que devem ser executados a
partir de browsers, precisaremos que o código seja subclasse de Applet, e portanto
Runnable é o caminho mais apropriado a ser seguido.
Este mesmo exemplo, na forma de um applet a ser incluído em uma página html,
ficaria assim:
// preparo para usar applets e elementos gráficos
import java.applet.Applet;
import java.awt.*;
import java.awt.event.*;
public class AppletUsaNossoThread extends Applet implements Runnable {
// Estamos definindo classe de objetos que pode ser incluída em páginas
// html, e também especifica código a ser executado por thread.
Label label_saida = new Label("Saída: "); // elemento gráfico
TextArea texto_saida = new TextArea("", 10, 50); // área para escrever
Button botao = new Button("Executar"); // botão para requisitar cria-
// ção e execução de threads
public void run() { // código a ser executado nos threads
// escreve na área gráfica criada para este fim
texto_saida.append("Executando thread "
+ Thread.currentThread().getName() + "- Oi!\n");
}
public void init() {
// coloca itens gráficos
add(label_saida);
add(texto_saida);
add(botao);
// Define, instancia e registra objeto que trata
// do botao quando pressionadobotao.
addActionListener( new ActionListener () {
public void actionPerformed(ActionEvent e) { 
cria_threads(); // rotina definida a seguir
}});
}
 
8 Isto está relacionado ao fato de não haver herança múltipla em Java, ou seja, não
seria possível uma classe ser subclasse ao mesmo tempo de Thread e alguma outra
classe.
void cria_threads() {
// agora criamos threads, fornecendo no construtor além do
// nome que queremos, o objeto que tem o que código a ser rodado.
 new Thread(this, "Um").start();
 new Thread(this, "Dois").start();
 new Thread(this, "Três").start();
}
}
Este applet visto pelo appletviewer tem a seguinte apresentação:
Além da execução exemplificada na figura acima, este programa poderia resultar
ainda em mais cinco possíveis saídas (duas delas exemplificadas a seguir) na área
de texto (TextArea texto_saida),dependendo de qual thread a máquina virtual de
java escolheu rodar primeiro
Executando thread Dois - Oi! Executando thread Três - Oi!
Executando thread Três - Oi! Executando thread Um - Oi!
Executando thread Um - Oi! Executando thread Dois - Oi!
3.2 Prioridades de Threads em Java
Threads, em Java e em outros ambientes, foram projetados para executar
concorrentemente. Mas muitos computadores dispõem apenas de um processador,
com threads executando um por vez, de forma a prover a ilusão de concorrência.
Os threads são escalonados em alguma ordem, isto é, o gerenciamento (da máquina
virtual Java ou do sistema operacional) decide (através de alguma política de
escolha) que thread deve ocupar a CPU. Java adota um algoritmo de
escalonamento bem clássico: escalonamento com prioridades fixas, onde a cada
thread está associada uma prioridade (numérica), e o escalonador escolhe threads
com base nas prioridades relativas entre os threads aguardando o uso da CPU.
Quando um thread é criado, ele tem a mesma prioridade do thread que o criou9.As
prioridades podem ser alteradas a qualquer momento através do método
setPriority. Prioridades são inteiros no intervalo definido pelas constantes
disponibilizadas em java.lang.Thread: MIN_PRIORITY e MAX_PRIORITY. Um
thread de maior prioridade só cede seu lugar para um thread de menor prioridade se
ele terminar sua execução, ceder espontaneamente sua vez (através do método
yield) ou não puder prosseguir sua execução por estar aguardando algum evento.
Threads de mesma prioridade são alternados, formando uma fila em que o thread,
após ser servido, vai para o final da fila. Um thread escolhido continuará
executando até que uma das seguintes condições se torne verdadeira:
• Algum thread de maior prioridade fica pronto para executar (por ter sido
criado ou por não precisar mais esperar por algo). O thread de menor
prioridade que estava executando é interrompido em favor do novo thread, de
maior prioridade;
• Seu método run() termina, ou ele cede a vez através do yield();
• O ambiente de execução permite que seja implementada a política de “fatia de
execução”, isto é, entre threads de mesma prioridade cada um recebe um fatia
de tempo, e sai da CPU quando sua fatia terminou. Este é o caso, por exemplo,
do Windows 95/NT.
Mas não há garantia de que sempre o thread de maior prioridade esteja rodando,
pois o escalonador pode decidir escolher um thread e menor prioridade a fim de
evitar que este espere para sempre [Tutorial Java].
O uso de prioridades é útil para incentivar que um dos threads consiga continuar
progredindo em sua execução. Por exemplo, se sua aplicação executa uma
simulação e mostra uma visualização da mesma, é importante que o thread de
visualização tenha chance de executar frequentemente, de forma que a visualização
esteja atualizada. Por exemplo, para simular o movimento (aleatório) de n formigas
em uma área retangular podemos usar um thread para cada formiga, com cada um
desses threads mantendo disponível a posição atual da formiga a que corresponde.
Um thread extra, para visualização da simulação, consultaria a posição corrente de
cada formiga e com esta informação alteraria o “display” da área retangular.
 
9 Observe que a execução sempre começa com um thread, que não é criado de
forma explícita pela aplicação. No caso de uma aplicação a ser executada da linha
de comando, um thread é automaticamente criado para executar o método public
static void main(String[] args).
Simplesmente atribuir a este thread de visualização uma prioridade maior do que as
das prioridades dos threads das formigas não leva a uma solução correta, pois este
thread teria lugar cativo em um processador, em detrimento dos threads das
formigas (ou seja, só seria uma solução aceitável se o número de processadores for
pelo menos n+ 1). Usualmente o tratamento dado a este thread envolve ganhar uma
prioridade maior, mas também deliberadamente desocupar a CPU periodicamente,
a fim de que o código de simulação das formigas possa executar. A operação
yield(), disponível na classe Thread, só tem efeito para ceder a vez a outro thread
de mesma prioridade. A fim de passar a CPU para um thread de menor prioridade,
o thread de maior prioridade pode usar o método sleep(), que faz com que este pare
de executar por um intervalo de tempo. Há duas formas disponíveis, uma em que a
unidade de tempo é milisegundos e a outra em que se pode também especificar o
número de nanosegundos: static void sleep(long millis) e static void sleep (long
millis, int nanos)10. Esta parada temporária de execução pode ser interrompida pelo
método interrupt(): se o thread t1 chamou sleep, outro thread t2 que esteja
executando pode chamar t1.interrupt() e com isso interromper a “dormida” de t1.
Quando isto ocorre, t1 retorna de sleep através do lançamento de uma interrupção;
assim, é necessário que as chamadas de sleep lidem com a possibilidade de ser
lançada a interrupção InterruptedException (vide o uso de sleep nos exemplos a
seguir).
É necessário cuidado também para evitar que cada um dos threads relativos as
formigas não atuem de forma “egoísta”. Se todos são criados com a mesma
prioridade, nos ambientes em que o suporte do sistema operacional para threads
não envolva a alocação de fatias limitadas de tempo a cada thread, seria possível
que uma formiga monopolizasse a CPU enquanto o thread de visualização está sem
executar (“dormindo” por iniciativa própria). Isto pode ser evitado se cada um dos
threads, após executar uma parte de sua simulação, cooperar com os outros através
da chamada yield(), que faz com que a CPU passe para outro thread de mesma
prioridade.
3.3 Sincronização em Java
Além do método sleep(), há outras formas de um thread tornar-se não executável:
bloquear na execução de I/O (entrada e saída), ou especificar que ele deseja esperar
até que uma condição seja satisfeita (um dos cenários desejáveis de sincronização
de threads,como vimos na seção 2. O outro cenário de sincronização é a exclusão
mútua no acesso a recursos compartilhados).
 
10 O modificador “static” significa que a rotina pode ser invocada sem a
especificação de um objeto específico.
A maior parte das aplicações de interesse exige que threads compartilhem
informação e considerem o estado e as atividades sendo exercidas por outros
threads antes de progredir com sua própria execução.
Java permite a definição de regiões críticas (seção 2.2) através da palavra-chave
synchronized. Regiões críticas, em que um mesmo objeto é acessado por threads
concorrentes, podem ser métodos ou blocos de comandos. A implementação de
Java associa com todo objeto (estejamos ou não usando threads) um lock, isto é,
uma variável para exclusão mútua (como um semáforo inicializado com o valor 1,
conforme discutido na seção 2.3). Introduziremos o uso de threads sincronizados
em Java com um exemplo onipresente: produtor/consumidor11. A aplicação é
formada por um thread que periodicamente (com intervalos aleatórios) produz
números e dois threads que consomem números produzidos sempre calculando a
média dos números já consumidos. O produtor e os consumidores precisam
compartilhar um repositório de números produzidos a serem consumidos. O
seguinte código descreve a funcionalidade do produtor, consumidor e do
repositório, mas não leva em consideração corretamente os aspectos de
concorrência:
public class ProdutorConsumidor { // classe com programa principal
 private Produtor prod; // implementa código do thread para produtor
 private Consumidor cons1,
 cons2; // implementa código do thread consumidor
 private Repositorio reposit; // guarda números a serem consumidor
 // construtor recebe quantidade de elementos a serem prod/cons
 ProdutorConsumidor(int num_elementos) {
 reposit = new Repositorio(num_elementos);
 prod = new Produtor(num_elementos, reposit);
 int num_cada_consumidor = num_elementos/2;cons1 = new Consumidor(num_cada_consumidor, reposit);
 if (num_elementos %2 != 0) num_cada_consumidor++;
 cons2 = new Consumidor(num_cada_consumidor, reposit);
 new Thread(prod, "Produtor").start(); // cria thread produtor
 new Thread(cons1, "Consumidor 1").start(); // cria thread consumidor
 new Thread(cons2, "Consumidor 2").start(); // cria thread consumidor
 }
 
11 Na aula do mini-curso uma versão mais gráfica e interessante deste exemplo é
utilizada. Aqui, por brevidade, nos atemos ao aspecto algoritmo do problema.
Todos os exemplos utilizados na aula se encontram no sítio deste texto
(http://www.ime.usp.br/~dilma/jai99).
 public static void main(String[] args) {
 // Programa principal: só cria objeto ProdutorConsumidor, já que
 // o construtor desta classe cria os threads e começa execução.
 // Argumento do construtor – 10 – é a quantidade a ser prod/cons
 ProdutorConsumidor prog = new ProdutorConsumidor(10);
 }
}
// class Produtor: thread que gera número aleatório, adiciona-o no repositório
// compartilhado e fica um período aleatório sem executar.
class Produtor implements Runnable {
 private Repositorio reposit;
 private int num_elementos;
 // construtor do Produtor recebe quem é o repositório e quantos elementos
 // devem ser produzidos.
 Produtor(int num, Repositorio reposit) {
 num_elementos = num;
 this.reposit = reposit;
 }
 public void run() {
 for (int i=0; i < num_elementos; i++) {
 int numero =(int) (Math.random() * 1000); // gera número
 reposit.poe(numero); // coloca na estrutura de dados
 // compartilhada
 System.out.println("Produtor colocou " + numero);
 // fica um tempinho sem fazer nada
 try { // dorme
 Thread.currentThread().sleep ((long) (Math.random() * 100));
 } catch (InterruptedException e) { /* ignora */ }
 }
 }
}
// Consumidor
class Consumidor implements Runnable {
 private Repositorio reposit;
 private int soma = 0; // mantém média dos elementos consumidos
 private int num_elementos;
 Consumidor(int num, Repositorio reposit) {
 num_elementos = num;
 this.reposit = reposit;
 }
 public void run() {
 int i = 0;
 while (i < num_elementos) { // tem que consumir quantidade estipulada
 if (!reposit.vazio()) {
 // repositório tem algo a ser consumido
 int numero = reposit.tira();
 soma += numero;
 i++;
 System.out.println(Thread.currentThread().getName() +
 “ tirou " + numero + “.Media “ + (double) soma / i);
 }
 }
 }
}
// Estrutura de dados que armazena números a serem consumidos
class Repositorio {
 int num_elementos; // tamanho da estrutura
 int in = 0, out = 0; // indicadores da próxima posição onde serão
 // colocados/retirados elementos
 int [ ] vetor; // local de armazenamento
 Repositorio(int num) {
 num_elementos = num;
 vetor = new int[num];
 }
 void poe (int num) {
 vetor[in++] = num;
 }
 boolean vazio () {
 if (in == out)
 return true;
 else
 return false;
 }
 int tira () {
 return(vetor[out++]);
 }
}
Um dos problemas com este código é, obviamente, a falta de cuidado com o acesso
concorrente ao recurso compartilhado (repositório de números). O thread
consumidor tenta garantir que existam elementos no repositório antes de invocar a
retirada, mas notem que o seguinte escalonamento é possível:
Programa principal prod cons1 cons2
 cria prod,cons1,cons2
 insere 1 número
vazia?não!
vazia?não!
retira 1 número
tenta retirar:ERRO
Seria possível ter os dois consumidores retirando o mesmo elemento ao mesmo
tempo (e portanto um elemento restaria no final sem ser consumido). Se
tivéssemos vários produtores, poderia ocorrer deles tentarem colocar um novo
elemento exatamente na mesma posição do vetor de armazenamento, perdendo-se
um dos números.
É necessário então delimitar que partes do acesso ao repositório deve ser prevenida
de acesso concorrente. A fim de lidar evitar que duas remoções (chamada
reposit.tira()) interfiram uma com a outra, este método deve ser executado por no
máximo um thread. Se também queremos permitir a possibilidade de termos vários
produtores, poe() também deve ser protegido contra acesso concorrente. Quando ao
método que checa se o repositório está vazio, também devemos evitar o seguinte
cenário:
Thread cons1 Thread cons2
 /*chama vazia() no caso
 em que há um elemento: */
in==out ? NÃO!!
/* chama vazia(), ainda há
 um elemento*/
in == out ? NÃO ∴ ret false
out++ e retorna elemento
∴ ret false
out++ e retorna elemento: ERRO !
Cenários como este indicam que a operação vazia deve ser executada com exclusão
mútua: enquanto ela executa nenhum outro thread pode estar executando algum
métodos do objeto reposit.. Da mesma forma, devem ser mutuamente exclusivas as
execuções de retira e põe.
A fim de definir que a estrutura de dados só pode ser manipulada por um de seus
métodos de cada vez, basta declarar cada um dos métodos com o modificador
synchronized:
class Repositorio {
synchronized void poe (int num) { … }
synchronized boolean vazia () { … }
synchronized int tira() {…}
}
Como todos os métodos da classe foram declarados como sincronizados, a classe
funciona como um Monitor (seção 2.4).
O objeto reposit possui um lock interno, isto é, uma variável que indica se o objeto
compartilhado está livre ou ocupado. A execução de um método synchronized (por
exemplo, reposit.tira() invocada pelo thread cons1) segue o seguinte protocolo: se
o lock de reposit está disponível, o lock passa a ficar de posse do thread cons1, e
cons1 prossegue executando tira(). Ao final da execução, cons1 libera o lock. Se,
por outro lado, o lock estiver ocupado (de posse de algum outro thread, por
exemplo cons2), então cons1 fica bloqueado, aguardando a liberação do lock.
A obtenção e liberação de um lock são feitas automaticamente pelo sistema de
execução da linguagem Java, e de forma atômica (sem interrupção). A
implementação suporta sincronização reentrante, isto é, um thread requisitar um
lock que não está disponível por estar exatamente com ele:
public class Reentrante {
public synchronized void a () {
b(); // chama b, que também é synchronized
System.out.println(“em a()”);
}
public synchronized void b() {
System.out.println(“em b()”);
}
}
A chamada “new Reentrante().a()” resulta na impressão de:
em a()
em b()
Voltando ao problema do produtor/consumidor, será que agora que tornamos o
acesso ao repositório disponível a no máximo um thread por vez, corrigimos o
problema de sincronização? Vejamos o código do Consumidor:
class Consumidor implements Runnable {
 private Repositorio reposit;
 private int soma = 0, num_elementos;
 Consumidor(int num, Repositorio reposit) { . . . }
 public void run() {
 int i = 0;
 while (i < num_elementos) { // tem que consumir quantidade estipulada
 if (!reposit.vazio()) {
 // repositório tem algo a ser consumido
 int numero = reposit.tira();
 soma += numero;
 i++;
 System.out.println(Thread.currentThread().getName() +
 “ tirou " + numero + “.Media “ + (double) soma / i);
 }
 }
 }
}
O trecho destacado permite o seguinte cenário se o repositório contem apenas um
inteiro:
Thread cons1 Thread cons2
 if (!reposit.vazio ( ) )
/* vazio( ) retornou falso, já
 que há um elemento */ if (!reposit.vazio( ) )
/* vazio( ) retornou falso, já
int num = reposit.tira ( ) 
int num = reposit.tira ( ); ERRO !!
É desejável que as operações vazia ( ) e tira ( ) sejam executadas atomicamente, ou
seja, uma vez constatado que há elemento disponível no repositório, o mesmo seja
retirado sem que haja chance de intervenção por parte de algum outro thread.
Incluíremos o método seDerTira ( ) na classe Repositorio, de forma a permitir que
o método infome a disponibilidade e já entregue um valor

Continue navegando