Você está na página 1de 299

Machine Translated by Google

Machine Translated by Google

Programação simultânea em Java: princípios e padrões de design, segundo


Edição

Por Doug Lea

Editora: Addison Wesley

Data de publicação: 01 de outubro de 1999

ISBN: 0-201-31009-0

Páginas: 432

Em Programação Simultânea em Java, Segunda Edição, você encontrará cobertura


completamente atualizada da plataforma Java 2 e cobertura nova ou expandida de:

modelo de memória
Cancelamento
Programação paralela portátil
Classes utilitárias para controle de simultaneidade

A plataforma Java fornece um conjunto amplo e poderoso de APIs, ferramentas e tecnologias.


Um de seus recursos mais poderosos é o suporte integrado para threads. Isso torna a programação
simultânea uma opção atraente e desafiadora para programadores que usam a linguagem de
programação Java.

Este livro mostra aos leitores como usar o modelo de encadeamento da plataforma Java com
mais precisão, ajudando-os a entender os padrões e compensações associados à programação
simultânea.

Você aprenderá como iniciar, controlar e coordenar atividades simultâneas usando a classe
java.lang.Thread, as palavras-chave sincronizadas e voláteis e os métodos wait, notify e notifyAll. Além
disso, você encontrará uma cobertura detalhada de todos os aspectos da programação simultânea,
incluindo tópicos como confinamento e sincronização, impasses e conflitos, controle de ação
dependente do estado, passagem assíncrona de mensagens e fluxo de controle, interação coordenada e
estruturação computacional e baseada na web. Serviços.

O livro destina-se a programadores intermediários a avançados interessados em dominar as


complexidades da programação simultânea. Adotando uma abordagem de padrão de projeto, o livro
oferece técnicas de projeto padrão para criar e implementar componentes que resolvem desafios
comuns de programação simultânea. Os numerosos exemplos de código ajudam a esclarecer as
sutilezas dos conceitos de programação simultânea discutidos.

Reconhecimentos de

Direitos Autorais Capítulo 1. Programação Concorrente Orientada a


Objetos Seção 1.1. Usando construções de

simultaneidade Seção 1.2. Objetos e Simultaneidade

Seção 1.3. Forças de projeto


Seção 1.4. Padrões Antes/Depois
Machine Translated by Google

Capítulo 2. Exclusão

Seção 2.1. Imutabilidade

Seção 2.2. Sincronização


Seção 2.3. Confinamento

Seção 2.4. Estruturação e Refatoração de Classes Seção 2.5.

Usando Utilitários de Bloqueio

Capítulo 3. Dependência do Estado

Seção 3.1. Lidando com o fracasso


Seção 3.2. Métodos Protegidos

Seção 3.3. Estruturando e Refatorando Classes Seção 3.4.

Usando utilitários de controle de simultaneidade


Seção 3.5. Ações conjuntas

Seção 3.6. Transações

Seção 3.7. Implementação de Utilitários

Capítulo 4. Criando Tópicos

Seção 4.1. Mensagens unidirecionais

Seção 4.2. Compondo mensagens unidirecionais


Seção 4.3. Serviços em Tópicos

Seção 4.4. Decomposição Paralela Seção 4.5.

Objetos ativos

direito autoral

Muitas das designações usadas por fabricantes e vendedores para distinguir seus produtos são reivindicadas como
marcas registradas. Onde essas designações aparecem neste livro e a Addison-Wesley estava ciente de uma
reivindicação de marca registrada, as designações foram impressas com iniciais maiúsculas ou todas em maiúsculas.

Duke desenhado por Joe Palrang.

A Sun Microsystems, Inc. possui direitos de propriedade intelectual relativos às implementações da tecnologia
descrita nesta publicação. Em particular, e sem limitação, esses direitos de propriedade intelectual podem incluir
uma ou mais patentes dos EUA, patentes estrangeiras ou pedidos pendentes. Sun, Sun Microsystems,
o logotipo da Sun e todas as marcas e logotipos baseados em Sun, Java, Jini e Solaris são marcas comerciais
ou marcas registradas da Sun Microsystems, Inc. nos Estados Unidos e em outros países. UNIX é uma
marca registrada nos Estados Unidos e em outros países, licenciada exclusivamente pela X/Open Company,
Ltd.

Conforme usado neste livro, os termos "Java virtual machine" e "JVM" significam uma máquina virtual para a plataforma
Java.

ESTA PUBLICAÇÃO É FORNECIDA "COMO ESTÁ" SEM GARANTIA DE QUALQUER TIPO, SEJA EXPRESSA OU
IMPLÍCITA, INCLUINDO, SEM LIMITAÇÃO, AS GARANTIAS IMPLÍCITAS DE COMERCIABILIDADE, ADEQUAÇÃO A UM
DETERMINADO FIM OU NÃO VIOLAÇÃO.
Machine Translated by Google

ESTA PUBLICAÇÃO PODE INCLUIR IMPRECISÕES TÉCNICAS OU ERROS TIPOGRÁFICOS.


ALTERAÇÕES SÃO ADICIONADAS PERIODICAMENTE ÀS INFORMAÇÕES AQUI CONTIDAS; ESTAS
MUDANÇAS SERÃO INCORPORADAS NAS NOVAS EDIÇÕES DA PUBLICAÇÃO. A SUN MICROSYSTEMS, INC. PODE
FAZER MELHORIAS E/OU MUDANÇAS EM QUALQUER TECNOLOGIA, PRODUTO OU PROGRAMA DESCRITO
NESTA PUBLICAÇÃO A QUALQUER MOMENTO.

O autor e o editor foram cuidadosos na preparação deste documento, mas não oferecem nenhuma garantia expressa
ou implícita de qualquer tipo e não assumem nenhuma responsabilidade por erros ou omissões. Nenhuma
responsabilidade é assumida por danos acidentais ou consequenciais relacionados ou decorrentes do uso das
informações ou programas aqui contidos.

Número do Cartão da Biblioteca do Congresso 99-066823

Copyright © 2000 da Addison Wesley Longman, Inc. Todos os direitos reservados. Nenhuma parte desta publicação
pode ser reproduzida, armazenada em um sistema de recuperação ou transmitida, de qualquer forma ou por
qualquer meio, eletrônico, mecânico, fotocópia, gravação ou outro, sem o consentimento prévio do editor.
Impresso nos Estados Unidos da América. Publicado simultaneamente no Canadá.

Texto impresso em papel reciclado e sem ácido.

2 3 4 5 6 7 - MA - 02 01 00 99

Segunda impressão, novembro de 1999

Agradecimentos
Este livro começou como um pequeno conjunto de páginas da Web que reuni na primavera de 1995, enquanto
tentava entender minhas próprias tentativas iniciais de usar recursos de simultaneidade Java em esforços de desenvolvimento experimental.
Então cresceu; primeiro na World Wide Web, onde estendi, expandi e removi padrões para refletir a experiência
crescente minha e de outras pessoas com simultaneidade Java; e agora neste livro, que coloca os padrões dentro
da perspectiva mais ampla do desenvolvimento de software simultâneo. As páginas da web também existem, mas
agora servem como um complemento para as apresentações conceituais mais adequadas à forma de livro.

Houve muitas mudanças ao longo do caminho, em um processo que se beneficiou de comentários, sugestões,
relatórios de errata e trocas com muitas pessoas gentis e conhecedoras. Estes incluem Ole Agesen, Tatsuya Aoyagi,
Taranov Alexander, Moti Ben-Ari, Peter Buhr, Bruce Chapman, Il Hyung Cho, Colin Cooper, Kelly Davis, Bruce
Eckel, Yacov Eckel, Saleh Elmohamed, Ed Falis, Randy Farmer, Glenn Goldstein, David Hanson, Jyrki Heikkinen,
Alain Hsiung, Jerry James, Johannes Johannsen, Istvan Kiss, Ross Knippel, Bil Lewis, Sheng Liang,
Jonathan Locke, Steve MacDonald, Hidehiko Masuhara, Arnulf Mester, Mike Mills, Trevor Morris, Bill Pugh,
Andrew Purshottam , Simon Roberts, John Rose, Rodney Ryan, Joel Rosi-Schwartz, Miles Sabin, Aamod Sane,
Beverly Sanders, Doug Schmidt, Kevin Shank, Yukari Shirota, David Spitz, David Stoutamire, Henry Story, Sumana
Srinivasan, Satish Subramanian, Jeff Swartz , Patrick Thompson, Volker Turau, Dennis Ulrich, Cees Vissar, Bruce
Wallace, Greg Wilson, Grant Woodside, Steve Yen e Dave Yost, bem como pessoas que enviaram comentários
anônimos por correio eletrônico.
Machine Translated by Google

Os membros do seminário de padrões de Ralph Johnson (especialmente Brian Foote e Ian Chai) leram as
primeiras formas de alguns padrões e sugeriram muitas melhorias. Raj Datta, Sterling Barrett e Philip
Eskelin do New York City Patterns Group, e Russ Rufer, Ming Kwok, Mustafa Ozgen, Edward Anderson e
Don Chin do Silicon Valley Patterns Group prestaram serviço valioso semelhante para as versões
preliminares da segunda edição .

Revisores oficiais e não oficiais dos manuscritos da primeira e da segunda edição fizeram comentários e
sugestões úteis em cronogramas apertados. Eles incluem Ken Arnold, Josh Bloch, Joseph Bowbeer,
Patrick Chan, Gary Craig, Desmond D'Souza, Bill Foote, Tim Harrison, David Henderson, Tim Lindholm,
Tom May, Oscar Nierstrasz, James Robins, Greg Travis, Mark Wales, Peter Welch e Deborra Zukowski.
Agradecimentos muito especiais vão para Tom Cargill por seus muitos insights e correções, bem como
pela permissão de incluir uma descrição de seu padrão de notificação específica. Agradecimentos muito
especiais também vão para David Holmes por, entre muitas contribuições, ajudar a desenvolver e
ampliar o material para tutoriais que, por sua vez, foram incluídos na segunda edição.

Rosemary Simpson contribuiu com inúmeras melhorias durante a criação do índice. Ken Arnold
pacientemente me ajudou a lidar com o FrameMaker. Mike Hendrickson e a equipe editorial da
Addison-Wesley têm me apoiado continuamente.

Este livro não teria sido possível sem o generoso apoio da Sun Labs. Agradecimentos especiais a Jos
Marlowe e Steve Heller por oferecerem oportunidades de trabalho colaborativo em projetos divertidos
e empolgantes de pesquisa e desenvolvimento.

Agradeço acima de tudo a Kathy, Keith e Colin por tolerar tudo isso.

Doug Lea, setembro de 1999

Capítulo 1. Programação Concorrente Orientada a Objetos


Este livro discute algumas maneiras de pensar, projetar e implementar programas simultâneos na
linguagem de programação Java. A maioria das apresentações neste livro assume que você é um
desenvolvedor experiente familiarizado com a programação orientada a objetos (OO), mas tem pouca
exposição à simultaneidade. Leitores com experiências opostas com concorrência em outros
idiomas também podem achar este livro útil.

O livro está organizado em quatro capítulos grosseiros. (Talvez partes seja um termo melhor.) Este primeiro
capítulo começa com um breve passeio por algumas construções usadas com frequência e depois volta
para estabelecer uma base conceitual para a programação orientada a objetos concorrentes: como a
simultaneidade e os objetos se encaixam, como o projeto resultante forças afetam a construção de
classes e componentes e como alguns padrões de projeto comuns podem ser usados para estruturar soluções.

Os três capítulos subseqüentes são centrados no uso (e evasão) dos três tipos de construções de
simultaneidade encontradas na linguagem de programação Java:

Exclusão. Manter estados consistentes de objetos evitando interferência indesejada entre atividades
simultâneas, geralmente usando métodos sincronizados .

Dependência do estado. Acionamento, prevenção, adiamento ou recuperação de ações,


dependendo se os objetos estão em estados nos quais essas ações poderiam ou foram bem-
sucedidas, às vezes usando os métodos de monitor Object.wait, Object.notify e Object.notifyAll.
Machine Translated by Google

Criando tópicos. Estabelecendo e gerenciando simultaneidade, usando objetos Thread .

Cada capítulo contém uma sequência de seções principais, cada uma sobre um tópico independente. Eles
apresentam princípios e estratégias de design de alto nível, detalhes técnicos em torno de
construções, utilitários que encapsulam usos comuns e padrões de design associados que abordam
problemas específicos de simultaneidade. A maioria das seções termina com um conjunto anotado
de leituras adicionais, fornecendo mais informações sobre tópicos selecionados. O suplemento online deste
livro contém links para recursos online adicionais, bem como atualizações, errata e exemplos de código. É acessível através de links de

http://java.sun.com/Series ou http://gee.cs.oswego.edu/dl/cpj

Se você já está familiarizado com o básico, pode ler este livro na ordem apresentada para explorar cada
tópico com mais profundidade. Mas a maioria dos leitores desejará ler este livro em várias ordens diferentes.
Como a maioria dos conceitos e técnicas de simultaneidade interagem com a maioria dos outros, nem sempre
é possível entender cada seção ou capítulo completamente isolado de todos os outros. No entanto, você ainda
pode adotar uma abordagem abrangente, examinando brevemente cada capítulo (incluindo este) antes de
prosseguir com uma cobertura mais detalhada do seu interesse. Muitas apresentações posteriores no livro
podem ser abordadas após a leitura seletiva de material anterior indicado por extensas referências cruzadas.

Você pode praticar isso agora folheando as seguintes preliminares.

Terminologia. Este livro usa convenções terminológicas padrão OO: os programas definem métodos
(implementando operações) e campos (representando atributos) que são válidos para todas as instâncias
(objetos) de classes especificadas.

As interações em programas OO normalmente giram em torno das responsabilidades colocadas sobre um


objeto cliente que precisa de uma ação a ser executada e um objeto servidor contendo o código para executar
a ação. Os termos cliente e servidor são usados aqui em seus sentidos genéricos, não no sentido especializado
de arquiteturas cliente/servidor distribuídas. Um cliente é qualquer objeto que envia uma solicitação para
outro objeto, e um servidor é qualquer objeto que recebe tal solicitação. A maioria dos objetos
desempenha as funções de clientes e servidores. No caso usual em que não importa se um objeto em discussão
atua como um cliente ou servidor ou ambos, ele geralmente é chamado de host; outros com os quais
ele pode, por sua vez, interagir são frequentemente chamados de ajudantes ou pares. Além disso, ao
discutir invocações no formato obj.msg(arg), o destinatário (ou seja, o objeto vinculado à variável obj) é chamado de objeto de destino.

Este livro geralmente evita lidar com fatos transitórios sobre classes e pacotes específicos não
diretamente relacionados à simultaneidade. E não cobre detalhes sobre controle de simultaneidade em
estruturas especializadas, como Enterprise JavaBeans e Servlets. Mas às vezes se refere a software de
marca e produtos de marca associados à plataforma Java. A página de direitos autorais deste livro fornece
mais informações.

Listagens de códigos. A maioria das técnicas e padrões neste livro é ilustrada por variantes de um conjunto
irritantemente pequeno de exemplos de corrida de brinquedos. Este não é um esforço para ser chato, mas
para ser claro. As construções de simultaneidade geralmente são sutis o suficiente para se perder em
exemplos significativos. A reutilização de exemplos em execução torna as diferenças pequenas, mas
críticas, mais óbvias, destacando os principais problemas de design e implementação. Além disso, as
apresentações incluem esboços de código e fragmentos de classes que ilustram técnicas de implementação,
mas não pretendem ser completas ou mesmo compiláveis. Essas classes são indicadas por comentários iniciais nas listagens.

Instruções de importação, qualificadores de acesso e até métodos e campos às vezes são omitidos das
listagens quando podem ser inferidos do contexto ou não afetam a funcionalidade relevante. O protegido
Machine Translated by Google

O qualificador é usado como padrão para recursos não públicos sempre que não houver motivo específico para
restringir o acesso à subclasse. Isso enfatiza as oportunidades de extensibilidade no design de classe concorrente
(consulte § 1.3.4 e § 3.3.3). As classes por padrão não têm qualificador de acesso. Listagens de amostra às
vezes são formatadas de maneiras não padronizadas para mantê-las juntas nas páginas ou para enfatizar as principais construções de inte

O código para todas as classes de exemplo neste livro está disponível no suplemento online. A maioria das
técnicas e padrões neste livro é ilustrada por um único exemplo de código mostrando suas formas mais típicas.
O suplemento inclui exemplos adicionais que demonstram pequenas variações, bem como alguns links para
outros usos conhecidos. Ele também inclui alguns exemplos maiores que são mais úteis para navegar e
experimentar on-line do que para ler como listagens.

O suplemento fornece links para um pacote, util.concurrent, que contém versões de qualidade de
produção das classes utilitárias discutidas neste livro. Este código é executado na plataforma Java 2 e foi testado
com releases 1.2.x. Discussões ocasionais, apartes e notas de rodapé mencionam brevemente mudanças de
versões anteriores, possíveis mudanças futuras conhecidas no momento em que este livro foi escrito
e algumas peculiaridades de implementação a serem observadas. Verifique o suplemento online para atualizações adicionais.

Diagramas. A notação UML padrão é usada para interação e diagramas de classe (consulte Leituras
Adicionais em § 1.1.3). Os diagramas anexos (cortesia de Martin Fowler) ilustram as únicas formas usadas
neste livro. Outros aspectos da notação, metodologia e terminologia UML não são especificamente
considerados.
Machine Translated by Google

A maioria dos outros diagramas mostra timethreads nos quais curvas cinzas de forma livre traçam
threads atravessando coleções de objetos. Pontas de seta achatadas representam bloqueio. Os objetos são
representados como ovais que às vezes mostram recursos internos selecionados, como bloqueios, campos
e bits de código. Linhas finas (geralmente rotuladas) entre objetos representam relações (normalmente
referências ou chamadas potenciais) entre eles. Aqui está um exemplo sem sentido mostrando que o thread A
adquiriu o bloqueio para o objeto X e está procedendo por meio de algum método no objeto Y que serve
como um auxiliar para X. Enquanto isso, o thread B é bloqueado de alguma forma ao inserir algum método no objeto X:

1.1 Usando construções de simultaneidade

Esta seção apresenta as construções básicas de suporte à simultaneidade por exemplo e, em seguida,
continua com uma explicação dos principais métodos da classe Thread. Outras construções de simultaneidade são brevemente
Machine Translated by Google

descritos à medida que são introduzidos, mas os detalhes técnicos completos são adiados para capítulos posteriores
(principalmente § 2.2.1 e § 3.2.2). Além disso, os programas simultâneos geralmente usam alguns recursos comuns
da linguagem de programação Java que não são amplamente usados em outros lugares. Estes são brevemente revistos à medida que surgem.

1.1.1 Um applet de partícula

ParticleApplet é um Applet que exibe partículas em movimento aleatório. Além das construções de
simultaneidade, este exemplo ilustra alguns dos problemas encontrados ao usar encadeamentos com qualquer
programa baseado em GUI. A versão aqui descrita precisa de muito embelezamento para ser visualmente atraente ou
realista. Você pode gostar de experimentar adições e variações como um exercício.

Como é típico de programas baseados em GUI, o ParticleApplet usa várias classes auxiliares que fazem a maior parte
do trabalho. Passaremos pela construção das classes Particle e ParticleCanvas antes de discutir o ParticleApplet.

1.1.1.1 Partícula

A classe Particle define um modelo completamente irreal de corpos móveis. Cada partícula é representada apenas
por sua localização (x, y). Cada partícula também suporta um método para alterar aleatoriamente sua localização e um
método para desenhar a si mesma (como um pequeno quadrado) dado um objeto java.awt.Graphics fornecido.

Embora os objetos Particle não exibam nenhuma simultaneidade intrínseca, seus métodos podem ser invocados em várias
atividades simultâneas. Quando uma atividade está executando um movimento e outra está invocando desenho quase ao
mesmo tempo, gostaríamos de ter certeza de que o desenho pinta uma representação precisa de onde a partícula
está. Aqui, exigimos que o sorteio use os valores de localização atuais antes ou depois do movimento. Por exemplo,
seria conceitualmente errado para uma operação de desenho exibir usando o valor y atual antes de um determinado
movimento, mas o valor x atual após o movimento. Se permitíssemos isso, o método draw às vezes exibiria a partícula
em um local que ela nunca ocupou.
Machine Translated by Google

Essa proteção pode ser obtida usando a palavra-chave sincronizada , que pode modificar um método ou um bloco de
código. Cada instância da classe Object (e suas subclasses) possui um bloqueio que é obtido na entrada de um método
sincronizado e liberado automaticamente na saída. A versão do bloco de código funciona da mesma maneira, exceto pelo
fato de receber um argumento informando qual objeto bloquear. O argumento mais comum é este, que significa travar o objeto
cujo método está sendo executado. Quando um bloqueio é mantido por um encadeamento, outros encadeamentos devem
bloquear, esperando que o encadeamento retentor libere o bloqueio.
O bloqueio não tem efeito em métodos não sincronizados, que podem ser executados mesmo se o bloqueio estiver sendo
mantido por outro thread.

O bloqueio fornece proteção contra conflitos de alto e baixo nível, reforçando a atomicidade entre métodos e blocos de
código sincronizados no mesmo objeto. Ações atômicas são executadas como unidades, sem qualquer
intercalação das ações de outras threads. Mas, conforme discutido na Seção 1.3.2 e no Capítulo 2, muitos bloqueios
também podem produzir problemas de ativação que fazem com que os programas congelem. Em vez de explorar esses
problemas em detalhes agora, contaremos com algumas regras padrão simples para escrever métodos que evitam problemas
de interferência:

Sempre bloqueie durante as atualizações nos campos do objeto.


Sempre bloqueie durante o acesso a campos de objetos possivelmente atualizados.
Nunca bloqueie ao invocar métodos em outros objetos.

Essas regras têm muitas exceções e refinamentos, mas fornecem orientação suficiente para escrever
Partícula:

import java.util.Random;

classe Partícula {
protegido int x;
protegido int y; final
protegido Random rng = new Random();

public Particle(int inicialX, int inicialY) { x = inicialX; y = inicialY; }

movimento nulo público sincronizado () {


x += rng.nextInt(10) - 5; y +=
rng.nextInt(20) - 10; }

public void draw(Gráficos g) { int lx, ly;


sincronizado
(este) { lx = x; ly = y; } g.drawRect(lx, ly, 10, 10); } }

Notas:
Machine Translated by Google

O uso de final na declaração do gerador de números aleatórios rng reflete nossa decisão de que
esse campo de referência não pode ser alterado, portanto, não é afetado por nossas regras de bloqueio.
Muitos programas concorrentes usam final extensivamente, em parte como uma documentação
útil e aplicada automaticamente de decisões de projeto que reduzem a necessidade de sincronização
(consulte § 2.1).
O método draw precisa obter um instantâneo consistente dos valores x e y. Como um único método
pode retornar apenas um valor por vez e precisamos dos valores x e y aqui, não podemos encapsular
facilmente os acessos de campo como um método sincronizado . Em vez disso, usamos um bloco
sincronizado . (Consulte o § 2.4 para algumas alternativas.)
O método draw está em conformidade com nossa regra geral para liberar bloqueios durante invocações
de método em outros objetos (aqui g.drawRect). O método move parece quebrar essa regra
chamando rng.nextInt. No entanto, esta é uma escolha razoável aqui porque cada partícula
limita seu próprio rng conceitualmente, o rng é apenas uma parte da própria partícula ,
portanto não conta como um "outro" objeto na regra. A seção § 2.3 descreve condições mais gerais
sob as quais esse tipo de raciocínio se aplica e discute fatores que devem ser levados em consideração
para garantir que essa decisão seja justificada.

1.1.1.2 ParticleCanvas

ParticleCanvas é uma subclasse simples de java.awt.Canvas que fornece uma área de desenho para todas as
partículas. Sua principal responsabilidade é invocar draw para todas as partículas existentes sempre que
seu método paint é chamado.

No entanto, o próprio ParticleCanvas não cria ou gerencia as partículas. Ele precisa ser informado sobre eles
ou perguntar sobre eles. Aqui, escolhemos o primeiro.

A variável de instância partículas contém a matriz de objetos Particle existentes. Este campo é definido quando
necessário pelo applet, mas é utilizado no método paint . Podemos novamente aplicar nossas regras padrão,
que neste caso levam à criação de métodos get e set pouco sincronizados (também conhecidos como métodos de
acesso e atribuição) para partículas, evitando o acesso direto à própria variável de partículas . Para simplificar
e impor o uso adequado, o campo de partículas nunca pode ser nulo. Em vez disso, é inicializado em uma matriz
vazia:

classe ParticleCanvas estende Canvas {

private Partícula[] partículas = new Partícula[0];

ParticleCanvas(tamanho int) {
setSize(nova Dimensão(tamanho, tamanho)); }

// destinado a ser chamado pelo applet sincronizado


void setParticles(Particle[] ps) {
se (ps == nulo)
throw new IllegalArgumentException("Não é possível definir nulo");

partículas = ps; }
Machine Translated by Google

Particle[] sincronizado protegido getParticles() {


partículas de retorno; }

public void paint(Graphics g) { // substitui Canvas.paint


Particle[] ps = getParticles();

for (int i = 0; i < ps.length; ++i) ps[i].draw(g);

}
}

1.1.1.3 PartículaApplet

As classes Particle e ParticleCanvas podem ser usadas como base para vários programas diferentes.
Mas no ParticleApplet tudo o que queremos fazer é definir cada uma de uma coleção de partículas
em movimento "contínuo" autônomo e atualizar a exibição de acordo para mostrar onde elas estão.
Para obedecer às convenções de applet padrão, essas atividades devem começar quando Applet.start
for invocado externamente (normalmente de um navegador da Web) e devem terminar quando
Applet.stop for invocado. (Também poderíamos adicionar botões que permitissem aos
usuários iniciar e parar a animação de partículas por conta própria.)

Existem várias maneiras de implementar tudo isso. Uma das mais simples é associar um loop
independente a cada partícula e executar cada ação de loop em uma thread diferente.

As ações a serem executadas em novos encadeamentos devem ser definidas nas


classes que implementam java.lang.Runnable. Esta interface lista apenas o único método executado,
sem argumentos, sem retornar resultados e sem lançar exceções verificadas:

interface pública java.lang.Runnable { void run(); }

Uma interface encapsula um conjunto coerente de serviços e atributos (em termos gerais, uma
função) sem atribuir essa funcionalidade a nenhum objeto ou código específico. As interfaces são
mais abstratas do que as classes, pois não dizem nada sobre representações ou código. Tudo o
que eles fazem é descrever as assinaturas (nomes, argumentos, tipos de resultado e exceções) de
operações públicas, sem nem mesmo fixar as classes dos objetos que podem executá-las. As classes
que podem oferecer suporte a Runnable normalmente não têm nada em comum, exceto que contêm um método run .

Cada instância da classe Thread mantém o estado de controle necessário para executar e
gerenciar a sequência de chamada que compreende sua ação. O construtor mais comumente
usado na classe Thread aceita um objeto Runnable como um argumento, que organiza a
chamada do método run do Runnable quando o thread é iniciado. Embora qualquer classe
possa implementar Runnable, geralmente é conveniente e útil definir um Runnable como uma classe interna anônima.

A classe ParticleApplet usa threads dessa maneira para colocar as partículas em


movimento e as cancela quando o applet é concluído. Isso é feito substituindo os métodos padrão do Applet start
Machine Translated by Google

e stop (que têm os mesmos nomes, mas não estão relacionados aos métodos Thread.start e
Thread.stop).

O diagrama de interação acima mostra as principais sequências de mensagens durante a


execução do applet. Além das threads explicitamente criadas, este applet interage com a thread
de evento AWT, descrita com mais detalhes em § 4.1.4. A relação produtor-consumidor que se
estende do lado direito omitido do diagrama de interação assume a forma aproximada:

public class ParticleApplet extends Applet {

protegido Thread[] threads = nulo; // nulo quando não está em execução


Machine Translated by Google

tela ParticleCanvas final protegida = new ParticleCanvas(100);

public void init() { add(canvas); }

Thread protegido makeThread(final Particle p) { // utilitário


Runloop executável = new Runnable() {
public void run() { tente
{ for(;;)
{
p.move();
canvas.repaint();
Thread.sleep(100); // 100msec é arbitrário }

} catch (InterruptedException e) { return; } } }; return novo

Thread(runloop); }

público sincronizado void start() { int n = 10; // apenas


para demonstração

if (threads == null) { // ignora se já iniciado


Partícula[] partículas = new Partícula[n]; for (int i = 0; i < n; +
+i)
partículas[i] = new Partícula(50, 50);
canvas.setParticles(partículas);

threads = new Thread[n]; for (int i =


0; i < n; ++i) { threads[i] =
makeThread(partículas[i]); threads[i].start(); } } }

public sincronizado void stop() { if (threads != null)


{ // ignora se já parou por (int i = 0; i < threads.length; ++i) threads[i].interrupt();

tópicos = nulo; }

}
}

Notas:
Machine Translated by Google

A ação em makeThread define um loop "forever" (que algumas pessoas preferem escrever de forma
equivalente como "while (true)") que é interrompido somente quando o thread atual é interrompido.
Durante cada iteração, a partícula se move, diz à tela para repintar para que o movimento seja exibido e, em
seguida, não faz nada por um tempo, para desacelerar as coisas a uma taxa visível para humanos.
Thread.sleep pausa o thread atual. Posteriormente, é retomado por um temporizador do sistema.

Uma razão pela qual as classes internas são convenientes e úteis é que elas capturam todas as variáveis
de contexto apropriadas aqui p e canvas sem a necessidade de criar uma classe separada com campos que
registram esses valores. Essa conveniência tem o preço de um pequeno embaraço: todos os
argumentos de método capturados e variáveis locais devem ser declarados como finais, como garantia
de que os valores podem realmente ser capturados sem ambiguidade. Caso contrário, por exemplo, se p fosse
reatribuído após a construção do Runnable dentro do método makeThread, seria ambíguo usar
o valor original ou atribuído ao executar o Runnable.

A chamada para canvas.repaint não invoca canvas.paint diretamente. Em vez disso, o método repaint
coloca um UpdateEvent em um java.awt.EventQueue.
(Isso pode ser otimizado internamente e posteriormente manipulado para eliminar eventos duplicados.) Um
java.awt.EventDispatchThread pega esse evento de forma assíncrona da fila e o despacha (finalmente) invocando
canvas.paint. Este thread e possivelmente outros threads criados pelo sistema podem existir mesmo em
programas nominalmente de thread único.
A atividade representada por um objeto Thread construído não começa até a invocação do método Thread.start .

Conforme discutido em § 3.1.2, existem várias maneiras de interromper a atividade de uma thread. O
mais simples é apenas fazer com que o método run termine normalmente. Mas em métodos de loop
infinito, a melhor opção é usar Thread.interrupt. Um encadeamento interrompido será abortado
automaticamente (através de uma InterruptedException) dos métodos Object.wait, Thread.join
e Thread.sleep. Os chamadores podem capturar essa exceção e executar qualquer ação apropriada para
desligar. Aqui, a captura em runloop apenas faz com que o método run saia, o que, por sua vez, faz com que o
thread seja encerrado.
Os métodos start e stop são sincronizados para impedir partidas ou paradas simultâneas. O bloqueio
funciona bem aqui, embora esses métodos precisem executar muitas operações (incluindo chamadas
para outros objetos) para obter as transições de estado iniciado para interrompido ou interrompido para
iniciado. A nulidade de threads variáveis é usada como um indicador de estado conveniente.

1.1.2 Mecânica da Rosca


Uma thread é uma sequência de chamada que executa independentemente de outras, enquanto ao mesmo tempo
possivelmente compartilha recursos subjacentes do sistema, como arquivos, bem como acessa outros objetos
construídos dentro do mesmo programa (consulte § 1.2.2) . Um objeto java.lang.Thread mantém a contabilidade e o controle
dessa atividade.

Todo programa consiste em pelo menos um thread aquele que executa o método principal da classe fornecido
como um argumento de inicialização para a máquina virtual Java ("JVM"). Outros encadeamentos de segundo
plano internos também podem ser iniciados durante a inicialização da JVM. O número e a natureza desses
encadeamentos variam entre as implementações da JVM. No entanto, todos os encadeamentos de nível de usuário
são explicitamente construídos e iniciados a partir do encadeamento principal ou de qualquer outro encadeamento que eles, por sua vez, criem.

Aqui está um resumo dos principais métodos e propriedades da classe Thread, bem como algumas notas de uso. Eles
são mais discutidos e ilustrados ao longo deste livro. A Linguagem Java
Machine Translated by Google

A especificação ("JLS") e a documentação API publicada devem ser consultadas para descrições mais detalhadas e
oficiais.

1.1.2.1 Construção

Construtores Thread diferentes aceitam combinações de argumentos fornecendo:

Um objeto Runnable , caso em que um Thread.start subsequente invoca a execução do objeto Runnable
fornecido . Se nenhum Runnable for fornecido, a implementação padrão de Thread.run retornará
imediatamente.
Uma String que serve como um identificador para o Thread. Isso pode ser útil para rastreamento e
depuração, mas não desempenha nenhuma outra função.
O ThreadGroup no qual o novo Thread deve ser colocado. Se o acesso ao ThreadGroup não for
permitido, uma SecurityException será lançada.

A própria classe Thread implementa Runnable. Portanto, em vez de fornecer o código a ser executado em um
Runnable e usá-lo como argumento para um construtor Thread , você pode criar uma subclasse de Thread que
substitui o método run para executar as ações desejadas. No entanto, a melhor estratégia padrão é definir um
Runnable como uma classe separada e fornecê-lo em um construtor Thread .
Isolar o código dentro de uma classe distinta evita que você se preocupe com possíveis interações de métodos
ou blocos sincronizados usados no Runnable com qualquer um que possa ser usado por métodos da classe
Thread. De forma mais geral, essa separação permite controle independente sobre a natureza da ação e o contexto em
que ela é executada: o mesmo Runnable pode ser fornecido para threads que são inicializados de maneiras diferentes,
bem como para outros executores leves (consulte § 4.1 .4). Observe também que a subclasse Thread impede que uma
classe crie subclasses de qualquer outra classe.

Os objetos Thread também possuem um atributo de status daemon que não pode ser definido por meio de
nenhum construtor Thread , mas pode ser definido apenas antes de um Thread ser iniciado. O método setDaemon
afirma que a JVM pode sair, encerrando abruptamente o encadeamento, desde que todos os outros
encadeamentos não daemon no programa tenham terminado. O método isDaemon retorna status. A utilidade do status
do daemon é muito limitada. Mesmo os threads em segundo plano geralmente precisam fazer alguma limpeza na
saída do programa. (A ortografia de daemon, geralmente pronunciada como "day-mon", é uma relíquia da
tradição de programação de sistemas. Daemons de sistema são processos contínuos, por exemplo, gerenciadores
de filas de impressão, que estão "sempre" presentes em um sistema.)

1.1.2.2 Tópicos iniciais

Invocar seu método start faz com que uma instância da classe Thread inicie seu método run como uma atividade
independente. Nenhum dos bloqueios de sincronização mantidos pelo thread chamador é mantido pelo novo thread
(consulte § 2.2.1).

Um Thread termina quando seu método run é concluído retornando normalmente ou


lançando uma exceção não verificada (ou seja, RuntimeException, Error ou uma de suas
subclasses). Os threads não podem ser reiniciados, mesmo depois de encerrados.
Invocar start mais de uma vez resulta em um InvalidThreadStateException.

O método isAlive retorna true se um thread foi iniciado, mas não foi finalizado. Ele retornará true se o thread

estiver meramente bloqueado de alguma forma. Sabe-se que as implementações de JVM diferem no ponto exato em
que isAlive retorna false para threads que foram cancelados (consulte
Machine Translated by Google

§ 3.1.2). Não há nenhum método que informe se um thread que não é isAlive já foi iniciado. Além disso, um
thread não pode determinar prontamente qual outro thread o iniciou, embora possa determinar as identidades
de outros threads em seu ThreadGroup (consulte § 1.1.2.6).

1.1.2.3 Prioridades

Para possibilitar a implementação da máquina virtual Java em diversas plataformas de hardware e sistemas
operacionais, a linguagem de programação Java não faz promessas sobre agendamento ou imparcialidade e nem
mesmo garante estritamente que os threads avancem (consulte § 3.4.1.5) . Mas as threads suportam métodos prioritários
que influenciam os escalonadores heuristicamente:

Cada Thread tem uma prioridade, variando entre Thread.MIN_PRIORITY e Thread.MAX_PRIORITY


(definido como 1 e 10 respectivamente).
Por padrão, cada novo thread tem a mesma prioridade do thread que o criou. O thread inicial associado
a um main por padrão tem prioridade Thread.NORM_PRIORITY (5).
A prioridade atual de qualquer thread pode ser acessada através do método getPriority.
A prioridade de qualquer thread pode ser alterada dinamicamente por meio do método setPriority. A
prioridade máxima permitida para um thread é limitada por seu ThreadGroup.

Quando houver mais threads executáveis (consulte § 1.3.2) do que CPUs disponíveis, um escalonador geralmente
tende a preferir executar aqueles com prioridades mais altas. A política exata pode variar e varia entre as plataformas.
Por exemplo, algumas implementações de JVM sempre selecionam o encadeamento com a prioridade atual mais alta
(com empates quebrados arbitrariamente). Algumas implementações de JVM mapeiam as dez prioridades de
encadeamento em um número menor de categorias suportadas pelo sistema, portanto, encadeamentos com prioridades diferentes podem ser trat
E alguns combinam prioridades declaradas com esquemas de envelhecimento ou outras políticas de agendamento para garantir
que até mesmo threads de baixa prioridade tenham a chance de serem executados. Além disso, definir prioridades pode, mas
não necessariamente, afetar o agendamento em relação a outros programas em execução no mesmo sistema de computador.

As prioridades não têm outra influência na semântica ou correção (ver § 1.3). Em particular, as manipulações
de prioridade não podem ser usadas como um substituto para o bloqueio. As prioridades podem ser usadas
apenas para expressar a importância ou urgência relativa de diferentes threads, onde essas indicações de prioridade
seriam úteis para levar em consideração quando houver forte contenção entre as threads tentando obter uma chance
de execução. Por exemplo, definir as prioridades dos encadeamentos de animação de partículas no ParticleApplet abaixo
do encadeamento do applet que os constrói pode, em alguns sistemas, melhorar a capacidade de resposta aos cliques
do mouse e, pelo menos, não prejudicar a capacidade de resposta de outros. Mas os programas devem ser projetados
para serem executados corretamente (embora talvez não de maneira tão responsiva), mesmo que setPriority
seja definido como não operacional. (Observações semelhantes valem para o rendimento; ver § 1.1.2.5.)

A tabela a seguir fornece um conjunto de convenções gerais para vincular categorias de tarefas a configurações
de prioridade. Em muitos aplicativos simultâneos, relativamente poucos threads são realmente executáveis em um
determinado momento (outros são todos bloqueados de alguma forma), caso em que há poucos motivos para manipular prioridades.
Em outros casos, pequenos ajustes nas configurações de prioridade podem desempenhar um papel pequeno no
ajuste final de um sistema simultâneo.

Alcance Usar

10 Gerenciamento de crise
7-9 Interativo, orientado a eventos
4-6 IO-bound

2-3 computação em segundo plano


Machine Translated by Google

1 Corra apenas se nada mais puder

1.1.2.4 Métodos de controle

Apenas alguns métodos estão disponíveis para comunicação entre threads:

Cada Thread tem um estado de interrupção booleano associado (ver § 3.1.2). Invocar
t.interrupt para algum Thread t define o status de interrupção de t como true, a menos que
Thread t esteja engajado em Object.wait, Thread.sleep ou Thread.join; neste caso, a interrupção faz
com que essas ações (em t) lancem InterruptedException, mas o status de interrupção de t é definido
como falso.
O status de interrupção de qualquer Thread pode ser inspecionado usando o método isInterrupted.
Este método retorna true se o thread foi interrompido pelo método de interrupção , mas o status não
foi redefinido desde então pelo thread que invocou Thread.interrupted
(consulte o § 1.1.2.5) ou no curso de wait, sleep ou join lançando InterruptedException .

Invocar t.join() para Thread t suspende o chamador até que o Thread t de destino seja concluído:
a chamada para t.join() retorna quando t.isAlive() é falso (consulte § 4.3.2).
Uma versão com um argumento de tempo (milissegundos) retorna o controle mesmo que o
thread não tenha sido concluído dentro do limite de tempo especificado. Por causa de como isAlive é
definido, não faz sentido invocar join em um thread que não foi iniciado. Por razões semelhantes,
não é aconselhável tentar entrar em um tópico que você não criou.

Originalmente, a classe Thread suportava os métodos de controle adicionais suspend, resume, stop e
destroy. Os métodos suspend, resume e stop já foram obsoletos; O método destroy nunca foi implementado
em nenhuma versão e provavelmente nunca será. Os efeitos dos métodos suspend e resume podem ser
obtidos de forma mais segura e confiável usando as técnicas de espera e notificação discutidas em § 3.2.
Os problemas relacionados à parada são discutidos no § 3.1.2.3.

1.1.2.5 Métodos estáticos

Alguns métodos da classe Thread podem ser aplicados apenas ao thread que está em execução no momento
(ou seja, o thread que faz a chamada para o método Thread ). Para impor isso, esses métodos são
declarados como estáticos.

Thread.currentThread retorna uma referência ao Thread atual. Essa referência pode então ser usada
para invocar outros métodos (não estáticos). Por exemplo,
Thread.currentThread().getPriority() retorna a prioridade do thread que faz a chamada.

Thread.interrupted limpa o status de interrupção do Thread atual e retorna o status anterior. (Assim,
o status de interrupção de um Thread não pode ser removido de outros Threads.)

Thread.sleep(long msecs) faz com que o thread atual seja suspenso por pelo menos msecs
milissegundos (consulte § 3.2.2).
Thread.yield é uma dica puramente heurística que avisa a JVM de que, se houver qualquer outro
encadeamento executável, mas não em execução, o planejador deve executar um ou mais
desses encadeamentos em vez do encadeamento atual. A JVM pode interpretar essa dica da maneira que desejar.
Machine Translated by Google

Apesar da falta de garantias, o rendimento pode ser pragmaticamente eficaz em algumas implementações
de JVM de CPU única que não usam programação preventiva fatiada no tempo (consulte § 1.2.2). Nesse caso, os
threads são reprogramados apenas quando um deles é bloqueado (por exemplo, em IO ou via sleep). Nesses
sistemas, os encadeamentos que executam cálculos sem bloqueio demorados podem ocupar uma CPU
por períodos prolongados, diminuindo a capacidade de resposta de um aplicativo. Como proteção, os
métodos que executam cálculos sem bloqueio que podem exceder os tempos de resposta aceitáveis para
manipuladores de eventos ou outros encadeamentos reativos podem inserir rendimentos (ou talvez até
suspender) e, quando desejável, também executar em configurações de prioridade mais baixa. Para minimizar
o impacto desnecessário, você pode invocar o rendimento apenas ocasionalmente; por exemplo, um loop pode conter:

if (Math.random() < 0.01) Thread.yield();

Em implementações de JVM que empregam políticas de agendamento preventivas, especialmente


aquelas em multiprocessadores, é possível e até desejável que o agendador simplesmente ignore essa
dica fornecida por yield.

1.1.2.6 Grupos de Tópicos

Cada Thread é construído como um membro de um ThreadGroup, por padrão, o mesmo grupo do Thread que
emite o construtor para ele. ThreadGroups se aninham de forma semelhante a uma árvore. Quando um objeto
constrói um novo ThreadGroup, ele é aninhado em seu grupo atual. O método getThreadGroup
retorna o grupo de qualquer thread. A classe ThreadGroup , por sua vez, oferece suporte a métodos como
enumerate que indicam quais threads estão atualmente no grupo.

Uma das finalidades da classe ThreadGroup é oferecer suporte a políticas de segurança que restringem
dinamicamente o acesso a operações Thread ; por exemplo, para tornar ilegal interromper um thread que não
esteja em seu grupo. Esta é uma parte de um conjunto de medidas de proteção contra problemas que podem
ocorrer, por exemplo, se um applet tentar matar o thread de atualização da exibição da tela principal.
ThreadGroups também podem colocar um teto na prioridade máxima que qualquer thread membro pode possuir.

ThreadGroups tendem a não ser usados diretamente em programas baseados em thread. Na maioria dos
aplicativos, as classes de coleção normais (por exemplo, java.util.Vector) são melhores opções para
rastrear grupos de objetos Thread para propósitos dependentes do aplicativo.

Entre os poucos métodos ThreadGroup que normalmente entram em jogo em programas simultâneos está o
método uncaughtException, que é invocado quando um thread em um grupo termina devido a uma exceção
não detectada e não verificada (por exemplo, um NullPointerException). Este método normalmente faz
com que um rastreamento de pilha seja impresso.

1.1.3 Leituras Adicionais


Este livro não é um manual de referência sobre a linguagem de programação Java. (Também não é
exclusivamente um guia tutorial de como fazer, ou um livro acadêmico sobre simultaneidade, ou um relatório
sobre pesquisa experimental, ou um livro sobre metodologia de design ou padrões de design ou linguagens de
padrão, mas inclui discussões sobre cada uma dessas facetas da simultaneidade .) A maioria das seções
termina com listas de recursos que fornecem mais informações sobre tópicos selecionados. Se você faz muita
programação concorrente, você vai querer ler mais sobre alguns deles.
Machine Translated by Google

O JLS deve ser consultado para relatos mais confiáveis das propriedades das construções da linguagem
de programação Java resumidas neste livro:

Gosling, James, Bill Joy e Guy Steele. The Java Language Specification, Addison-Wesley, 1996. No
momento em que este livro foi escrito, uma segunda edição do JLS foi projetada para conter esclarecimentos
e atualizações para a plataforma Java 2.

As contas introdutórias incluem:

Arnold, Ken e James Gosling. A Linguagem de Programação Java, Segunda Edição, Addison Wesley,
1998.

Se você nunca escreveu um programa usando threads, pode ser útil trabalhar com a versão on-line ou em
livro da seção Threads de:

Campione, Mary e Kathy Walrath. The Java Tutorial, segunda edição, Addison-Wesley, 1998.

Um guia conciso para a notação UML é:

Fowler, Martin, com Kendall Scott. UML Distilled, Segunda Edição, Addison-Wesley, 1999. As chaves do
diagrama UML nas páginas 3-4 deste livro foram extraídas com permissão.

Uma descrição mais extensa da UML é:

Rumbaugh, James, Ivar Jacobson e Grady Booch. O Manual de Referência da Linguagem de Modelagem
Unificada, Addison-Wesley, 1999.

1.2 Objetos e Simultaneidade


Há muitas maneiras de caracterizar objetos, simultaneidade e seus relacionamentos. Esta seção
discute várias perspectivas diferentes definicional, baseada em sistema, estilística e baseada em modelagem
que juntas ajudam a estabelecer uma base conceitual para a programação orientada a objetos concorrente.

1.2.1 Simultaneidade

Como a maioria dos termos de computação, "simultaneidade" é difícil de definir. Informalmente, um programa
concorrente é aquele que faz mais de uma coisa por vez. Por exemplo, um navegador da Web pode
executar simultaneamente uma solicitação HTTP GET para obter uma página HTML, reproduzir um clipe de
áudio, exibir o número de bytes recebidos de alguma imagem e iniciar um diálogo consultivo com um
usuário. No entanto, esta simultaneidade é por vezes uma ilusão. Em alguns sistemas de computador,
essas atividades diferentes podem, de fato, ser executadas por CPUs diferentes. Mas em outros sistemas
eles são todos executados por uma única CPU compartilhada de tempo que alterna entre
diferentes atividades com rapidez suficiente para que pareçam simultâneas, ou pelo menos intercaladas de forma não determinística, p

Uma definição mais precisa, embora não muito interessante, de programação simultânea pode ser
formulada operacionalmente: uma máquina virtual Java e seu sistema operacional (SO) subjacente
fornecem mapeamentos da aparente simultaneidade ao paralelismo físico (através de várias CPUs) ou falta
dela, permitindo as atividades prossigam em paralelo quando possível e desejável e, caso contrário,
por compartilhamento de tempo. A programação concorrente consiste em usar construções de programação
que são mapeadas dessa maneira. A programação simultânea na linguagem de programação Java envolve o uso de programação Java
Machine Translated by Google

construções de linguagem para esse efeito, em oposição às construções de nível de sistema que são usadas para criar
novos processos de sistema operacional. Por convenção, essa noção é ainda mais restrita a construções que afetam uma
única JVM, em oposição à programação distribuída, por exemplo, usando invocação de método remoto (RMI), que envolve
várias JVMs residindo em vários sistemas de computador.

A simultaneidade e as razões para empregá-la são melhor capturadas considerando a natureza de alguns tipos comuns de
aplicações concorrentes:

Serviços web. A maioria dos serviços da Web baseados em soquete (por exemplo, daemons HTTP, mecanismos de
servlet e servidores de aplicativos) são multiencadeados. Normalmente, a principal motivação para suportar várias
conexões simultâneas é garantir que novas conexões recebidas não precisem esperar a conclusão de outras. Isso geralmente
minimiza as latências do serviço e melhora a disponibilidade.

Trituração de números. Muitas tarefas de computação intensiva podem ser paralelizadas e, portanto, executadas mais
rapidamente se várias CPUs estiverem presentes. Aqui, o objetivo é maximizar o throughput explorando o
paralelismo.

Processamento de E/S. Mesmo em um computador nominalmente sequencial, os dispositivos que executam leituras e
gravações em discos, fios, etc., operam independentemente da CPU. Os programas simultâneos podem usar o tempo que
seria desperdiçado esperando por uma E/S lenta e, assim, fazer um uso mais eficiente dos recursos de um computador.

Simulação. Programas simultâneos podem simular objetos físicos com comportamentos autônomos independentes
que são difíceis de capturar em programas puramente sequenciais.

Aplicativos baseados em GUI. Embora a maioria das interfaces de usuário sejam intencionalmente de encadeamento
único, elas geralmente estabelecem ou se comunicam com serviços multiencadeados. A simultaneidade permite que os
controles do usuário permaneçam responsivos mesmo durante ações demoradas.

Software baseado em componentes. Componentes de software de grande granularidade (por exemplo, aqueles que
fornecem ferramentas de design, como editores de layout) podem construir threads internamente para auxiliar na contabilidade,
fornecer suporte multimídia, obter maior autonomia ou melhorar o desempenho.

Código móvel. Estruturas como o pacote java.applet executam o código baixado em encadeamentos separados como
parte de um conjunto de políticas que ajudam a isolar, monitorar e controlar os efeitos do código desconhecido.

Sistemas embarcados. A maioria dos programas executados em pequenos dispositivos dedicados executa controle em tempo real.
Vários componentes reagem continuamente a entradas externas de sensores ou outros dispositivos e produzem saídas
externas em tempo hábil. Conforme definido em The Java Language Specification, a plataforma Java não suporta controle
rígido em tempo real, no qual a correção do sistema depende de ações executadas em determinados prazos. Sistemas de
tempo de execução específicos podem fornecer as garantias mais fortes exigidas em alguns sistemas de tempo
real críticos para a segurança. Mas todas as implementações de JVM suportam controle soft em tempo real, no qual
pontualidade e desempenho são considerados problemas de qualidade de serviço em vez de problemas de correção
(consulte § 1.3.3). Isso reflete as metas de portabilidade que permitem que a JVM seja implementada em hardware e software
de sistema modernos e oportunistas.

1.2.2 Construções de Execução Simultânea

Threads são apenas uma das várias construções disponíveis para execução simultânea de código. A ideia de gerar uma
nova atividade pode ser mapeada para qualquer uma das várias abstrações ao longo de um contínuo de granularidade,
refletindo as compensações de autonomia versus sobrecarga. Projetos baseados em roscas nem sempre fornecem o
Machine Translated by Google

melhor solução para um dado problema de concorrência. A seleção de uma das alternativas discutidas abaixo
pode fornecer mais ou menos segurança, proteção, tolerância a falhas e controle administrativo, com mais ou
menos sobrecarga associada. As diferenças entre essas opções (e suas construções de suporte de programação
associadas) afetam as estratégias de design mais do que qualquer um dos detalhes que cercam cada uma.

1.2.2.1 Sistemas de computador

Se você tivesse um grande suprimento de sistemas de computador, poderia mapear cada unidade lógica de
execução para um computador diferente. Cada sistema de computador pode ser um uniprocessador, um
multiprocessador ou mesmo um cluster de máquinas administradas como uma única unidade e
compartilhando um sistema operacional comum. Isso fornece autonomia e independência ilimitadas. Cada
sistema pode ser administrado e controlado separadamente de todos os outros.

No entanto, construir, localizar, recuperar e passar mensagens entre essas entidades pode ser caro, as
oportunidades de compartilhamento de recursos locais são eliminadas e as soluções para problemas
envolvendo nomenclatura, segurança, tolerância a falhas, recuperação e acessibilidade são relativamente
pesadas em comparação com aqueles vistos em programas concorrentes. Portanto, essa escolha de mapeamento
normalmente é aplicada apenas para os aspectos de um sistema que requerem intrinsecamente uma solução
distribuída. E mesmo aqui, todos, exceto os menores dispositivos de computador embutidos, hospedam mais de um processo.

1.2.2.2 Processos

Um processo é uma abstração do sistema operacional que permite que um sistema de computador suporte
muitas unidades de execução. Cada processo normalmente representa um programa em execução separado; por
exemplo, uma JVM em execução. Como a noção de um "sistema de computador", um "processo" é uma abstração lógica, não física.
Assim, por exemplo, as ligações de processos para CPUs podem variar dinamicamente.

Os sistemas operacionais garantem algum grau de independência, ausência de interferência e segurança entre
os processos em execução concorrente. Os processos geralmente não têm permissão para acessar os locais de
armazenamento uns dos outros (embora geralmente haja algumas exceções) e, em vez disso, devem
se comunicar por meio de recursos de comunicação entre processos, como pipes. A maioria dos sistemas faz
pelo menos promessas de melhor esforço sobre como os processos serão criados e escalonados. Isso
quase sempre envolve o corte de tempo preventivo, suspendendo os processos periodicamente para dar a outros processos a chance de se

A sobrecarga para criar, gerenciar e comunicar entre os processos pode ser muito menor do que em soluções por
máquina. No entanto, como os processos compartilham recursos computacionais subjacentes (CPUs, memória,
canais de E/S e assim por diante), eles são menos autônomos. Por exemplo, uma falha de máquina causada por
um processo mata todos os processos.
Machine Translated by Google

1.2.2.3 Tópicos

Construções de thread de várias formas fazem mais compensações em autonomia, em parte por causa de menor
sobrecarga. As principais compensações são:

Compartilhamento. Os threads podem compartilhar o acesso à memória, arquivos abertos e outros recursos associados
a um único processo. Threads na linguagem de programação Java podem compartilhar todos esses recursos.
Alguns sistemas operacionais também suportam construções intermediárias, por exemplo, "processos leves" e
"encadeamentos de kernel" que compartilham apenas alguns recursos, fazem isso apenas mediante solicitação
explícita ou impõem outras restrições.

Agendamento. As garantias de independência podem ser enfraquecidas para apoiar políticas de agendamento mais
baratas. Em um extremo, todos os encadeamentos podem ser tratados juntos como um processo de encadeamento
único, caso em que eles podem lutar cooperativamente entre si, de modo que apenas um encadeamento seja executado
por vez, sem dar a nenhum outro encadeamento a chance de executar até que seja bloqueado (ver § 1.3.2). No
outro extremo, o escalonador subjacente pode permitir que todas as threads em um sistema compitam diretamente
umas com as outras por meio de regras de escalonamento preventivas. Os encadeamentos na linguagem de programação
Java podem ser escalonados usando qualquer política situada em ou em qualquer lugar entre esses extremos.

Comunicação. Os sistemas interagem por meio de comunicação através de fios ou canais sem fio, por exemplo, usando
soquetes. Os processos também podem se comunicar dessa maneira, mas também podem usar mecanismos mais leves,
como pipes e recursos de sinalização entre processos. Os threads podem usar todas essas opções, além de outras
estratégias mais baratas que dependem do acesso a locais de memória acessíveis em vários threads e empregam
recursos de sincronização baseados em memória, como bloqueios e mecanismos de espera e notificação. Essas
construções suportam uma comunicação mais eficiente, mas às vezes incorrem na despesa de maior complexidade
e, consequentemente, maior potencial de erro de programação.

1.2.2.4 Tarefas e estruturas executáveis leves

As compensações feitas nas roscas de suporte abrangem uma ampla gama de aplicações, mas nem sempre são
perfeitamente adequadas às necessidades de uma determinada atividade. Embora os detalhes de desempenho sejam
diferentes entre as plataformas, a sobrecarga na criação de um thread ainda é significativamente maior do que a maneira
mais barata (mas menos independente) de invocar um bloco de código chamando-o diretamente no thread atual.
Machine Translated by Google

Quando a criação de encadeamentos e a sobrecarga de gerenciamento se tornam preocupações de desempenho, você


pode fazer compensações adicionais em autonomia criando suas próprias estruturas de execução mais leves que
impõem mais restrições ao uso (por exemplo, proibindo o uso de certas formas de bloqueio), ou fazer menos garantias
de agendamento, ou restringir a sincronização e a comunicação a um conjunto mais limitado de escolhas.
Conforme discutido na Seção 4.1.4, essas tarefas podem então ser mapeadas para threads da mesma forma que as
threads são mapeadas para processos e sistemas de computador.

As estruturas executáveis leves mais familiares são sistemas e subsistemas baseados em eventos (consulte § 1.2.3, §
3.6.4 e § 4.1), nos quais as chamadas que disparam atividades conceitualmente assíncronas são mantidas
como eventos que podem ser enfileirados e processados por threads em segundo plano . Vários frameworks
executáveis leves adicionais são descritos no Capítulo 4. Quando se aplicam, a construção e o uso de tais frameworks
podem melhorar a estrutura e o desempenho de programas simultâneos. Seu uso reduz as preocupações (ver § 1.3.3)
que podem inibir o uso de técnicas de execução concorrente para expressar atividades logicamente
assíncronas e objetos logicamente autônomos (ver § 1.2.4).

1.2.3 Simultaneidade e Programação OO

Objetos e simultaneidade foram vinculados desde os primeiros dias de cada um. A primeira linguagem de
programação OO concorrente (criada por volta de 1966), Simula, também foi a primeira linguagem OO e estava entre as
primeiras linguagens concorrentes. As construções OO e de simultaneidade iniciais do Simula eram um tanto
primitivas e desajeitadas. Por exemplo, a simultaneidade foi baseada em construções semelhantes a threads de
corrotinas, exigindo que os programadores transferissem explicitamente o controle de uma tarefa para outra. Várias
outras linguagens fornecendo construções de simultaneidade e OO seguiram de fato, até mesmo algumas das primeiras
versões de protótipo do C++ incluíam algumas classes de biblioteca de suporte à simultaneidade. E Ada (embora,
em suas primeiras versões, dificilmente fosse uma linguagem OO) ajudou a trazer a programação concorrente para
fora do mundo dos sistemas e linguagens especializadas de baixo nível.

O design OO não desempenhou nenhum papel prático nas práticas de programação de sistemas multithread que
surgiram na década de 1970. E a simultaneidade não desempenhou nenhum papel prático na adoção em larga escala
da programação OO que começou na década de 1980. Mas o interesse na simultaneidade OO permaneceu vivo em
laboratórios de pesquisa e grupos de desenvolvimento avançado e ressurgiu como um aspecto essencial da
programação em parte devido à popularidade e onipresença da plataforma Java.

A programação OO simultânea compartilha a maioria dos recursos com programação de qualquer tipo. Mas difere
em aspectos críticos dos tipos de programação com os quais você pode estar mais familiarizado, conforme discutido abaixo.

1.2.3.1 Programação OO Sequencial

Os programas OO simultâneos geralmente são estruturados usando as mesmas técnicas de programação e padrões
de projeto que os programas OO sequenciais (consulte, por exemplo, § 1.4). Mas eles são intrinsecamente mais complexos.
Quando mais de uma atividade pode ocorrer ao mesmo tempo, a execução do programa é necessariamente não determinística.
O código pode executar em ordens surpreendentes, qualquer ordem que não seja explicitamente descartada é
permitida (consulte o § 2.2.7). Portanto, você nem sempre pode entender os programas simultâneos lendo
sequencialmente seu código. Por exemplo, sem mais precauções, um campo definido com um valor em uma linha de
código pode ter um valor diferente (devido às ações de alguma outra atividade simultânea) no momento em que a
próxima linha de código for executada. Lidar com esta e outras formas de interferência muitas vezes introduz a
necessidade de um pouco mais de rigor e uma visão mais conservadora do design.

1.2.3.2 Programação baseada em eventos


Machine Translated by Google

Algumas técnicas de programação simultânea têm muito em comum com aquelas em estruturas de eventos
empregadas em kits de ferramentas GUI suportados por java.awt e javax.swing, e em outras linguagens como Tcl/Tk e
Visual Basic. Em estruturas de GUI, eventos como cliques do mouse são encapsulados como objetos Event que são
colocados em um único EventQueue. Esses eventos são então despachados e processados um a um em um único
loop de eventos, que normalmente é executado como um thread separado. Conforme discutido em § 4.1, esse projeto pode
ser estendido para suportar concorrência adicional (entre outras táticas) criando vários encadeamentos de loop de
eventos, cada um processando eventos simultaneamente ou até mesmo despachando cada evento em um encadeamento
separado. Novamente, isso abre novas possibilidades de design, mas também introduz novas preocupações sobre
interferência e coordenação entre atividades simultâneas.

1.2.3.3 Programação de sistemas concorrentes

A programação simultânea orientada a objetos difere da programação de sistemas multithread em linguagens


como C, principalmente devido ao encapsulamento, modularidade, extensibilidade, segurança e recursos de segurança
ausentes em C. Além disso, o suporte à simultaneidade é incorporado à linguagem de programação Java, em vez de
fornecido por bibliotecas. Isso elimina a possibilidade de alguns erros comuns e também permite que os compiladores
executem automaticamente e com segurança algumas otimizações que precisariam ser executadas manualmente em C.

Embora as construções de suporte à simultaneidade na linguagem de programação Java sejam geralmente semelhantes
àquelas da biblioteca padrão POSIX pthreads e pacotes relacionados normalmente usados em C, existem algumas
diferenças importantes, especialmente nos detalhes de espera e notificação (consulte § 3.2.2) . É muito possível
usar classes utilitárias que agem quase como rotinas POSIX (consulte § 3.4.4). Mas geralmente é mais produtivo
fazer pequenos ajustes para explorar as versões que o idioma suporta diretamente.

1.2.3.4 Outras linguagens de programação concorrentes

Essencialmente, todas as linguagens de programação simultâneas são, em algum nível, equivalentes, apenas no sentido
de que todas as linguagens simultâneas são amplamente consideradas como não tendo definido os recursos de simultaneidade corretos.
No entanto, não é tão difícil fazer com que os programas em uma linguagem pareçam quase equivalentes aos de outras
linguagens ou que usam outras construções, desenvolvendo pacotes, classes, utilitários, ferramentas e convenções de
codificação que imitam recursos incorporados a outras. Ao longo deste livro, são apresentadas construções que
fornecem as capacidades e estilos de programação de sistemas baseados em semáforos (§ 3.4.1), futuros (§ 4.3.3),
paralelismo baseado em barreiras (§ 4.4.3), CSP ( § 4.5.1) e outros. É uma ótima ideia escrever programas usando
apenas um desses estilos, se isso atender às suas necessidades. No entanto, muitos designs, padrões,
estruturas e sistemas simultâneos têm heranças ecléticas e roubam boas ideias de qualquer lugar que possam.

1.2.4 Modelos e Mapeamentos de Objetos

As concepções de objetos geralmente diferem entre a programação OO sequencial e a concorrente, e até mesmo entre
os diferentes estilos de programação OO simultânea. A contemplação dos modelos e mapeamentos de objetos subjacentes
pode revelar a natureza das diferenças entre os estilos de programação sugeridos na seção anterior.

A maioria das pessoas gosta de pensar em objetos de software como modelos de objetos reais, representados
com algum grau arbitrário de precisão. A noção de "real" está, obviamente, nos olhos de quem vê, e muitas vezes
inclui artifícios que só fazem sentido dentro do reino da computação.
Machine Translated by Google

Para um exemplo simples, considere o esqueleto do diagrama de classe UML e o esboço de código para classe
Tanque de água:

class WaterTank // Esboço do código


{ capacidade de flutuação final;
flutuar volumecorrente = 0,0f;
Transbordamento do WaterTank;

WaterTank(float cap) { capacidade = cap; ... }

void addWater(float amount) lança OverflowException; void removeWater(float


amount) lança UnderflowException; }

A intenção aqui é representar, ou simular, uma caixa d'água com:

Atributos como capacidade e currentVolume, que são representados como campos de objetos WaterTank .
Podemos escolher apenas os atributos com os quais nos preocupamos em algum conjunto de contextos
de uso. Por exemplo, enquanto todos os tanques de água reais têm localizações, formas, cores e assim
por diante, esta classe lida apenas com volumes.
Machine Translated by Google

Restrições de estado invariante, como o fato de que o CurrentVolume sempre permanece entre zero e a
capacidade, e que a capacidade é não negativa e nunca muda após a construção.

Operações que descrevem comportamentos como os de addWater e removeWater. Essa escolha de


operações novamente reflete algumas decisões de projeto implícitas em relação à exatidão,
granularidade e precisão. Por exemplo, poderíamos ter escolhido modelar tanques de água no nível de
válvulas e interruptores, e poderíamos ter modelado cada molécula de água como um objeto que muda
de localização como resultado das operações associadas.
Conexões (e possíveis conexões) com outros objetos com os quais os objetos se comunicam, como
canos ou outros tanques. Por exemplo, o excesso de água encontrado em uma operação addWater
pode ser desviado para um tanque de transbordamento conhecido por cada tanque.
Pré-condições e pós-condições sobre os efeitos das operações, como regras que afirmam que é
impossível remover água de um tanque vazio ou adicionar água a um tanque cheio que não esteja
equipado com um tanque de transbordamento disponível.
Protocolos que restringem quando e como as mensagens (solicitações de operação) são processadas.
Por exemplo, podemos impor uma regra que no máximo uma mensagem addWater ou removeWater seja
processada em um determinado momento ou, alternativamente, uma regra declarando que as mensagens
removeWater são permitidas no meio de operações addWater .

1.2.4.1 Modelos de objetos

A classe WaterTank usa objetos para modelar a realidade. Os modelos de objetos fornecem regras e estruturas para
definir objetos de forma mais geral, abrangendo:

Estática. A estrutura de cada objeto é descrita (normalmente por meio de uma classe) em termos de atributos
internos (estado), conexões com outros objetos, métodos locais (internos) e métodos ou portas para aceitar
mensagens de outros objetos.

Encapsulamento. Os objetos têm membranas que separam seus interiores e exteriores. O estado interno pode ser
modificado diretamente apenas pelo próprio objeto. (Ignoramos por enquanto os recursos de linguagem que permitem
que essa regra seja quebrada.)
Machine Translated by Google

Comunicação. Os objetos se comunicam apenas por meio da passagem de mensagens. Os objetos emitem mensagens que acionam
ações em outros objetos. As formas dessas mensagens podem variar de simples chamadas processuais até aquelas transportadas
por meio de protocolos de comunicação arbitrários.

Identidade. Novos objetos podem ser construídos a qualquer momento (sujeito a restrições de recursos do sistema) por qualquer
objeto (sujeito a controle de acesso). Uma vez construído, cada objeto mantém uma identidade única que persiste ao longo
de sua vida.

Conexões. Um objeto pode enviar mensagens para outros se souber suas identidades. Alguns modelos contam com identidades de
canal em vez de ou além de identidades de objeto. Abstratamente, um canal é um veículo para passar mensagens. Dois objetos
que compartilham um canal podem passar mensagens por meio desse canal sem conhecer a identidade um do outro. Modelos e
linguagens OO típicos dependem de primitivas baseadas em objetos para invocações diretas de métodos, abstrações baseadas em
canais para IO e comunicação através de fios e construções como canais de eventos que podem ser vistos de qualquer
perspectiva.

Computação. Os objetos podem executar quatro tipos básicos de computação:

Aceite uma mensagem.


Atualize o estado interno.
Envie uma mensagem.
Crie um novo objeto.

Essa caracterização abstrata pode ser interpretada e refinada de várias maneiras. Por exemplo, uma maneira de implementar um
objeto WaterTank é construir um minúsculo dispositivo de hardware para fins especiais que apenas mantém os estados,
instruções e conexões indicados. Mas como este não é um livro sobre projeto de hardware, vamos ignorar essas opções e restringir
a atenção às alternativas baseadas em software.

1.2.4.2 Mapeamentos sequenciais

Os recursos de um computador comum de uso geral (uma CPU, um barramento, alguma memória e algumas portas IO) podem
ser explorados para que esse computador possa fingir que é qualquer objeto, por exemplo, um WaterTank . Isso
pode ser organizado carregando uma descrição de WaterTanks (através de um arquivo .class ) em uma JVM. A JVM pode então
construir uma representação passiva de uma instância e então interpretar as operações associadas. Essa estratégia de
mapeamento também se aplica no nível da CPU quando as operações são compiladas em código nativo em vez de interpretadas
como bytecodes. Também se estende a
Machine Translated by Google

programas que envolvem muitos objetos de classes diferentes, cada um carregado e instanciado conforme
necessário, fazendo com que a JVM registre sempre a identidade ("este") do objeto que está simulando no momento.

Em outras palavras, a própria JVM é um objeto, embora muito especial que pode fingir ser qualquer outro objeto.
(Mais formalmente, ela serve como uma Máquina de Turing Universal.) Embora observações semelhantes sejam
válidas para os mapeamentos usados na maioria das outras linguagens, objetos de classe e reflexão simplificam a
caracterização de objetos reflexivos que tratam outros objetos como dados.

Em um ambiente puramente sequencial, este é o fim da história. Mas antes de prosseguir, considere as restrições
no modelo de objeto genérico impostas por esse mapeamento. Em uma JVM sequencial, seria impossível simular
diretamente vários objetos waterTank que interagem simultaneamente. E porque toda a passagem de mensagens é
realizada por meio de invocação processual sequencial, não há necessidade de regras sobre se várias mensagens
podem ser processadas simultaneamente, elas nunca são de qualquer maneira. Assim, o processamento OO
sequencial limita os tipos de preocupações de design de alto nível que você pode expressar.

1.2.4.3 Objetos ativos

No outro extremo do espectro de mapeamento estão os modelos de objetos ativos (também conhecidos como
modelos de atores), nos quais cada objeto é autônomo. Cada um pode ser tão poderoso quanto uma JVM
sequencial. As representações internas de classes e objetos podem assumir as mesmas formas daquelas
usadas em estruturas passivas. Por exemplo, cada waterTank pode ser mapeado para um objeto ativo separado,
carregando uma descrição para uma JVM separada e, em seguida, permitindo que ele simule para sempre as ações definidas.

Modelos de objetos ativos formam uma visão comum de alto nível de objetos em sistemas orientados a
objetos distribuídos: Objetos diferentes podem residir em máquinas diferentes, portanto, a localização e o
domínio administrativo de um objeto geralmente são questões de programação importantes. Toda a passagem
de mensagens é organizada por meio de comunicação remota (por exemplo, via soquetes) que pode
obedecer a vários protocolos, incluindo mensagens unidirecionais (ou seja, mensagens que intrinsecamente
não exigem respostas), multicasts (envio simultâneo da mesma mensagem para vários destinatários) e trocas de
solicitação-resposta no estilo de procedimento.

Esse modelo também serve como uma visão orientada a objetos da maioria dos processos no nível do sistema
operacional, cada um dos quais é independente e compartilha o mínimo possível de recursos com outros processos (consulte o § 1.2.2) .

1.2.4.4 Modelos mistos


Machine Translated by Google

Os modelos e mapeamentos subjacentes ao suporte de simultaneidade na linguagem de programação Java ficam


entre os dois extremos de modelos passivos e ativos. Uma JVM completa pode ser composta por vários
encadeamentos, cada um dos quais age da mesma maneira que uma única JVM sequencial. No entanto, ao contrário
dos objetos ativos puros, todos esses encadeamentos podem compartilhar o acesso ao mesmo conjunto
de representações passivas subjacentes.

Esse estilo de mapeamento pode simular cada um dos extremos. Modelos sequenciais puramente passivos podem
ser programados usando apenas um thread. Modelos puramente ativos podem ser programados criando tantos
threads quantos objetos ativos, evitando situações em que mais de um thread pode acessar uma determinada
representação passiva (consulte § 2.3) e usando construções que fornecem os mesmos efeitos semânticos da passagem
remota de mensagens (ver § 4.1). No entanto, a maioria dos programas simultâneos ocupa um meio-termo.

Modelos OO simultâneos baseados em threads separam conceitualmente objetos passivos "normais" de objetos
ativos (threads). Mas os objetos passivos geralmente exibem reconhecimento de thread não visto na programação
sequencial, por exemplo, protegendo-se por meio de bloqueios. E os objetos ativos são mais simples do que os vistos
nos modelos de ator, suportando apenas algumas operações (como executar). Mas o projeto de sistemas OO
simultâneos pode ser abordado de qualquer uma dessas duas direções, aprimorando objetos passivos para viver
em um ambiente multithread ou simplificando objetos ativos para que possam ser expressos mais facilmente usando
construções de thread.

Uma razão para dar suporte a esse tipo de modelo de objeto é que ele mapeia de maneira direta e eficiente para
armazenar hardware e sistemas operacionais de uniprocessador e multiprocessador de memória compartilhada (SMP):
Threads podem ser vinculados a CPUs quando possível e desejável e, caso contrário, compartilhado; mapas de estado
de encadeamento local para registradores e CPUs; e as representações de objetos compartilhados são mapeadas para a memória principal compar
Machine Translated by Google

O grau de controle do programador sobre esses mapeamentos é uma distinção que separa muitas formas de
programação paralela da programação simultânea. A programação paralela clássica envolve etapas de design
explícitas para mapear threads, tarefas ou processos, bem como dados, para processadores físicos e seus
armazenamentos locais. A programação simultânea deixa a maioria das decisões de mapeamento para a JVM (e o sistema operacional su
Isso aumenta a portabilidade, em detrimento da necessidade de acomodar diferenças na qualidade da
implementação desses mapeamentos.

O compartilhamento de tempo é realizado aplicando o mesmo tipo de estratégia de mapeamento aos próprios
threads: as representações dos objetos Thread são mantidas e um escalonador organiza as trocas de
contexto nas quais o estado da CPU correspondente a um thread é salvo em sua representação de
armazenamento associada e restaurado de outro .

Vários outros refinamentos e extensões de tais modelos e mapeamentos são possíveis. Por exemplo,
aplicativos e sistemas de objetos persistentes geralmente dependem de bancos de dados para
manter representações de objetos em vez de depender diretamente da memória principal.

1.2.5 Leituras Adicionais


Existe uma literatura substancial sobre concorrência, variando de trabalhos sobre fundamentos teóricos a
guias práticos para o uso de aplicativos simultâneos específicos.

1.2.5.1 Programação simultânea

Os livros didáticos que apresentam detalhes sobre algoritmos simultâneos adicionais, estratégias de
programação e métodos formais não abordados neste livro incluem:

ANDRÉS, Gregório. Foundations of Multithreaded, Parallel, and Distributed Programming, Addison Wesley,
1999. Esta é uma atualização expandida de Concurrent Programming: Principles and Practice, de Andrews,
Benjamin Cummings, 1991.

Ben-Ari, M. Princípios de Programação Concorrente e Distribuída, Prentice Hall, 1990.

Bernstein, Arthur e Philip Lewis. Simultaneidade em Programação e Sistemas de Banco de Dados, Jones e
Bartlett, 1993.
Machine Translated by Google

Burns, Alan e Geoff Davis. Programação Concorrente, Addison-Wesley, 1993.

Bustard, David, John Elder e Jim Welsh. Estruturas de programas simultâneos, Prentice Hall, 1988.

Schneider, Fred. Sobre Programação Concorrente, Springer-Verlag, 1997.

As construções de simultaneidade encontradas na linguagem de programação Java têm suas raízes em


construções semelhantes descritas pela primeira vez por CAR Hoare e Per Brinch Hansen. Veja artigos deles e
de outros nas seguintes coleções:

Dahl, Ole-Johan, Edsger Dijkstra e CAR Hoare (eds.). Programação Estruturada, Academic Press, 1972.

Gehani, Narain e Andrew McGettrick (eds.). Programação Concorrente, Addison-Wesley, 1988.

Uma pesquisa comparativa de como algumas dessas construções são definidas e suportadas em diferentes
linguagens e sistemas pode ser encontrada em:

Buhr, Peter, Michel Fortier e Michael Coffin. "Classificação do monitor", ACM Computing Surveys, 1995.

As linguagens simultâneas orientadas a objetos, baseadas em objetos ou baseadas em módulos incluem


Simula, Modula-3, Mesa, Ada, Orca, Sather e Euclid. Mais informações sobre esses idiomas podem ser
encontradas em seus manuais, bem como em:

Birtwistle, Graham, Ole-Johan Dahl, Bjorn Myhrtag e Kristen Nygaard. Simula Begin, Auerbach Press, 1973.

Burns, Alan e Andrew Wellings. Simultaneidade em Ada, Cambridge University Press, 1995.

Holt, RC Concurrent Euclid, o Sistema Unix, e Tunis, Addison-Wesley, 1983.

Nelson, Greg (ed.). Programação de Sistemas com Modula-3, Prentice Hall, 1991.

Stoutamire, David e Stephen Omohundro. The Sather/pSather 1.1 Specification, Technical Report, University of
California at Berkeley, 1996.

Os livros que adotam diferentes abordagens para simultaneidade na linguagem de programação Java incluem:

Harley, Stephen. Programação simultânea usando Java, Oxford University Press, 1998. Isso requer uma
abordagem de sistemas operacionais para simultaneidade.

Holub, Allen. Taming Java Threads, Apress, 1999. Coleta as colunas do autor sobre tópicos na revista on-line
JavaWorld.

Lewis, Bill. Multithreaded Programming in Java, Prentice Hall, 1999. Isso apresenta um tratamento um
pouco mais leve de vários tópicos discutidos neste livro e fornece conexões mais estreitas com threads
POSIX.
Machine Translated by Google

Magee, Jeff e Jeff Kramer. Simultaneidade: modelos de estado e programas Java, Wiley, 1999. Isso fornece uma
ênfase mais forte na modelagem e análise.

A maioria dos livros, artigos e manuais sobre programação de sistemas usando threads concentra-se nos detalhes
daqueles em sistemas operacionais ou pacotes de threads específicos. Ver:

Butenhof, David. Programming with POSIX Threads, Addison-Wesley, 1997. Fornece as discussões mais completas
sobre a biblioteca de threads POSIX e como usá-la.

Lewis, Bil e Daniel Berg. Programação multithread com Pthreads, Prentice Hall, 1998.

Norton, Scott e Mark Dipasquale. Thread Time, Prentice Hall, 1997.

A maioria dos textos sobre sistemas operacionais e programação de sistemas descreve o projeto e a construção de
mecanismos de suporte subjacentes para construções de sincronização e encadeamento em nível de linguagem.
Veja, por exemplo:

Hanson, David. Interfaces e implementações C, Addison-Wesley, 1996.

Silberschatz, Avi e Peter Galvin. Conceitos de Sistemas Operacionais, Addison-Wesley, 1994.

Tanenbaum, André. Sistemas Operacionais Modernos, Prentice Hall, 1992.

1.2.5.2 Modelos

Dadas as diversas formas de simultaneidade vistas no software, não é surpreendente que tenha havido um grande
número de abordagens para a teoria básica da simultaneidade. Explicações teóricas de cálculos de processos,
estruturas de eventos, lógica linear, redes de Petri e lógica temporal têm relevância potencial para a compreensão
de sistemas OO concorrentes. Para uma visão geral da maioria das abordagens da teoria da simultaneidade,
consulte:

van Leeuwen, janeiro (ed.). Handbook of Theoretical Computer Science, Volume B, MIT Press, 1990.

Uma apresentação eclética (e ainda fresca) de modelos, técnicas de programação associadas e padrões de projeto,
ilustrados usando diversas linguagens e sistemas, é:

Filman, Robert e Daniel Friedman. Computação Coordenada. McGraw-Hill, 1984.

Existem várias linguagens OO concorrentes experimentais baseadas em objetos ativos, principalmente a família de
linguagens Actor. Ver:

Aga, Gul. ATORES: Um Modelo de Computação Concorrente em Sistemas Distribuídos, MIT Press, 1986.

Uma pesquisa mais extensa de abordagens orientadas a objetos para simultaneidade pode ser encontrada em:

Briot, Jean-Pierre, Rachid Guerraoui e Klaus-Peter Lohr. "Simultaneidade e distribuição em programação orientada a
objetos", Computing Surveys, 1998.

Documentos de pesquisa sobre modelos, sistemas e linguagens orientados a objetos podem ser encontrados em anais
de conferências OO, incluindo ECOOP, OOPSLA, COOTS, TOOLS e ISCOPE, bem como em simultaneidade
Machine Translated by Google

conferências como CONCUR e revistas como IEEE Concurrency. Além disso, as seguintes coleções
contêm capítulos que examinam muitas abordagens e questões:

Agha, Gul, Peter Wegner e Aki Yonezawa (eds.). Direções de Pesquisa em Programação Orientada a Objetos
Simultâneos, MIT Press, 1993.

Briot, Jean-Pierre, Jean-Marc Geib e Akinori Yonezawa (eds.). Computação paralela e distribuída baseada
em objetos, LNCS 1107, Springer Verlag, 1996.

Guerraoui, Rachid, Oscar Nierstrasz e Michel Riveill (eds.). Processamento distribuído baseado em objetos, LNCS 791,
Springer-Verlag, 1993.

Nierstrasz, Oscar e Dennis Tsichritzis (eds.). Composição de Software Orientado a Objetos, Prentice Hall, 1995.

1.2.5.3 Sistemas distribuídos

Textos sobre algoritmos distribuídos, protocolos e design de sistema incluem:

Barbosa, Valmir. Uma Introdução aos Algoritmos Distribuídos. Morgan Kaufmann, 1996.

Birman, Kenneth e Robbert von Renesse. Computação distribuída confiável com o Isis Toolkit, IEEE Press, 1994.

Coulouris, George, Jean Dollimore e Tim Kindberg. Sistemas Distribuídos: Conceitos e Design, Addison-Wesley,
1994.

Lynch, Nancy. Algoritmos Distribuídos, Morgan Kaufman, 1996.

Mullender, Sape (ed.), Distributed Systems, Addison-Wesley, 1993.

Raynal, Michel. Algoritmos e Protocolos Distribuídos, Wiley, 1988.

Para obter detalhes sobre programação distribuída usando RMI, consulte:

Arnold, Ken, Bryan O'Sullivan, Robert Scheifler, Jim Waldo e Ann Wollrath. A Especificação Jini, Addison-
Wesley, 1999.

1.2.5.4 Programação em tempo real

A maioria dos textos sobre programação em tempo real concentra-se em sistemas de tempo real rígidos nos
quais, para fins de correção, certas atividades devem ser executadas dentro de certas restrições de
tempo. A linguagem de programação Java não fornece primitivas que forneçam tais garantias, portanto, este livro não
aborda o agendamento de prazos, algoritmos de atribuição de prioridade e assuntos relacionados. Fontes sobre
design em tempo real incluem:

Burns, Alan e Andy Wellings. Real-Time Systems and Programming Languages, Addison-Wesley, 1997. Este livro ilustra
a programação em tempo real em Ada, occam e C e inclui um relato recomendado de problemas e
soluções de inversão de prioridade.
Machine Translated by Google

GOMAA, Hassan. Métodos de Design de Software para Sistemas Simultâneos e de Tempo Real, Addison-Wesley, 1993.

Levi, Shem-Tov e Ashok Agrawala. Projeto de sistema em tempo real, McGraw-Hill, 1990.

Selic, Bran, Garth Gullekson e Paul Ward. Modelagem Orientada a Objetos em Tempo Real, Wiley, 1995.

1.3 Forças de Projeto

Esta seção examina as preocupações de design que surgem no desenvolvimento de software simultâneo, mas
desempenham, na melhor das hipóteses, papéis secundários na programação sequencial. A maioria das apresentações
de construções e padrões de projeto posteriormente neste livro inclui descrições de como eles resolvem as forças
aplicáveis discutidas aqui (bem como outras menos diretamente ligadas à simultaneidade, como precisão, testabilidade e assim por diante).

Pode-se ter duas visões complementares de qualquer sistema OO, centrado no objeto e centrado na atividade:

Sob uma visão centrada no objeto, um sistema é uma coleção de objetos interconectados. Mas é uma coleção
estruturada, não uma sopa de objetos aleatórios. Os objetos agrupam-se em grupos, por exemplo, o grupo de objetos
que compreende um ParticleApplet, formando assim componentes e subsistemas maiores.

Sob uma visão centrada na atividade, um sistema é uma coleção de atividades possivelmente concorrentes. No nível
mais refinado, esses são apenas envios de mensagens individuais (normalmente, invocações de método). Eles, por
sua vez, se organizam em conjuntos de cadeias de chamadas, sequências de eventos, tarefas, sessões, transações e
threads. Uma atividade lógica (como executar o ParticleApplet) pode envolver muitos threads.
Em um nível superior, algumas dessas atividades representam casos de uso em todo o sistema.

Nenhuma visão sozinha fornece uma imagem completa de um sistema, uma vez que um determinado objeto pode estar
envolvido em várias atividades e, inversamente, uma determinada atividade pode abranger vários objetos. No entanto,
essas duas visões dão origem a dois conjuntos complementares de preocupações de correção, um centrado no objeto
e outro centrado na atividade:

Segurança. Nada de ruim acontece a um objeto.


Machine Translated by Google

Vivacidade. Eventualmente algo acontece dentro de uma atividade.

Falhas de segurança levam a um comportamento não intencional em tempo de execução, as coisas simplesmente começam
a dar errado. Falhas de vivacidade levam a nenhum comportamento, as coisas simplesmente param de funcionar. Infelizmente,
algumas das coisas mais fáceis que você pode fazer para melhorar as propriedades de vivacidade podem destruir as propriedades
de segurança e vice-versa. Acertar os dois pode ser um desafio.

Você deve equilibrar os efeitos relativos de diferentes tipos de falha em seus próprios programas. Mas é uma prática padrão de
engenharia (não apenas engenharia de software) colocar a ênfase principal do projeto na segurança. Quanto mais seu código
realmente importa, melhor é garantir que um programa não faça nada, em vez de algo que leve a um comportamento aleatório e até
perigoso.

Por outro lado, a maior parte do tempo gasto ajustando projetos simultâneos na prática geralmente envolve questões de
eficiência relacionadas à vivacidade. E às vezes há razões boas e conscienciosas para sacrificar seletivamente a segurança pela
vivacidade. Por exemplo, pode ser aceitável que as exibições visuais mostrem temporariamente um absurdo total devido à
execução simultânea descoordenada desenhando pixels perdidos, indicadores de progresso incorretos ou imagens que não
têm relação com as formas pretendidas se você tiver certeza de que esse estado de coisas logo será corrigido.

As questões de segurança e vivacidade podem ser estendidas para abranger duas categorias de preocupações de qualidade, uma
principalmente centrada no objeto e a outra principalmente centrada na atividade, que às vezes também estão em oposição
direta:

Reutilização. A utilidade de objetos e classes em vários contextos.

Desempenho. A medida em que as atividades são executadas com rapidez e rapidez.

O restante desta seção examina mais de perto a segurança, vivacidade, desempenho e reutilização em programas simultâneos.
Ele apresenta termos e definições básicas, juntamente com breves introduções a questões e táticas centrais que são revisitadas
e ampliadas ao longo deste livro.

1.3.1 Segurança

Práticas seguras de programação simultânea são generalizações de práticas seguras e seguras de programação sequencial. A
segurança em projetos simultâneos adiciona uma dimensão temporal às noções comuns de segurança de tipo.
Machine Translated by Google

Um programa com verificação de tipo pode não estar correto, mas pelo menos não faz coisas perigosas
como interpretar mal os bits que representam um float como se fossem uma referência de objeto. Da mesma
forma, um design simultâneo seguro pode não ter o efeito pretendido, mas pelo menos nunca encontra erros
devido à corrupção de representações por threads rivais.

Uma diferença prática entre segurança de tipo e segurança multithread é que a maioria das questões de segurança
de tipo pode ser verificada automaticamente por compiladores. Um programa que falha nas verificações de tempo
de compilação não pode nem ser executado. A maioria das questões de segurança multithread, no entanto, não
pode ser verificada automaticamente e, portanto, deve depender da disciplina do programador. Os métodos para
provar que os projetos são seguros estão fora do escopo deste livro (consulte as Leituras Adicionais). As técnicas
para garantir a segurança descritas aqui dependem de práticas de engenharia cuidadosas (incluindo várias
com raízes em formalismos) em vez de métodos formais propriamente ditos.

A segurança multithread também adiciona uma dimensão temporal às técnicas de design e programação
relacionadas à segurança. Práticas de programação segura desabilitam o acesso a determinadas operações em
objetos e recursos de determinados chamadores, aplicativos ou principais. O controle de simultaneidade
introduz a desativação transitória do acesso com base na consideração das ações que estão sendo executadas por
outros threads.

O principal objetivo na preservação da segurança é garantir que todos os objetos em um sistema mantenham estados
consistentes: estados nos quais todos os campos e todos os campos de outros objetos dos quais eles
dependem possuam valores legais e significativos. Às vezes, é preciso muito trabalho para definir exatamente o que
"legal" e "significativo" significam em uma determinada classe. Um caminho é primeiro estabelecer invariantes em
nível conceitual, por exemplo, a regra de que os volumes das caixas d'água devem estar sempre entre zero e suas
capacidades. Eles geralmente podem ser reformulados em termos de relacionamentos entre valores de campo nas classes concretas associada

Um objeto é consistente se todos os campos obedecem a seus invariantes. Todo método público em toda classe
deve levar um objeto de um estado consistente para outro. Objetos seguros podem ocasionalmente entrar em
estados inconsistentes transitoriamente no meio de métodos, mas eles nunca tentam iniciar novas ações quando
estão em estados inconsistentes. Se cada objeto for projetado para executar ações apenas quando for logicamente
capaz de fazê-lo e se toda a mecânica for implementada corretamente, você pode ter certeza de que um aplicativo
que usa esses objetos não encontrará nenhum erro devido à inconsistência do objeto.

Uma razão para ser mais cuidadoso com invariantes em programas concorrentes é que é muito mais fácil quebrá-los
inadvertidamente do que na maioria dos programas sequenciais. A necessidade de proteção contra os efeitos da
inconsistência surge mesmo em contextos sequenciais, por exemplo, ao processar exceções e retornos de
chamada e ao fazer autochamadas de um método em uma classe para outro. No entanto, essas questões tornam-
se muito mais centrais em programas concorrentes. Conforme discutido no § 2.2, as formas mais comuns de garantir
a consistência empregam técnicas de exclusão para garantir a atomicidade das ações públicas que cada ação executa
até a conclusão sem interferência de outras. Sem essa proteção, as inconsistências em programas
simultâneos podem resultar de condições de corrida que produzem conflitos de armazenamento no nível das células
de memória bruta:

Conflitos de leitura/gravação. Um thread lê um valor de um campo enquanto outro escreve nele. O valor visto pelo
thread de leitura é difícil de prever, pois depende de qual thread ganhou a "corrida" para acessar o campo
primeiro. Conforme discutido em § 2.2, o valor lido nem precisa ser um valor que já foi escrito por qualquer thread.

Conflitos de gravação/gravação. Dois threads tentam gravar no mesmo campo. O valor visto na próxima leitura é
novamente difícil ou impossível de prever.
Machine Translated by Google

É igualmente impossível prever as consequências das ações que são tentadas quando os objetos estão em estados
inconsistentes. Exemplos incluem:

Uma representação gráfica (por exemplo, de uma partícula) é exibida em um local que o objeto nunca ocupou.

O saldo de uma conta bancária está incorreto após uma tentativa de sacar dinheiro no meio de uma
transferência automática.

Seguir o próximo ponteiro de uma lista encadeada leva a um nó que nem mesmo está na lista.
Duas atualizações de sensor simultâneas fazem com que um controlador em tempo real execute uma ação
incorreta do efetor.

1.3.1.1 Atributos e restrições

Técnicas de programação seguras dependem de uma compreensão clara das propriedades e restrições necessárias
em torno das representações de objetos. Os desenvolvedores que não estão cientes dessas propriedades raramente
fazem um bom trabalho em preservá-las. Muitos formalismos estão disponíveis para declarar com precisão os predicados
que descrevem os requisitos (como discutido na maioria dos textos sobre métodos de projeto simultâneos listados
nas Leituras Adicionais). Estes podem ser muito úteis, mas aqui vamos manter a precisão suficiente sem
introduzir formalismos.

Os requisitos de consistência às vezes decorrem de definições de atributos conceituais de alto nível feitos durante o
projeto inicial de classes. Essas restrições normalmente são mantidas independentemente de como os atributos são
representados de forma concreta e acessados por meio de campos e métodos. Isso foi visto, por exemplo, no
desenvolvimento das classes WaterTank e Particle anteriormente neste capítulo. Aqui estão alguns outros exemplos,
muitos dos quais são revisitados com mais detalhes ao longo deste livro:

Uma conta bancária tem um saldo igual à soma de todos os depósitos e juros menos saques e taxas de serviço.

Um pacote tem um destino que deve ser um endereço IP válido.


Um contador tem um valor de contagem integral não negativo.
Uma fatura tem um PaymentDue que reflete as regras de um sistema de pagamento.
Um termostato tem uma temperatura igual à leitura mais recente do sensor.
Uma Forma tem uma localização, dimensão e cor que obedecem a um conjunto de diretrizes estilísticas para um
determinado kit de ferramentas GUI.
Um BoundedBuffer tem um elementCount que está sempre entre zero e uma capacidade.
Uma Pilha tem um tamanho e, quando não está vazia, um elemento superior.
Uma janela tem um propertySet que mantém os mapeamentos atuais de fontes, cor de fundo, etc.
Um intervalo tem um startDate que não é posterior ao seu endDate.

Embora tais atributos essencialmente sempre sejam mapeados de alguma forma para campos de objeto, as
correspondências não precisam ser diretas. Por exemplo, o topo de uma pilha geralmente não é mantido em uma variável,
mas em um elemento de matriz ou nó de lista encadeada. Além disso, alguns atributos podem ser calculados ("derivados")
por meio de outros; por exemplo, o atributo booleano descoberto de uma BankAccount pode ser calculado
comparando o saldo com zero.

1.3.1.2 Restrições de representação

Outras restrições e invariantes geralmente surgem à medida que decisões de implementação adicionais são feitas para
uma determinada classe. Campos declarados para manter uma estrutura de dados específica, para melhorar
Machine Translated by Google

desempenho, ou para outros fins de contabilidade interna, muitas vezes precisam respeitar conjuntos de invariantes.
Categorias amplas de campos e restrições incluem o seguinte:

Representações diretas de valor. Campos necessários para implementar atributos concretos. Por exemplo,
um Buffer pode ter um campo putIndex contendo a posição do índice do array para usar ao inserir o próximo
elemento adicionado.

Representações de valor em cache. Campos usados para eliminar ou minimizar a necessidade de cálculos ou
invocações de método. Por exemplo, em vez de calcular o valor de descoberto toda vez que for necessário, um
BankAccount pode manter um campo de descoberto que é verdadeiro se e somente se o saldo atual for menor
que zero.

Representações de estado lógico. Reflexões do estado de controle lógico. Por exemplo, um


BankCardReader pode ter um campo de cartão representando o cartão que está sendo lido no momento e um
campo de PIN válido registrando se o código de acesso PIN foi verificado. O campo CardReader validPIN
pode ser usado para rastrear o ponto em um protocolo no qual o cartão foi lido e validado com sucesso. Algumas
representações de estado assumem a forma de variáveis de função, controlando as respostas a todos de um conjunto
relacionado de métodos (às vezes aqueles declarados em uma única interface). Por exemplo, um objeto de
jogo pode alternar entre funções ativas e passivas, dependendo do valor de um campo whoTurn .

Variáveis de estado de execução. Campos que registram o estado dinâmico refinado de um objeto, por exemplo, o
fato de que uma determinada operação está em andamento. As variáveis de estado de execução podem
representar o fato de que uma determinada mensagem foi recebida, que a ação correspondente foi iniciada, que a
ação foi encerrada e que uma resposta à mensagem foi emitida. Uma variável de estado de execução geralmente
é um tipo enumerado com valores cujos nomes terminam em -ing; por exemplo, CONECTANDO,
ATUALIZANDO, AGUARDANDO. Outro tipo comum de variável de estado de execução é um contador que registra
o número de entradas ou saídas de algum método. Conforme discutido na Seção 3.2, os objetos em programas
concorrentes tendem a exigir mais dessas variáveis do que aqueles em contextos sequenciais, para ajudar a rastrear
e gerenciar o progresso de métodos que procedem de forma assíncrona.

Variáveis da história. Representações da história ou estados passados de um objeto. A representação mais


extensa é um log de histórico, registrando todas as mensagens já recebidas e enviadas, juntamente com
todas as ações internas correspondentes e mudanças de estado que foram iniciadas e concluídas.
Subconjuntos menos extensos são muito mais comuns. Por exemplo, uma classe BankAccount pode manter um
campo lastSavedBalance que contém o último valor de ponto de verificação e é usado ao reverter transações
canceladas.

Variáveis de rastreamento de versão. Um número inteiro, carimbo de data/hora, referência de objeto,


código de assinatura ou outra representação que indique a hora, a ordem ou a natureza da última alteração de
estado feita por um objeto. Por exemplo, um termostato pode incrementar um ReadingNumber
ou registrar o lastReadingTime ao atualizar sua temperatura.

Referências a conhecidos. Campos que apontam para outros objetos com os quais o host interage, mas que não
constituem o estado lógico do host: por exemplo, um destino de retorno de chamada de um
EventDispatcher ou um requestHandler delegado por um WebServer.

Referências a objetos de representação. Atributos que são mantidos conceitualmente por um objeto host, mas
na verdade são gerenciados por outros objetos auxiliares. Os campos de referência podem apontar para outros
objetos que auxiliam na representação do estado do objeto hospedeiro. Assim, o estado lógico de qualquer objeto pode incluir os estados de
Machine Translated by Google

objetos aos quais ele contém referências. Além disso, os próprios campos de referência fazem parte do estado
concreto do objeto hospedeiro (ver § 2.3.3). Qualquer tentativa de garantir a segurança deve levar essas
relações em consideração. Por exemplo:

Uma pilha pode ter um campo headOfLinkedList registrando o primeiro nó de uma lista que representa
a pilha.
Um objeto Person pode manter um campo homePageURL mantido como um objeto
java.net.URL .
O saldo de uma BankAccount pode ser mantido em um repositório central, caso em que a BankAccount
manteria um campo referente ao repositório (para perguntar sobre o saldo atual). Nesse caso, parte do
estado lógico de BankAccount é realmente gerenciado pelo repositório.

Um objeto pode conhecer seus atributos apenas por meio do acesso a listas de propriedades mantidas
por outros objetos.

1.3.2 Vivacidade

Uma maneira de construir um sistema seguro garantido é fazer com que nenhum objeto execute nenhum método e,
portanto, nunca encontre nenhum conflito. Mas esta não é uma forma muito produtiva de programação. As preocupações
com a segurança devem ser equilibradas pelas preocupações com a vivacidade[1].

[1]
Algumas propriedades de "vivacidade" podem ser interpretadas como propriedades de segurança de conjuntos de objetos de
encadeamento. Por exemplo, a liberdade de impasse pode ser definida como evitar o estado ruim no qual um conjunto de threads
espera infinitamente um pelo outro.
Machine Translated by Google

Em sistemas vivos, toda atividade eventualmente progride para a conclusão; todo método invocado eventualmente
é executado. Mas uma atividade pode (talvez apenas temporariamente) deixar de progredir por qualquer uma das
várias razões inter-relacionadas:

Bloqueando. Um método sincronizado bloqueia um thread porque outro thread mantém o bloqueio.

Esperando. Um método bloqueia (via Object.wait ou seus derivados) esperando por um evento, mensagem ou condição
que ainda não foi produzida em outro thread.

Entrada. Um método baseado em IO espera pela entrada que ainda não chegou de outro processo ou dispositivo.

contenção da CPU. Um encadeamento falha ao executar mesmo estando em um estado executável porque outros
encadeamentos, ou mesmo programas completamente separados em execução no mesmo computador, estão
ocupando a CPU ou outros recursos computacionais.

Falha. Um método em execução em um thread encontra uma exceção, erro ou falha prematura.

Bloqueios momentâneos no andamento do thread são geralmente aceitáveis. Na verdade, o bloqueio frequente de curta
duração é intrínseco a muitos estilos de programação concorrente.

O ciclo de vida de um encadeamento típico pode incluir vários bloqueios e reagendamentos transitórios:

No entanto, a falta permanente ou ilimitada de progresso geralmente é um problema sério. Exemplos de falhas
de vivacidade potencialmente permanentes descritas com mais profundidade em outras partes deste livro incluem:

Impasse. Dependências circulares entre fechaduras. No caso mais comum, a thread A detém um bloqueio para o
objeto X e então tenta adquirir o bloqueio para o objeto Y. Simultaneamente, a thread B já possui o bloqueio para o
objeto Y e tenta adquirir o bloqueio para o objeto X. Nenhuma das threads pode jamais fazer mais progressos
(ver § 2.2.5).

Sinais perdidos. Um thread permanece inativo porque começou a esperar depois que uma notificação para ativá-lo foi
produzida (consulte § 3.2.2).
Machine Translated by Google

Bloqueios de monitor aninhados. Uma thread em espera contém um bloqueio que seria necessário para qualquer
outra thread tentando ativá-la (consulte § 3.3.4).

Livelock. Uma ação repetida continuamente falha continuamente (consulte § 2.4.4.2).

Inanição. A JVM/OS sempre falha ao alocar tempo de CPU para um encadeamento. Isso pode ser devido a políticas de
agendamento ou até mesmo ataques hostis de negação de serviço no computador host (consulte § 1.1.2.3 e § 3.4.1.5).

Esgotamento de recursos. Um grupo de threads juntos contém um número finito de recursos. Um deles precisa de
recursos adicionais, mas nenhum outro thread abrirá mão de um (consulte § 4.5.1).

Falha distribuída. Uma máquina remota conectada por um soquete servindo como um InputStream trava ou fica
inacessível (consulte § 3.1).

1.3.3 Desempenho

As forças baseadas no desempenho ampliam as preocupações com a vitalidade. Além de exigir que todos os
métodos invocados eventualmente sejam executados, as metas de desempenho exigem que eles sejam executados logo
e rapidamente. Embora não consideremos neste livro sistemas de tempo real rígidos nos quais a falha na
execução em um determinado intervalo de tempo pode levar a erros catastróficos do sistema, quase todos os programas
simultâneos têm metas de desempenho implícitas ou explícitas.

Requisitos de desempenho significativos são declarados em termos de qualidades mensuráveis, incluindo as


seguintes métricas. Os objetivos podem ser expressos para tendências centrais (por exemplo, média,
mediana) de medições, bem como sua variabilidade (por exemplo, faixa, desvio padrão).

Taxa de transferência. O número de operações realizadas por unidade de tempo. As operações de interesse podem
variar de métodos individuais a execuções de programas inteiros. Na maioria das vezes, a taxa de transferência é
relatada não como uma taxa, mas como o tempo necessário para executar uma operação.

Latência. O tempo decorrido entre a emissão de uma mensagem (através, por exemplo, de um clique do mouse,
invocação de método ou conexão de soquete de entrada) e sua manutenção. Em contextos em que as operações são
uniformes, de thread único e solicitadas "continuamente", a latência é exatamente o inverso da taxa de transferência.
Porém, mais tipicamente, as latências de interesse refletem os tempos de resposta, os atrasos até que algo aconteça,
não necessariamente a conclusão completa de um método ou serviço.

Capacidade. O número de atividades simultâneas que podem ser suportadas para uma determinada taxa de
transferência mínima de destino ou latência máxima. Especialmente em aplicativos de rede, isso pode servir como
um indicador útil de disponibilidade geral, pois reflete o número de clientes que podem ser atendidos sem queda de
conexões devido a tempos limite ou estouros de fila de rede.

Eficiência. Taxa de transferência dividida pela quantidade de recursos computacionais (por exemplo, CPUs,
memória e dispositivos de E/S) necessários para obter essa taxa de transferência.

Escalabilidade. A taxa na qual a latência ou taxa de transferência melhora quando recursos (novamente, geralmente
CPUs, memória ou dispositivos) são adicionados a um sistema. As medidas relacionadas incluem a utilização a
porcentagem de recursos disponíveis que são aplicados a uma tarefa de interesse.

Degradação. A taxa na qual a latência ou taxa de transferência piora à medida que mais clientes, atividades ou operações
são adicionadas sem adicionar recursos.
Machine Translated by Google

A maioria dos projetos multithread aceita implicitamente uma pequena compensação de eficiência computacional inferior
para obter melhor latência e escalabilidade. O suporte à simultaneidade apresenta os seguintes tipos de sobrecarga e
contenção que podem tornar os programas mais lentos:

Fechaduras. Um método sincronizado geralmente requer maior sobrecarga de chamada do que um método não sincronizado.
Além disso, os métodos que frequentemente bloqueiam a espera de bloqueios (ou por qualquer outro motivo) são
executados mais lentamente do que aqueles que não o fazem.

Monitores. Object.wait, Object.notify, Object.notifyAll e os métodos derivados deles


(como Thread.join) podem ser mais caros do que outras operações básicas de suporte
de tempo de execução da JVM.

Tópicos. Criar e iniciar um Thread normalmente é mais caro do que criar um objeto comum e invocar um método nele.

Mudança de contexto. O mapeamento de encadeamentos para CPUs encontra sobrecarga de troca de contexto quando
uma JVM/OS salva o estado da CPU associado a um encadeamento, seleciona outro encadeamento para execução e
carrega o estado da CPU associado.

Agendamento. Cálculos e políticas subjacentes que selecionam qual encadeamento elegível executar adicionam
sobrecarga. Eles podem interagir ainda mais com outras tarefas do sistema, como processamento de eventos
assíncronos e coleta de lixo.

Localidade. Em multiprocessadores, quando vários threads em execução em diferentes CPUs compartilham acesso aos
mesmos objetos, o hardware de consistência de cache e o software de sistema de baixo nível devem comunicar os
valores associados entre os processadores.

Algorítmica. Alguns algoritmos sequenciais eficientes não se aplicam a configurações simultâneas. Por exemplo, algumas
estruturas de dados que dependem do cache funcionam apenas se for conhecido que exatamente um thread executa todas
as operações. No entanto, também existem algoritmos simultâneos alternativos eficientes para muitos problemas,
incluindo aqueles que abrem a possibilidade de maiores acelerações via paralelismo.

As despesas gerais associadas às construções de simultaneidade diminuem constantemente à medida que as


JVMs melhoram. Por exemplo, até o momento em que este livro foi escrito, o custo indireto de uma única chamada de método
sincronizada não contida com um corpo não operacional em JVMs recentes é da ordem de algumas chamadas não
operacionais não sincronizadas. (Como diferentes tipos de chamadas, por exemplo, métodos estáticos versus métodos
de instância, podem levar tempos diferentes e interagir com outras otimizações, não vale a pena tornar isso mais preciso.)

No entanto, essas despesas gerais tendem a degradar de forma não linear. Por exemplo, usar um bloqueio que é
freqüentemente mantido por dez encadeamentos provavelmente levará a um desempenho geral muito pior do que ter cada
encadeamento passando por dez bloqueios não contidos. Além disso, como o suporte à simultaneidade envolve o
gerenciamento de recursos do sistema subjacente que geralmente é otimizado para determinadas cargas de destino, o
desempenho pode diminuir drasticamente quando muitos bloqueios, operações de monitoramento ou encadeamentos são usados.

Os capítulos subseqüentes incluem discussões sobre como minimizar o uso das construções associadas quando
necessário. No entanto, tenha em mente que problemas de desempenho de qualquer tipo podem ser remediados somente
após serem medidos e isolados. Sem evidências empíricas, a maioria das suposições sobre a natureza e a origem dos
problemas de desempenho estão erradas. As medições mais úteis são comparativas, mostrando diferenças ou
tendências sob diferentes projetos, cargas ou configurações.
Machine Translated by Google

1.3.4 Reutilização

Uma classe ou objeto é reutilizável na medida em que pode ser prontamente empregado em diferentes contextos, seja
como um componente de caixa preta ou como a base da extensão de caixa branca por meio de subclasses e técnicas
relacionadas.

A interação entre as preocupações de segurança e vivacidade pode afetar significativamente a reutilização. Geralmente é
possível projetar componentes para serem seguros em todos os contextos possíveis. Por exemplo, um
método sincronizado que se recusa a iniciar até que possua o bloqueio de sincronização fará isso, não importa como seja
usado. Mas, em alguns desses contextos, os programas que usam esse componente seguro podem encontrar falhas de
atividade (por exemplo, impasse). Por outro lado, a funcionalidade em torno de um componente usando apenas métodos não
sincronizados sempre estará ativa (pelo menos com relação ao bloqueio), mas pode encontrar violações de segurança
quando várias execuções simultâneas podem ocorrer.

As dualidades de segurança e vivacidade são refletidas em algumas visões extremas da metodologia de projeto. Algumas
estratégias de design de cima para baixo adotam uma abordagem de segurança em primeiro lugar pura: certifique-se de que
cada classe e objeto seja seguro e, posteriormente, tente melhorar a vivacidade como uma medida de otimização. Uma
abordagem oposta, de baixo para cima, às vezes é adotada na programação de sistemas multithread: certifique-se de que
o código esteja ativo e, em seguida, tente adicionar camadas aos recursos de segurança, por exemplo, adicionando
bloqueios. Nenhum dos extremos é especialmente bem-sucedido na prática. É muito fácil que as abordagens de cima
para baixo resultem em sistemas lentos e propensos a impasses, e as abordagens de baixo para cima resultem em códigos com erros e violações de seg

Geralmente é mais produtivo prosseguir com o entendimento de que alguns componentes muito úteis e eficientes não
são e não precisam ser absolutamente seguros e que serviços úteis suportados por alguns componentes não são
absolutamente ativos. Em vez disso, eles operam corretamente apenas dentro de certos contextos de uso restrito. Portanto,
estabelecer, documentar, anunciar e explorar esses contextos tornam-se questões centrais no design de software
concorrente.

Existem duas abordagens gerais (e uma variedade de escolhas intermediárias) para lidar com a dependência do
contexto: (1) Minimizar a incerteza fechando partes dos sistemas e (2) Estabelecer políticas e protocolos que permitem que
os componentes se tornem ou permaneçam abertos. Muitos esforços práticos de design envolvem alguns de cada um.

1.3.4.1 Subsistemas fechados

Um sistema idealmente fechado é aquele para o qual você tem conhecimento estático perfeito (tempo de design) sobre
todos os comportamentos possíveis. Isso é tipicamente inatingível e indesejável. No entanto, muitas vezes ainda é
possível fechar partes de sistemas, em unidades que variam de classes individuais a componentes de nível de
produto, empregando possivelmente versões extremas de técnicas de encapsulamento OO:
Machine Translated by Google

Comunicação externa restrita. Todas as interações, internas e externas, ocorrem por meio de uma interface
estreita. No caso mais tratável, o subsistema é fechado para comunicação, nunca invocando internamente métodos
em objetos fora do subsistema.

Estrutura interna determinística. A natureza concreta (e, idealmente, o número) de todos os objetos e threads que
compõem o subsistema são conhecidos estaticamente. As palavras-chave finais e privadas podem ser usadas para
ajudar a impor isso.

Pelo menos em alguns desses sistemas, você pode, em princípio, provar informalmente, formalmente
ou mesmo mecanicamente que nenhuma violação interna de segurança ou vivacidade é possível dentro de um componente fechado.
Ou, se forem possíveis, você pode continuar a refinar projetos e implementações até que um componente esteja
comprovadamente correto. Nos melhores casos, você pode aplicar esse conhecimento de forma composicional
para analisar outras partes de um sistema que dependem desse componente.

Informações estáticas perfeitas sobre objetos, threads e interações informam não apenas o que pode acontecer,
mas também o que não pode acontecer. Por exemplo, pode ser que, mesmo que dois métodos
sincronizados em dois objetos contenham chamadas um para o outro, eles nunca possam ser acessados
simultaneamente por diferentes threads dentro do subsistema, portanto, o impasse nunca ocorrerá.

O fechamento também pode fornecer mais oportunidades para otimização manual ou orientada por compilador;
por exemplo, remover a sincronização de métodos que normalmente a exigiriam ou empregar algoritmos inteligentes
para fins especiais que podem ser aplicados apenas eliminando a possibilidade de interação indesejada. Os sistemas
embutidos são frequentemente compostos como coleções de módulos fechados, em parte para melhorar a
previsibilidade, escalonamento e análises de desempenho relacionadas.

Embora os subsistemas fechados sejam tratáveis, eles também podem ser frágeis. Quando as restrições e suposições
que governam sua estrutura interna mudam, esses componentes geralmente são jogados fora e reconstruídos do
zero.

1.3.4.2 Sistemas abertos

Um sistema aberto ideal é infinitamente extensível, através de várias dimensões. Ele pode carregar classes
desconhecidas dinamicamente, permitir que subclasses sobrescrevam praticamente qualquer método, empregue
retornos de chamada em objetos dentro de diferentes subsistemas, compartilhe recursos comuns entre threads,
use reflexão para descobrir e invocar métodos em objetos desconhecidos e assim por diante. A abertura
ilimitada é geralmente tão inatingível e indesejável quanto o fechamento completo: se tudo pode mudar, então
você não pode programar nada. Mas a maioria dos sistemas requer pelo menos parte dessa flexibilidade.

A análise estática completa de sistemas abertos nem mesmo é possível, pois sua natureza e estrutura evoluem com
o tempo. Em vez disso, os sistemas abertos devem contar com políticas e protocolos documentados aos quais
todos os componentes aderem.

A Internet está entre os melhores exemplos de um sistema aberto. Ele evolui continuamente, por exemplo,
adicionando novos hosts, páginas da Web e serviços, exigindo apenas que todos os participantes obedeçam a
algumas políticas e protocolos de rede. Como em outros sistemas abertos, às vezes é difícil cumprir as políticas
e protocolos da Internet. No entanto, as próprias JVMs fazem com que os componentes não conformes não danifiquem
catastroficamente a integridade do sistema.

O design orientado por políticas pode funcionar bem em um nível muito menor de sistemas simultâneos típicos,
em que políticas e protocolos geralmente assumem a forma de regras de design. Exemplos de domínios de política
explorados com mais profundidade nos capítulos subsequentes incluem:
Machine Translated by Google

Fluxo. Por exemplo, uma regra da forma: Componentes do tipo A enviam mensagens para os do tipo B, mas nunca
vice-versa.

Bloqueio. Por exemplo, uma regra na forma: Métodos do tipo A sempre lançam exceções imediatamente se o recurso R
não estiver disponível, em vez de bloquear até que esteja disponível.

Notificações. Por exemplo, uma regra da forma: Objetos do tipo A sempre enviam notificações de alteração para seus
ouvintes sempre que atualizados.

A adoção de um número relativamente pequeno de políticas simplifica o projeto, minimizando a possibilidade de


decisões inconsistentes caso a caso. Os autores de componentes, talvez com a ajuda de revisões de código e
ferramentas, precisam apenas verificar se estão obedecendo às regras de design relevantes e, caso contrário,
podem concentrar a atenção nas tarefas em mãos. Os desenvolvedores podem pensar localmente enquanto agem globalmente.

No entanto, o design orientado por políticas pode se tornar incontrolável quando o número de políticas aumenta e as
obrigações de programação que elas induzem sobrecarregam os desenvolvedores. Mesmo quando métodos
simples, como atualizar o saldo de uma conta ou imprimir "Olá, mundo", exigem dezenas de linhas de código
desajeitado e sujeito a erros para obedecer às políticas de design, é hora de tomar algum tipo de ação corretiva:
simplificar ou reduzir o número de políticas; ou criar ferramentas que ajudem a automatizar a geração de código e/ou
verificar a conformidade; ou criar linguagens específicas de domínio que imponham uma determinada disciplina; ou
criar estruturas e bibliotecas de utilitários que reduzam a necessidade de escrever tanto código de suporte dentro
de cada método.

As escolhas políticas não precisam ser, em nenhum sentido, "ótimas" para serem eficazes, mas devem ser aceitas e
acreditadas, quanto mais fervorosamente melhor. Essas escolhas políticas formam a base de várias estruturas e
padrões de projeto descritos ao longo deste livro. É provável que alguns deles sejam inaplicáveis a seus projetos de
software e possam até parecer um equívoco ("Eu nunca faria isso!") porque as políticas subjacentes entram em conflito
com outras que você adotou.

Enquanto induzir maior fechamento permite otimizar o desempenho, induzir maior abertura permite otimizar mudanças
futuras. Esses dois tipos de ajustes e refatorações costumam ser igualmente difíceis de realizar, mas têm efeitos
opostos. A otimização do desempenho geralmente envolve a exploração de casos especiais por meio de decisões de
design de hardware. A otimização para extensibilidade envolve a remoção de decisões predefinidas e, em vez disso,
permite que elas variem, por exemplo, encapsulando-as como métodos substituíveis, suportando ganchos de
retorno de chamada ou abstraindo a funcionalidade por meio de interfaces que podem ser reimplementadas de maneiras
completamente diferentes por componentes carregados dinamicamente .

Como os programas concorrentes tendem a incluir mais decisões de política in-the-small do que os sequenciais, e
porque eles tendem a depender mais de invariantes em torno de escolhas de representação particulares, as
classes que envolvem construções de simultaneidade geralmente exigem atenção especial para serem facilmente
extensíveis . Esse fenômeno é tão difundido que recebeu um nome, a anomalia hereditária, e é descrito com
mais detalhes no § 3.3.3.3.

No entanto, algumas outras técnicas de programação restringem desnecessariamente a extensibilidade por


causa do desempenho. Essas táticas se tornam mais questionáveis à medida que os compiladores e as JVMs melhoram.
Por exemplo, a compilação dinâmica permite que muitos componentes extensíveis sejam tratados como se fossem
fechados no tempo de carregamento da classe, levando a otimizações e especializações que exploram contextos
de tempo de execução específicos com mais eficiência do que qualquer programador poderia.

1.3.4.3 Documentação
Machine Translated by Google

Quando a composicionalidade depende do contexto, é vital que os contextos de uso pretendido e as restrições em
torno dos componentes sejam bem compreendidos e bem documentados. Quando essas informações não são
fornecidas, o uso, a reutilização, a manutenção, os testes, o gerenciamento de configuração, a evolução do sistema e
as preocupações relacionadas à engenharia de software tornam-se muito mais difíceis.

A documentação pode ser usada para melhorar a compreensão por qualquer um dos vários públicos, outros
desenvolvedores que usam uma classe como um componente de caixa preta, autores de subclasses, desenvolvedores
que posteriormente mantêm, modificam ou reparam o código, testadores e revisores de código e usuários do sistema.
Para esses públicos, o primeiro objetivo é eliminar a necessidade de documentação extensa, minimizando o
inesperado e, assim, reduzindo a complexidade conceitual por meio de:

Estandardização. Usando políticas, protocolos e interfaces comuns. Por exemplo:

Adotar padrões de design padrão e fazer referência a livros, páginas da Web ou documentos de design que
os descrevam de forma mais completa.
Empregando bibliotecas e estruturas utilitárias padrão.
Usando idiomas de codificação padrão e convenções de nomenclatura.
Compensação contra listas de verificação de revisão padrão que enumeram erros comuns.

Clareza. Usando as expressões de código mais simples e auto-evidentes. Por exemplo:

Usando exceções para anunciar condições verificadas.


Expressar restrições internas por meio de qualificadores de acesso (como privado).
Adotando convenções comuns de nomenclatura e assinatura padrão, por exemplo, a menos que
especificado de outra forma, os métodos que podem bloquear declaram que
lançam InterruptedException.

Código auxiliar. Fornecendo código que demonstra os usos pretendidos. Por exemplo:

Incluindo amostra ou exemplos de uso recomendados.


Fornecendo trechos de código que alcançam efeitos não óbvios.
Incluindo métodos projetados para servir como autotestes.

Depois de eliminar a necessidade de explicar o óbvio por meio de documentação, formas mais úteis de
documentação podem ser usadas para esclarecer as decisões de projeto. Os detalhes mais críticos podem ser expressos
de maneira sistemática, usando anotações semiformais dos formulários listados na tabela a seguir, que são usados e
explicados conforme necessário ao longo deste livro.

PRE Pré-condição (não necessariamente marcada).


/** PRE: O chamador mantém o bloqueio de sincronização

QUANDO Condição de guarda (sempre marcada).


/** WHEN: não vazio retorna pós-condição POST mais antiga

(normalmente desmarcada).
/** POST: Recurso r é liberado
OUT Envio de mensagem garantida (por exemplo, um retorno de chamada).
/** OUT: c.process(buff) chamado após leitura RELY Propriedade necessária

(normalmente desmarcada) de outros objetos ou métodos.


/** RELY: Deve ser ativado por x.signal()
INV Uma restrição de objeto verdadeira no início e no final de cada método público.
Machine Translated by Google

/** INV: x,y são coordenadas de tela válidas


INIT Uma restrição de objeto que deve ser mantida na construção.
/** INIT: bufferCapacity maior que zero

Documentação adicional e menos estruturada pode ser usada para explicar restrições não óbvias, limitações
contextuais, suposições e decisões de design que afetam o uso em um ambiente simultâneo. É impossível
fornecer uma listagem completa de construções que requerem este tipo de documentação, mas os casos
típicos incluem:

Informações de design de alto nível sobre restrições de estado e método.


Conhecidas limitações de segurança por falta de travamento em situações que o exigissem.
O fato de que um método pode bloquear indefinidamente a espera de uma condição, evento ou recurso.
Métodos projetados para serem chamados apenas de outros métodos, talvez de outras classes.

Este livro, como a maioria dos outros, não pode servir como um modelo especialmente bom para essas
práticas de documentação, pois a maioria desses assuntos é discutida no texto, e não como
documentação de código de exemplo.

1.3.5 Leituras Adicionais


Contas de análise e projeto de software orientado a objetos de alto nível que cobrem pelo menos
alguns problemas de simultaneidade incluem:

ATKINSON, Colin. Reutilização orientada a objetos, simultaneidade e distribuição, Addison-Wesley, 1991.

BOOCH, Grady. Análise e Design Orientado a Objetos, Benjamin Cummings, 1994.

Buhr, Ray JA e Ronald Casselman. Use Case Maps for Object-Oriented Systems, Prentice Hall, 1996. Buhr e
Casselman generalizam diagramas timethread semelhantes aos usados neste livro para Use Case Maps.

Cook, Steve e John Daniels. Projetando Sistemas de Objetos: Modelagem Orientada a Objetos com Syntropy,
Prentice Hall, 1994.

de Champeaux, Dennis, Doug Lea e Penelope Faure. Desenvolvimento de Sistemas Orientados a Objetos,
Addison-Wesley, 1993.

D'Souza, Desmond e Alan Wills. Objetos, componentes e estruturas com UML, Addison Wesley, 1999.

Reenskaug, Trygve. Trabalhando com Objetos, Prentice Hall, 1995.

Rumbaugh, James, Michael Blaha, William Premerlani, Frederick Eddy e William Lorensen.
Modelagem e Design Orientado a Objetos, Prentice Hall, 1991.

Contas de especificação, análise, design e verificação de software simultâneos incluem:

Apt, Krzysztof e Ernst-Rudiger Olderog. Verificação de programas sequenciais e simultâneos, Springer-


Verlag, 1997.
Machine Translated by Google

Carriero, Nicholas e David Gelernter. Como escrever programas paralelos, MIT Press, 1990.

Chandy, K. Mani e Jayedev Misra. Projeto de Programa Paralelo, Addison-Wesley, 1989.

Jackson, Miguel. Principles of Program Design, Academic Press, 1975.

Jensen, Kurt e Grzegorz Rozenberg (eds.). Redes de Petri de alto nível: teoria e aplicação, Springer-Verlag,
1991.

Lamport, Leslie. The Temporal Logic of Actions, SRC Research Report 79, Digital Equipment Corp, 1991.

Leveson, Nancy. Safeware: System Safety and Computers, Addison-Wesley, 1995.

Maná, Zohar e Amir Pneuli. A Lógica Temporal de Sistemas Reativos e Concorrentes, Springer Verlag, 1991.

Vários campos especializados de desenvolvimento de software dependem fortemente da simultaneidade. Por


exemplo, muitos sistemas de simulação, sistemas de telecomunicações e sistemas multimídia são altamente multithread.
Embora as técnicas básicas de simultaneidade constituam grande parte da base para o projeto de tais sistemas, este
livro não chega a descrever arquiteturas de software em larga escala ou técnicas de programação especializadas
associadas a aplicativos simultâneos específicos. Veja, por exemplo:

Fishwick, Paulo. Projeto e Execução de Modelo de Simulação, Prentice Hall, 1995.

Gibbs. Simon e Dennis Tsichritzis. Programação Multimídia, Addison-Wesley, 1994.

Watkins, Kevin. Simulação de eventos discretos em C, McGraw-Hill, 1993.

Questões técnicas são apenas um aspecto do desenvolvimento de software simultâneo, que também envolve teste,
organização, gerenciamento, fatores humanos, manutenção, ferramentas e disciplina de engenharia. Para obter
uma introdução aos métodos básicos de engenharia que podem ser aplicados tanto na programação diária
quanto em esforços maiores, consulte:

Humphrey, Watts. Uma Disciplina para Engenharia de Software, Addison-Wesley, 1995.

Para uma perspectiva completamente diferente, veja:

Beck, Kent. Programação Extrema Explicada: Abrace a Mudança, Addison-Wesley, 1999.

Para obter mais informações sobre como integrar questões de desempenho aos esforços de engenharia de software,
consulte, por exemplo:

Jain, Raj. A arte da análise de desempenho de sistemas de computador, Wiley, 1991.

Outras distinções entre sistemas abertos e fechados são discutidas em:

Wegner, Pedro. "Por que a interação é mais poderosa que os algoritmos", Communications of the ACM, maio de 1997.
Machine Translated by Google

1.4 Padrões Antes/Depois

Muitos projetos simultâneos são melhor descritos como padrões. Um padrão encapsula uma forma de design bem-
sucedida e comum, geralmente uma estrutura de objeto (também conhecida como microarquitetura) que consiste em
uma ou mais interfaces, classes e/ou objetos que obedecem a certas restrições e relacionamentos estáticos e
dinâmicos. Os padrões são um veículo ideal para caracterizar projetos e técnicas que não precisam ser implementados
exatamente da mesma maneira em diferentes contextos e, portanto, não podem ser encapsulados de maneira
útil como componentes reutilizáveis. Componentes e estruturas reutilizáveis podem desempenhar um papel central no
desenvolvimento de software simultâneo. Mas grande parte da programação OO simultânea envolve a reutilização,
adaptação e extensão de formas e práticas de design recorrentes, em vez de classes específicas.

Ao contrário daqueles no livro pioneiro Design Patterns de Gamma, Helm, Johnson e Vlissides (consulte Leituras
Adicionais em § 1.4.5), os padrões aqui estão embutidos em capítulos que discutem conjuntos de contextos relacionados
e princípios de design de software que geram as principais forças e restrições resolvidas nos padrões. Muitos desses
padrões são extensões menores ou variantes de outros padrões comuns de composição e camadas OO. Esta seção
revisa alguns dos quais são amplamente utilizados nos capítulos subseqüentes.
Outros são brevemente descritos no primeiro encontro.

1.4.1 Estratificação

O controle de políticas em camadas sobre o mecanismo é um princípio de estruturação comum em sistemas de todos os tipos.
Muitas camadas OO e técnicas de composição dependem de algumas chamadas de método ou corpo de código entre
uma dada ação anterior e uma ação posterior. Todas as formas de controle antes/depois fazem com que um determinado
método de solo, digamos método, seja interceptado para sempre executar na sequência:

antes(); método(); depois();

Ou, para garantir que as ações posteriores sejam executadas mesmo se os métodos de solo encontrarem exceções:

antes(); tente
{método(); } finalmente
{ depois(); }

A maioria dos exemplos neste livro gira em torno do controle de simultaneidade. Por exemplo, um método
sincronizado adquire um bloqueio antes de executar o código dentro do método e libera o bloqueio após a conclusão do
método. Mas as ideias básicas dos padrões antes/depois podem ser ilustradas em conjunto com outra prática útil na
programação OO, o código de autoverificação: os campos de qualquer objeto devem preservar todos os invariantes
sempre que o objeto não estiver envolvido em um método público (consulte § 1.3 .1). As invariantes devem ser
mantidas mesmo se esses métodos lançarem qualquer uma de suas exceções declaradas, a menos que essas
exceções indiquem corrupção ou falha do programa (como pode ser verdade para RuntimeExceptions e Errors).

A conformidade com invariantes computáveis pode ser testada dinamicamente criando classes que os verifiquem na
entrada e na saída de cada método público. Técnicas semelhantes se aplicam a pré-condições e pós-condições, mas, para
simplificar, ilustraremos apenas com invariantes.

Como exemplo, suponha que gostaríamos de criar classes de caixa d'água que contenham uma autoverificação
no invariante de que o volume está sempre entre zero e a capacidade. Para fazer isso, podemos definir um método
checkVolumeInvariant e usá-lo como operação anterior e posterior. Podemos primeiro definir uma exceção para lançar se a
invariante falhar:
Machine Translated by Google

class AssertionError extends java.lang.Error { public AssertionError()


{ super(); } public AssertionError(String mensagem)
{ super(mensagem); } }

Pode ser prejudicial inserir essas verificações manualmente dentro de cada método. Em vez disso, um dos
três padrões de projeto antes/depois pode ser usado para separar as verificações dos métodos básicos: classes
adaptadoras, projetos baseados em subclasses e classes adaptadoras de método.

Em todos os casos, a melhor maneira de configurar isso é definir uma interface que descreva a funcionalidade básica.
As interfaces são quase sempre necessárias quando você precisa ter espaço suficiente para variar as
implementações. Por outro lado, a falta de interfaces existentes limita as opções ao aplicar retrospectivamente
os padrões antes/depois.

Aqui está uma interface que descreve uma variante secundária da classe de tanque de água discutida em § 1.2.4.
As técnicas antes/depois podem ser aplicadas para verificar invariantes em torno da operação transferWater .

interface Tanque { float


getCapacity(); float
getVolume(); void
transferWater(float amount)
lança OverflowException, UnderflowException;
}

1.4.2 Adaptadores

Quando as interfaces padronizadas são definidas após o projeto de uma ou mais classes concretas, essas classes
geralmente não implementam a interface desejada. Por exemplo, os nomes de seus métodos podem ser ligeiramente
diferentes daqueles definidos na interface. Se você não puder modificar essas classes concretas para corrigir esses
problemas, ainda poderá obter o efeito desejado criando uma classe Adapter que converte as incompatibilidades.

Digamos que você tenha uma classe Performer que ofereça suporte ao método perform e atenda a todas as qualificações
de ser utilizável como um Runnable , exceto pela incompatibilidade de nome. Você pode construir um Adapter para
que ele possa ser usado em uma thread por alguma outra classe:
Machine Translated by Google

class AdaptedPerformer implements Runnable { private final


Performer adaptee;

public AdaptedPerformer(Performer p) { adaptee = p; } public void run()


{ adaptee.perform(); } }

Este é apenas um dos muitos contextos comuns para a construção de Adapters, que também formam a base de
vários padrões relacionados apresentados no livro Design Patterns. Um Proxy é um Adaptador com a mesma
interface que seu delegado. Um Composite mantém uma coleção de delegados, todos suportando a mesma interface.

Nesse estilo de composição baseado em delegação, a classe host publicamente acessível encaminha todos os
métodos para um ou mais delegados e retransmite as respostas, talvez fazendo alguma tradução leve (mudanças
de nome, coerção de parâmetro, filtragem de resultados, etc.) envolvendo as chamadas de delegado.

Os adaptadores podem ser usados para fornecer controle antes/depois simplesmente agrupando a chamada
delegada nas ações de controle. Por exemplo, assumindo que temos uma classe de implementação, digamos
TankImpl, podemos escrever a seguinte classe AdaptedTank . Essa classe pode ser usada no lugar da original
em algum aplicativo, substituindo todas as ocorrências de:

novo TankImpl(...)

com:

new AdaptedTank(new TankImpl(...)).


class AdaptedTank implements Tank { protected
final Tank delegate;

public AdaptedTank(Tanque t) { delegado = t; }

public float getCapacity() { return delegate.getCapacity(); }

public float getVolume() { return delegate.getVolume(); }


Machine Translated by Google

protected void checkVolumeInvariant() throws AssertionError {


float v = getVolume(); float c =
getCapacity(); se ( !(v >= 0,0 && v <= c) )

lançar novo AssertionError();


}

public sincronizado void transferWater(float amount) throws OverflowException,


UnderflowException {

checkVolumeInvariant(); // verificação prévia

tente
{ delegado.transferÁgua(quantidade);
}

// Os relançamentos serão adiados até a verificação posterior // na cláusula final

catch (OverflowException ex) { lançar ex; } catch (UnderflowException ex)


{ lançar ex; }

finalmente {
checkVolumeInvariant(); // verificação posterior }

}}

1.4.3 Subclasse
No caso normal, quando as versões interceptadas antes/depois dos métodos têm os mesmos nomes e usos
das versões base, a criação de subclasses pode ser uma alternativa mais simples ao uso de adaptadores.
Versões de subclasse de métodos podem interpor verificações em torno de chamadas para suas superversões . Por exemplo:

class SubclassedTank extends TankImpl {

protected void checkVolumeInvariant() throws AssertionError {


// ... idêntico à versão AdaptedTank ... }

public sincronizado void transferWater(float amount) throws OverflowException,


UnderflowException {
// idêntico à versão AdaptedTank exceto para chamada interna:

// ... tente

{ super.transferWater(quantidade);
}
Machine Translated by Google

// ...
}
}

Algumas opções entre subclasses e adaptadores são apenas uma questão de estilo. Outros refletem
diferenças entre delegação e herança.

Os adaptadores permitem manipulações que escapam das regras de subclasse. Por exemplo, você não
pode substituir um método público como privado em uma subclasse para desabilitar o acesso, mas pode
simplesmente falhar ao retransmitir o método em um Adapter. Várias formas de delegação podem até mesmo
ser usadas como substitutos para subclasses, fazendo com que cada "sub" classe (Adapter) mantenha
uma referência a uma instância de sua classe "super" (Adaptee), encaminhando todas as operações
"herdadas". Esses adaptadores geralmente têm exatamente as mesmas interfaces que seus delegados; nesse caso, são considerados t
A delegação também pode ser mais flexível do que a subclasse, já que os objetos "sub" podem até
mudar seus "supers" (reatribuindo a referência do delegado) dinamicamente.

A delegação também pode ser usada para obter os efeitos da herança de vários códigos. Por exemplo, se
uma classe deve implementar duas interfaces não relacionadas,
digamos Tank e java.awt.event.ActionListener, e há duas superclasses disponíveis fornecendo a funcionalidade
necessária, uma delas pode ser subclasse e a outra delegada.
Machine Translated by Google

No entanto, a delegação é menos poderosa do que a subclasse em alguns outros aspectos. Por exemplo,
autochamadas em "superclasses" não são automaticamente vinculadas às versões de métodos que foram
"substituídos" em "subclasses" baseadas em delegação. Projetos de adaptadores também podem encontrar
dificuldades em torno do fato de que os objetos Adaptee e Adapter são objetos diferentes. Por exemplo, testes de
igualdade de referência de objeto devem ser executados com mais cuidado, pois um teste para ver se você tem a
versão Adaptee de um objeto falha se você tiver a versão Adapter e vice-versa.

A maioria desses problemas pode ser evitada por meio da medida extrema de declarar todos os métodos nas
classes Adaptee para receber um argumento "aparente" referente ao Adapter e sempre usá-lo em vez disso,
mesmo para chamadas automáticas e verificações de identidade ( por exemplo substituindo Object.iguals).
Algumas pessoas reservam o termo delegação para objetos e classes escritos neste estilo, em vez das
técnicas de encaminhamento que quase sempre são usadas para implementar adaptadores simples.

1.4.3.1 Métodos de modelo

Quando tiver certeza de que vai contar com o controle antes/depois em um conjunto de classes relacionadas,
você pode criar uma classe abstrata que automatize a sequência de controle por meio de uma aplicação do padrão
Template Method (que não tem nada a ver com C++ tipos genéricos).

Uma classe abstrata que suporta métodos de modelo configura uma estrutura que facilita a construção
de subclasses que podem substituir as ações no nível do solo, os métodos antes/depois ou ambos:

O código de ação básico no nível do solo é definido em métodos não públicos. (Por convenção,
nomeamos a versão não pública de qualquer método de método como doMethod.) Com menos
flexibilidade, esses métodos não precisam ser declarados como não públicos se forem projetados
para serem substituídos em subclasses.
As operações antes e depois também são definidas como métodos não públicos.
Os métodos públicos invocam os métodos de solo entre os métodos antes e depois.

Aplicar isso ao exemplo Tank leva a:


Machine Translated by Google

classe abstrata AbstractTank implementa Tank { protected void


checkVolumeInvariant() throws AssertionError {
// ... idêntico à versão AdaptedTank ... }

protected abstract void doTransferWater(quantidade flutuante)


lança OverflowException, UnderflowException;

public sincronizado void transferWater(float amount) throws OverflowException,


UnderflowException {
// idêntico à versão AdaptedTank exceto para chamada interna:

// ... tente

{ doTransferWater(quantidade);

} // ... }

class ConcreteTank extends AbstractTank {


capacidade de flutuação final protegida; volume
flutuante protegido; // ... public float

getVolume() { return volume; } public float getCapacity() { return


capacidade; }

void protegido doTransferWater(quantidade flutuante)


throws OverflowException, UnderflowException { // ... código de
implementação ...
}
}

1.4.4 Adaptadores de método

A abordagem mais flexível, mas às vezes mais complicada para o controle antes/depois é definir uma classe
cujo propósito é invocar um método específico em um objeto específico. No padrão Command Object e suas
muitas variantes, as instâncias de tais classes podem ser passadas, manipuladas e finalmente executadas
(aqui, entre operações antes/depois).

Devido às regras de tipagem estática, deve haver um tipo diferente de classe adaptadora para cada tipo de
método que está sendo encapsulado. Para evitar a proliferação de todos esses tipos, a maioria dos
aplicativos restringe a atenção a apenas uma ou a um pequeno conjunto de interfaces genéricas, cada
uma definindo um único método. Por exemplo, a classe Thread e a maioria das outras estruturas de execução
aceitam apenas instâncias da interface Runnable para invocar seus métodos de execução sem argumentos,
sem resultados e sem exceções. Da mesma forma, em § 4.3.3.1, definimos e usamos a interface Callable
contendo apenas uma chamada de método que aceita um argumento Object , retorna um Object e pode lançar qualquer exceção.
Machine Translated by Google

Em aplicativos mais focados, você pode definir qualquer interface de método único adequada, instanciar uma
implementação quase sempre por meio de uma classe interna anônima e, em seguida, passá-la para execução
posterior. Essa técnica é amplamente usada nos pacotes java.awt e javax.swing , que definem interfaces e classes
abstratas associadas a diferentes tipos de métodos de manipulação de eventos.
(Em algumas outras linguagens, ponteiros e fechamentos de função são definidos e usados para obter alguns
desses efeitos.)

Podemos aplicar uma versão de camadas antes/depois com base nos adaptadores de método aqui, primeiro definindo um
Interface TankOp :

interface TankOp { void


op() gera OverflowException, UnderflowException; }

No código de exemplo a seguir, de forma incomum, todos os usos de adaptadores de método são locais para
a classe TankWithMethodAdapter . Além disso, neste pequeno exemplo, há apenas um método que pode ser
agrupado. No entanto, o mesmo andaime pode ser usado para qualquer outro método Tank definido nesta classe ou
em suas subclasses. Os adaptadores de método são muito mais comuns em aplicações onde as instâncias devem ser
registradas e/ou repassadas entre vários objetos antes de serem executadas, o que justifica os custos extras de
configuração e obrigações de programação.

class TankWithMethodAdapter { // ...

protected void checkVolumeInvariant() throws AssertionError { // ... idêntico à versão


AdaptedTank ...
}

protected void runWithinBeforeAfterChecks(TankOp cmd) throws


OverflowException, UnderflowException { // idêntico a
AdaptedTank.transferWater // exceto para chamada interna:

// ...
tente

{ cmd.op(); } // ...
}

void protegido doTransferWater(quantidade flutuante)


lança OverflowException, UnderflowException {
// ... código de implementação ...
}

transferência nula sincronizada públicaWater(valor flutuante final)


lança OverflowException, UnderflowException {

runWithinBeforeAfterChecks(new TankOp() { public void


op() lança
OverflowException, UnderflowException {
Machine Translated by Google

doTransferWater(quantidade);

} });
}
}

Algumas aplicações de adaptadores de método podem ser parcialmente automatizadas usando recursos
de reflexão. Um construtor genérico pode sondar uma classe para um determinado java.lang.reflect.Method,
configurar argumentos para ele, invocá -lo e transferir de volta os resultados. Isso vem com o preço de
garantias estáticas mais fracas, maior sobrecarga e a necessidade de lidar com as muitas exceções que
podem surgir. Portanto, isso geralmente só vale a pena ao lidar com código desconhecido carregado dinamicamente.

Técnicas de interceptação reflexiva mais extremas e exóticas estão disponíveis se você escapar dos limites
do idioma. Por exemplo, é possível criar e aplicar ferramentas que unem bytecodes representando ações
antes e depois em representações de classe compiladas ou fazem isso no carregamento da classe.

1.4.5 Leituras Adicionais


Existem muitos padrões de projeto úteis além daqueles que são específicos da programação concorrente
e certamente muitos outros relacionados à concorrência que não estão incluídos neste livro. Outros
livros que apresentam padrões e aspectos relacionados a padrões de design de software incluem:

Buschmann, Frank, Regine Meunier, Hans Rohnert, Peter Sommerlad e Michael Stal. Arquitetura de
Software Orientada a Padrões: Um Sistema de Padrões, Wiley, 1996.

Coplien, James. Advanced C++: Programming Styles and Idioms, Addison-Wesley, 1992.

Fowler, Martin. Padrões de Análise, Addison-Wesley, 1997

Gamma, Erich, Richard Helm, Ralph Johnson e John Vlissides. Design Patterns, Addison-Wesley, 1994. (O
livro "Gang of Four".)

Subindo, Linda. O Manual de Padrões, Cambridge University Press, 1998.

Shaw, Mary e David Garlan. Arquitetura de Software, Prentice Hall, 1996.

(Vários editores) Linguagens Padrão de Design de Programas, Addison-Wesley. Esta série incorpora
padrões apresentados na conferência anual Pattern Languages of Programming (PLoP).

A linguagem OO Self está entre as poucas que suportam diretamente um estilo de programação
baseado em delegação pura sem exigir o encaminhamento explícito de mensagens. Ver:

Ungar, David. "The Self Papers", Lisp e Computação Simbólica, 1991.

Técnicas reflexivas antes/depois são freqüentemente vistas em Lisp, Scheme e CLOS (Common Lisp
Object System). Veja, por exemplo:

Abelson, Harold e Gerald Sussman. Estrutura e Interpretação de Programas de Computador, MIT Press,
1996.
Machine Translated by Google

Kiczales, Gregor, Jim des Rivieres e Daniel Bobrow. The Art of the Metaobject Protocol, MIT Press, 1993.

Padrões de design de sincronização em camadas adicionais são discutidos em:

Rito Silva, António, João Pereira e José Alves Marques. "Object Synchronizer", em Neil Harrison, Brian Foote e Hans
Rohnert (eds.), Pattern Languages of Program Design, Volume 4, Addison Wesley, 1999.

Uma abordagem de composição para o controle de simultaneidade em camadas é descrita em:

Holmes, David. Anéis de Sincronização: Sincronização Componível para Sistemas Orientados a Objetos Simultâneos, Tese
de Doutorado, Macquarie University, 1999.

A composição de coleções de métodos antes/depois que lidam com diferentes aspectos de funcionalidade (por
exemplo, misturando controle de sincronização com controle de persistência) pode exigir estruturas mais elaboradas do
que as discutidas aqui. Uma abordagem é construir uma estrutura de metaclasse que automatize parcialmente a
interceptação e encapsulamento de métodos por objetos de classe. Para uma extensa análise e discussão das técnicas
de composição resultantes, veja:

Forman, Ira e Scott Danforth. Colocando Metaclasses para Trabalhar, Addison-Wesley, 1999.

A programação orientada a aspectos substitui as técnicas de antes/depois em camadas por ferramentas que unem códigos
que lidam com diferentes aspectos de controle. Relatórios sobre a linguagem AspectJ incluem alguns exemplos
deste livro expressos de maneira orientada a aspectos. Ver:

Kiczales, Gregor, John Lamping, Anurag Mendhekar, Chris Maeda, Cristina Videira Lopes, Jean Marc Loingtier e John
Irwin. "Aspect-Oriented Programming", Proceedings of the European Conference on Object-Oriented Programming
(ECOOP), 1997.

Várias ferramentas estão disponíveis para automatizar parcialmente os testes invariantes. Veja, por exemplo:

Beck, Kent e Erich Gama. "Test Infected: Programmers Love Writing Tests", The Java Report, julho de 1998.

Capítulo 2. Exclusão
Em um sistema seguro, todo objeto se protege contra violações de integridade. Isso às vezes requer a cooperação de
outros objetos e seus métodos.

As técnicas de exclusão preservam as invariantes do objeto e evitam os efeitos que resultariam da ação sobre
representações de estado ainda que momentaneamente inconsistentes. As técnicas de programação e os padrões de
design alcançam a exclusão impedindo que vários threads modifiquem ou atuem simultaneamente nas representações
do objeto. Todas as abordagens dependem de uma ou mais das três estratégias básicas:

Eliminar a necessidade de algum ou todo o controle de exclusão, garantindo que os métodos nunca modifiquem a
representação de um objeto, de modo que o objeto não possa entrar em estados inconsistentes.

Garantindo dinamicamente que apenas um thread por vez possa acessar o estado do objeto, protegendo os objetos com
bloqueios e construções relacionadas.
Machine Translated by Google

Garantindo estruturalmente que apenas um thread (ou apenas um thread por vez) possa usar um determinado
objeto, ocultando ou restringindo o acesso a ele.

As três primeiras seções deste capítulo descrevem as características centrais e padrões de uso em torno de cada
uma dessas abordagens: imutabilidade (§ 2.1), sincronização (§ 2.2) e confinamento (§ 2.3).
A seção § 2.4 discute algumas maneiras de combinar essas diferentes abordagens para melhorar a segurança,
vivacidade, desempenho e/ou garantias semânticas. A seção § 2.5 mostra como usar classes utilitárias para
obter efeitos que, de outra forma, são difíceis de organizar usando construções internas. Além disso, várias
classes, técnicas e utilitários descritos no Capítulo 3 podem ser usados para garantir a exclusão (consulte especialmente
o § 3.3.2).

O uso obrigatório dessas técnicas representa uma diferença importante entre as práticas de programação
sequencial e concorrente. Para garantir a segurança em um sistema simultâneo, você deve garantir que todos os
objetos acessíveis de vários encadeamentos sejam imutáveis ou empreguem sincronização apropriada
e também deve garantir que nenhum outro objeto se torne acessível simultaneamente por vazamento de seu
domínio de propriedade. Embora as técnicas que ajudam a manter essas garantias sejam, em muitos aspectos,
apenas extensões de outras práticas de desenvolvimento OO, os programas simultâneos geralmente são menos
tolerantes a erros.

Conforme discutido no § 1.3.1, a maioria desses assuntos não pode, por natureza, ser aplicada por compiladores ou
sistemas de tempo de execução. As ferramentas de análise e teste podem ser úteis na detecção de alguns tipos
de falha, mas a principal responsabilidade de garantir a segurança de cada classe, componente, subsistema,
aplicativo e sistema recai sobre seus desenvolvedores. Além disso, políticas relacionadas à exclusão e regras de
design devem ser explícitas e bem divulgadas.

É necessário muito cuidado ao usar código que não foi projetado para operar em ambientes multithread.
A maioria das classes nos pacotes java.* são projetadas para serem thread-safe quando aplicadas em seus contextos
de uso pretendidos. (Algumas exceções são observadas à medida que surgem neste livro. Outras limitações aparecem
na documentação da API de classe.) No entanto, ao construir aplicativos multithread, muitas vezes é necessário
retrabalhar ou agrupar (consulte § 2.3.3.1) suas próprias classes e pacotes que foram originalmente projetado apenas
para uso em contextos de thread único.

2.1 Imutabilidade
Se um objeto não puder mudar de estado, ele nunca poderá encontrar conflitos ou inconsistências quando várias
atividades tentarem mudar seu estado de maneiras incompatíveis.

Os programas são muito mais simples de entender se os objetos existentes nunca forem alterados, mas, em vez
disso, novos objetos são continuamente criados durante o curso de qualquer computação. Infelizmente, esses
programas geralmente não conseguem lidar com a interação por meio de interfaces de usuário, threads cooperantes
e assim por diante. No entanto, o uso seletivo de imutabilidade é uma ferramenta básica na programação OO concorrente.

Os objetos imutáveis mais simples não possuem nenhum campo interno. Seus métodos são intrinsecamente sem
estado, eles não dependem de nenhum campo atribuível de nenhum objeto. Por exemplo, todos os usos
possíveis da seguinte classe StatelessAdder e seu método add são obviamente sempre seguros e ativos:

class StatelessAdder {
public int add(int a, int b) { return a + b; } }
Machine Translated by Google

As mesmas propriedades de segurança e vivacidade são mantidas em classes que possuem apenas campos
finais . Instâncias de classes imutáveis não podem experimentar conflitos de leitura/gravação ou gravação/
gravação de baixo nível (consulte § 1.3.1), porque os valores nunca são gravados. E desde que seus valores
iniciais sejam estabelecidos de forma legal e consistente, esses objetos não podem sofrer falhas invariáveis de nível superior. Por exemplo:

class ImmutableAdder {
compensação int final privada;

public ImmutableAdder(int a) { offset = a; }

public int addOffset(int b) { return offset + b; } }

2.1.1 Aplicações

É claro que é possível criar objetos imutáveis que contenham estrutura e funcionalidade mais interessantes
do que as vistas no ImmutableAdder. Os aplicativos incluem tipos de dados abstratos, contêineres de valor
e representações de estado compartilhado.

2.1.1.1 Tipos de dados abstratos (ADTs)

Objetos imutáveis podem servir como instâncias de tipos de dados abstratos simples
que representam valores. Alguns comuns já estão definidos nos pacotes java.* . Isso inclui
java.awt.Color, java.lang.Integer, java.math.BigDecimal, java.lang.String e outros[1].
É fácil definir suas próprias classes ADT, por exemplo, Fraction, Interval,
ComplexFloat e assim por diante. As instâncias dessas classes nunca alteram seus valores
de campo construídos, mas podem fornecer métodos que criam objetos que representam novos valores. Por exe

[1]
Observe, no entanto, que algumas outras classes de estilo ADT nos pacotes java.* não são imutáveis, por
exemplo java.awt.Point.

class Fraction // Fragmentos


{ numerador longo final protegido; denominador
final longo protegido;

public Fraction(long num, long den) {


// normaliza:
boolean sameSign = (num >= 0) == (den >= 0); longo n = (num >=
0)? num : -num; longo d = (den >= 0)? den : -den;
longo g = mdc(n, d); numerador = (mesmoSinal)?
n/g: -n/g; denominador = d/
g; }

static long mdc(long a, long b) {


// ...calcula o máximo divisor comum ... }
Machine Translated by Google

public Fraction plus(Fraction f) { return new


Fraction(numerator * f.denominator +
f.numerador * denominador,
denominador * f.denominador);
}

public boolean equals(Object other) { // substitui o padrão if (! (outra instância de


Fraction) ) return false; Fração f = (Fração)(outro); retorna numerador *
f.denominador == denominador * f.numerador;

public int hashCode() { // substitui o denominador ^


return (int) (numerador } padrão);

Classes que representam abstrações de dados imutáveis possuem instâncias que servem apenas para
encapsular valores, portanto suas identidades não são importantes. Por exemplo, dois objetos java.awt.Color
que representam preto (através do valor RGB 0) normalmente devem ser tratados como equivalentes. Esse é
um dos motivos pelos quais as classes de estilo ADT devem normalmente substituir os
métodos Object.equals e Object.hashCode para refletir a igualdade de valor abstrato, conforme ilustrado na classe Fraction .
As implementações padrão desses métodos dependem das identidades dos objetos que mantêm esses
valores. Mascarar a identidade substituindo iguais permite que vários objetos ADT representem os mesmos
valores e/ou executem a mesma funcionalidade sem que os clientes precisem saber ou se preocupar
exatamente com qual objeto ADT está sendo usado em um determinado momento.

Você nem sempre precisa se comprometer com representações imutáveis de ADTs em um programa inteiro.
Às vezes é útil definir diferentes classes, suportando diferentes usos, para as versões imutáveis versus
atualizáveis de algum conceito. Por exemplo, a classe java.lang.String é imutável, enquanto a classe
java.lang.StringBuffer é atualizável, contando com métodos sincronizados .

2.1.1.2 Recipientes de valor

Objetos imutáveis podem ser usados quando for necessário ou conveniente estabelecer algum estado
consistente uma vez e depois confiar nele para sempre. Por exemplo, um objeto ProgramConfiguration
imutável pode refletir todas as configurações a serem usadas durante a execução de um determinado programa.

Os contêineres de valor imutável também podem ser úteis sempre que a criação de diferentes variantes,
versões ou estados de um objeto por meio da criação de novos por meio de cópia parcial for relativamente
rara ou barata. Nesses casos, o custo da cópia pode ser superado pelos benefícios de nunca precisar
sincronizar as mudanças de estado (consulte o § 2.4.4). O análogo de uma mudança de estado para um
objeto imutável é produzir um novo objeto imutável que difere do original de alguma maneira especificada.

2.1.1.3 Compartilhamento

A imutabilidade é um dispositivo técnico útil quando você deseja compartilhar objetos para eficiência de
espaço e ainda fornecer acesso eficiente a esses objetos. Um objeto imutável pode ser referenciado por
qualquer número de outros objetos sem preocupação com sincronização ou restrição de acesso. Por exemplo, muitos
Machine Translated by Google

objetos de caracteres (ou glifos) individuais podem todos compartilhar referências ao mesmo objeto de fonte imutável.
Esta é uma aplicação do padrão Flyweight descrito no livro Design Patterns. A maioria dos projetos
Flyweights é mais simples de estabelecer, garantindo a imutabilidade das representações compartilhadas.

Instâncias de muitas classes utilitárias usadas em configurações simultâneas são intrinsecamente imutáveis e são
compartilhadas por muitos outros objetos. Por exemplo:

class Relay
{ servidor servidor final protegido;

Relé(Servidor s) { servidor = s; }

void doIt() { server.doIt(); } }

Embora objetos puramente imutáveis sejam simples, convenientes, predominantes e úteis, muitos programas OO
simultâneos também contam com constância de imutabilidade parcial apenas para alguns campos, ou somente após
a execução de um método específico, ou apenas durante algum período de interesse. A exploração da imutabilidade
é uma estratégia útil para a produção de designs que seriam, na melhor das hipóteses, difíceis de realizar usando
objetos atualizáveis. Vários desses designs são apresentados ao longo deste livro, especialmente no § 2.4.

2.1.2 Construção

Para serem eficazes, todas as decisões de design que dependem da imutabilidade devem ser aplicadas por meio do
uso apropriado da palavra-chave final . Além disso, alguns cuidados são necessários ao inicializar objetos imutáveis
(consulte § 2.2.7). Em particular, é contraproducente disponibilizar um objeto imutável para outros antes de ter sido
totalmente inicializado. Como regra geral válida para qualquer tipo de classe:

Não permita que os campos sejam acessados até que a construção esteja concluída.

Isso pode ser mais difícil de garantir em configurações simultâneas do que em programas sequenciais. Os
construtores devem executar apenas ações diretamente relacionadas à inicialização do campo. Eles não devem
invocar nenhum outro método cujos efeitos possam depender da construção completa do objeto. Os construtores
devem evitar registrar uma referência ao objeto que está sendo construído em campos ou tabelas acessíveis a outros,
evitar fazer chamadas a outros métodos com this como argumento e, de forma mais geral, evitar permitir que a
referência a this escape (consulte § 2.3). Sem essas precauções, outros objetos e métodos executados em outros
encadeamentos poderiam, em vez disso, acessar os zeros inicializados padrão (para campos escalares) ou nulos (para
campos de referência) definidos pela JVM para cada objeto antes que seu construtor seja executado .

Em alguns casos, os valores de campos conceitualmente imutáveis não podem ser totalmente inicializados em
construtores, por exemplo, quando são inicializados de forma incremental a partir de arquivos ou quando há
interdependências entre vários objetos sendo construídos ao mesmo tempo. Mais cuidado é necessário para garantir
que esses objetos não sejam disponibilizados para uso por outros até que os valores estejam estáveis. Isso quase
sempre requer o uso de sincronização (consulte, por exemplo, § 2.2.4 e § 3.4.2).

2.2 Sincronização
O bloqueio protege contra conflitos de armazenamento de baixo nível e falhas invariáveis de alto nível correspondentes.
Por exemplo, considere a seguinte classe:
Machine Translated by Google

class Even // Não use


{ private int n = 0; public
int next(){ // POST?: next é sempre par
++n;
++n;
retornar n;
}
}

Sem bloqueio, a pós-condição desejada pode falhar devido a um conflito de armazenamento quando dois
ou mais threads executam o próximo método do mesmo objeto Even . Aqui está um rastreamento de
execução possível, mostrando apenas as leituras e gravações na variável n que resultariam dos
putfields e getfields no bytecode compilado.

Linha A Linha B
ler 0
escreva 1
leia 1
escreva 2
leia 2 leia 2
escreva 3
escreva 3 retorno 3
retorno 3

Como é típico em programas simultâneos, a maioria dos rastreamentos de dois threads executando
Even.next simultaneamente não exibe essa violação de segurança. Os programas que usam esta versão do Even
provavelmente passarão em alguns testes, mas é quase certo que quebrarão eventualmente. Essas violações de
segurança podem ser raras e difíceis de testar, mas podem ter efeitos devastadores. Isso motiva as práticas de
design cuidadosas e conservadoras vistas em programas simultâneos confiáveis.

Declarar o próximo método como sincronizado impediria esses rastreamentos conflitantes. O bloqueio serializa
a execução de métodos sincronizados . Portanto, aqui, o próximo método do thread A seria executado por
completo antes do thread B ou vice-versa.

2.2.1 Mecânica

Como preliminar para discussões adicionais sobre estratégias de design baseadas em bloqueio, aqui está
um resumo da mecânica, bem como algumas notas de uso em torno da palavra-chave sincronizada .

2.2.1.1 Objetos e fechaduras

Cada instância da classe Object e suas subclasses possuem um bloqueio. Escalares do tipo int, float, etc., não
são Objetos. Os campos escalares podem ser bloqueados apenas por meio de seus objetos envolventes.
Campos individuais não podem ser marcados como sincronizados. O bloqueio pode ser aplicado apenas
ao uso de campos dentro de métodos. No entanto, conforme descrito no § 2.2.7.4, os campos podem ser
declarados como voláteis, o que afeta a atomicidade, a visibilidade e as propriedades de ordenação em torno de seu uso.
Machine Translated by Google

Da mesma forma, objetos de matriz contendo elementos escalares possuem bloqueios, mas seus elementos
escalares individuais não. (Além disso, não há como declarar os elementos do array como voláteis.)
Bloquear um array de Objects não bloqueia automaticamente todos os seus elementos. Não há construções para
bloquear simultaneamente vários objetos em uma única operação atômica.

Instâncias de classe são Objects. Conforme descrito abaixo, os bloqueios associados aos objetos Class são usados
em métodos sincronizados estáticos .

2.2.1.2 Métodos e blocos sincronizados

Existem duas formas sintáticas baseadas na palavra-chave sincronizada , blocos e métodos. A sincronização de
blocos usa um argumento de qual objeto bloquear. Isso permite que qualquer método bloqueie qualquer objeto.
O argumento mais comum para blocos sincronizados é este.

A sincronização de bloco é considerada mais fundamental do que a sincronização de método. Uma declaração:

void sincronizado f() { /* corpo */ }

é equivalente a:

void f() { sincronizado(este) { /* corpo */ } }

A palavra-chave sincronizada não é considerada parte da assinatura de um método. Portanto, o modificador


sincronizado não é herdado automaticamente quando as subclasses substituem os métodos da superclasse
e os métodos nas interfaces não podem ser declarados como sincronizados. Além disso, os construtores
não podem ser qualificados como sincronizados (embora a sincronização de blocos possa ser usada nos
construtores).

Métodos de instância sincronizados em subclasses empregam o mesmo bloqueio daqueles em suas superclasses.
Mas a sincronização em um método de classe interna é independente de sua classe externa. No entanto, um método
de classe interna não estático pode bloquear sua classe recipiente, digamos OuterClass, por meio de blocos de código usando:

sincronizado(OuterClass.this) { /* corpo */ }.

2.2.1.3 Adquirindo e liberando bloqueios

O bloqueio obedece a um protocolo interno de liberação de aquisição controlado apenas pelo uso da palavra-chave
sincronizada . Todos os bloqueios são estruturados em blocos. Um bloqueio é adquirido na entrada de um método ou
bloco sincronizado e liberado na saída, mesmo que a saída ocorra devido a uma exceção. Você não pode esquecer de
liberar um bloqueio.

Os bloqueios operam por thread, não por chamada. Um thread atingindo passes sincronizados se o bloqueio estiver
livre ou se o thread já possuir o bloqueio e, caso contrário, bloquear. (Esse bloqueio reentrante ou recursivo difere
da política padrão usada, por exemplo, em encadeamentos POSIX.) Entre outros efeitos, isso permite que um
método sincronizado faça uma autochamada para outro método sincronizado no mesmo objeto sem congelar.

Um método ou bloco sincronizado obedece ao protocolo de liberação de aquisição apenas em relação a outros
métodos e blocos sincronizados no mesmo objeto de destino. Métodos que não são
Machine Translated by Google

sincronizado ainda pode ser executado a qualquer momento, mesmo se um método sincronizado estiver em andamento.
Em outras palavras, sincronizado não é equivalente a atômico, mas a sincronização pode ser usada para atingir
a atomicidade.

Quando um thread libera um bloqueio, outro thread pode adquiri-lo (talvez o mesmo thread, se atingir outro
método sincronizado ). Mas não há garantia sobre qual dos threads bloqueados adquirirá o bloqueio em seguida
ou quando o fará. (Em particular, não há garantias de imparcialidade, consulte § 3.4.1.5.) Não há mecanismo
para descobrir se um determinado bloqueio está sendo mantido por algum encadeamento.

Conforme discutido em § 2.2.7, além de controlar o bloqueio, sincronizado também tem o efeito colateral de
sincronizar o sistema de memória subjacente.

2.2.1.4 Estática

Bloquear um objeto não protege automaticamente o acesso aos campos estáticos da classe desse objeto ou de
qualquer uma de suas superclasses. O acesso a campos estáticos é protegido por meio de métodos e blocos
estáticos sincronizados . A sincronização estática emprega o bloqueio possuído pelo objeto Class
associado à classe em que os métodos estáticos são declarados. O bloqueio estático para a classe C também pode ser
acessado dentro de métodos de instância via:

sincronizado(C.class) { /* corpo */ }

O bloqueio estático associado a cada classe não está relacionado ao de qualquer outra classe, incluindo
suas superclasses. Não é eficaz adicionar um novo método sincronizado estático em uma subclasse que tenta
proteger campos estáticos declarados em uma superclasse. Em vez disso, use a versão de bloco explícito.

Também é uma prática ruim usar construções da forma:

sincronizado(getClass()) { /* corpo */ } // Não use

Isso bloqueia a classe real, que pode ser diferente de (uma subclasse da) classe que define os campos
estáticos que precisam ser protegidos.

A JVM obtém e libera internamente os bloqueios para objetos Class durante o carregamento e a inicialização
da classe. A menos que você esteja escrevendo um ClassLoader especial ou mantendo vários bloqueios
durante as sequências de inicialização estática , essas mecânicas internas não podem interferir no uso de métodos
comuns e blocos sincronizados em objetos Class . Nenhuma outra ação interna da JVM adquire independentemente
quaisquer bloqueios para quaisquer objetos de classes que você cria e usa. No entanto, se você criar
subclasses de classes java.* , deverá estar ciente das políticas de bloqueio usadas nessas classes.
Machine Translated by Google

2.2.2 Objetos Totalmente Sincronizados

Um bloqueio é o tipo mais básico de mecanismo de controle de aceitação de mensagem. Os bloqueios podem ser
usados para bloquear clientes que tentam invocar um método em um objeto enquanto outro método ou bloco de
código (executando em um thread diferente) está em andamento.

A estratégia de design OO concorrente mais segura (mas nem sempre a melhor) baseada em bloqueio é restringir
a atenção a objetos totalmente sincronizados (também conhecidos como objetos atômicos) nos quais:

Todos os métodos são sincronizados.


Não há campos públicos ou outras violações de encapsulamento.
Todos os métodos são finitos (sem loops infinitos ou recursão ilimitada) e, portanto, eventualmente liberam
bloqueios.
Todos os campos são inicializados para um estado consistente em construtores.

O estado do objeto é consistente (obedece às invariantes) tanto no início quanto no final de cada método,
mesmo na presença de exceções.

Por exemplo, considere a seguinte classe ExpandableArray , uma variante simplificada de java.util.Vector.

class ExpandableArray {

dados Object[] protegidos; // os elementos protegidos int


size = 0; // INV: 0 <= tamanho <= // o número de slots de array usados
data.length

public ExpandableArray(int cap) { data = new


Object[cap]; }

público tamanho int sincronizado () {


tamanho de
retorno; }

public sincronizado Object get(int i) // acesso subscrito throws NoSuchElementException


{ if (i < 0 || i >= size )
Machine Translated by Google

lançar novo NoSuchElementException();

dados de retorno[i]; }

public sincronizado void add(Object x) { // adiciona no final // precisa de um array


{ Object[] olddata = data; dados = novo maior if (size == data.length)
Objeto[3 * (tamanho + 1) / 2];
System.arraycopy(olddata, 0, data, 0, olddata.length);

} dados[tamanho++] = x; }

public sincronizado void removeLast() lança


NoSuchElementException { if (size == 0) lança
novo
NoSuchElementException();

dados[--tamanho] = nulo;

}}

Sem sincronização, uma instância dessa classe não poderia ser usada de forma confiável em configurações simultâneas.
Por exemplo, ele pode encontrar um conflito de leitura/gravação se processar o acessador enquanto estiver no meio
de uma operação removeLast . E pode encontrar um conflito de gravação/gravação se executar
simultaneamente duas operações de adição , caso em que o estado da matriz de dados seria muito difícil de prever.

2.2.3 Travessia

Em classes totalmente sincronizadas, você pode adicionar outra operação atômica


apenas envolvendo-a em um método sincronizado . Para fins de reutilização e conveniência,
muitas vezes é uma boa ideia adicionar pequenos conjuntos de tais operações a classes de
uso geral ou suas subclasses. Isso evita que os clientes passem por contorções tentando
construir versões atômicas de operações comumente usadas a partir de componentes
menores. Por exemplo, seria útil definir versões sincronizadas de removeFirst, prepend
e métodos semelhantes para ExpandableArray, conforme encontrado em java.util.Vector e outras classes de coleção.

No entanto, essa estratégia não funciona para outro uso comum de coleções, travessia. Uma travessia itera por
todos os elementos de uma coleção e executa alguma operação em ou usando cada elemento.
Como há um número ilimitado de operações que os clientes podem querer aplicar aos elementos de uma coleção,
é inútil tentar codificar todos eles como métodos sincronizados .

Existem três soluções comuns para esse problema de design: operações agregadas, travessia indexada e iteradores
com versão, cada uma refletindo diferentes compensações de design. (Consulte § 2.4.1.3, § 2.4.4 e § 2.5.1.4
para obter estratégias adicionais que se aplicam a outros tipos de classes de coleção.) Os problemas e
compensações encontrados em cada abordagem são vistos de forma mais geral no projeto de muitas classes usando fechaduras.
Machine Translated by Google

2.2.3.1 Operações agregadas sincronizadas

Uma maneira de proteger a travessia é abstrair a operação que está sendo aplicada a cada elemento para que possa
ser enviada como um argumento para um único método applyToAll sincronizado . Por exemplo:

procedimento de interface {
void apply(objeto obj); }

class ExpandableArrayWithApply extends ExpandableArray {

public ExpandableArrayWithApply(int cap) { super(cap); }

void sincronizado applyToAll(Procedimento p) {


for (int i = 0; i < tamanho; ++i)
p.apply(dados[i]);
}
}

Isso poderia ser usado, por exemplo, para imprimir todos os elementos na coleção v:

v.applyToAll(new Procedimento() {
public void apply(Object obj)
{ System.out.println(obj) } } );

Essa abordagem elimina possíveis interferências que poderiam ocorrer se outros encadeamentos tentassem
adicionar ou remover elementos durante a travessia, mas às custas de possivelmente manter o bloqueio na coleção
por períodos prolongados. Embora isso geralmente seja aceitável, pode levar aos tipos de problemas de vivacidade
e desempenho que motivaram a regra padrão em § 1.1.1.1 dizendo para liberar bloqueios ao fazer chamadas de
método (aqui, para aplicar).

2.2.3.2 Percurso indexado e bloqueio do lado do cliente

Uma segunda estratégia de passagem disponível com ExpandableArray é exigir que os clientes usem os
métodos de acesso indexados para passagem; por exemplo:

for (int i = 0; i < v.size(); ++i) // Não use


System.out.println(v.get(i));

Isso evita manter o bloqueio em v durante a execução de cada operação de elemento, mas à custa de duas operações
sincronizadas (tamanho e at) por elemento. Mais importante, o loop deve ser reescrito para lidar com um possível
problema de interferência resultante da granularidade de bloqueio mais fina: É possível que a verificação de i <
v.size() seja bem-sucedida, mas que outro thread remova o último elemento atual antes da chamada para v.get(i).
Uma maneira de lidar com isso é empregar o bloqueio do lado do cliente para preservar a atomicidade na verificação
de tamanho e acesso:

for (int i = 0; verdadeiro; ++i) { // utilidade limitada


Machine Translated by Google

Objeto obj = nulo;


sincronizado(v) { if (i <
v.size()) obj = v.get(i);
senão quebrar;

}
System.out.println(obj);
}

No entanto, mesmo isso pode ser problemático. Por exemplo, se a classe ExpandableArray suportasse métodos para
reorganizar os elementos, esse loop poderia imprimir o mesmo elemento duas vezes se v fosse modificado entre
as iterações.

Como uma medida mais extrema, os clientes podem cercar toda a travessia com sincronizado(v).
Novamente, isso geralmente é aceitável, mas pode induzir problemas de travamento de longo prazo
vistos em métodos agregados sincronizados . Se as operações nos elementos forem demoradas, o cliente pode
primeiro fazer uma cópia do array para fins de passagem:

Instantâneo do objeto[];
sincronizado(v)
{ instantâneo = novo Objeto[v.size()]; for (int i
= 0; i < snapshot.length, ++i)
instantâneo[i] = v.get(i);
}

for (int i = 0; instantâneo.comprimento; ++i)


{ System.out.println(instantâneo[i]); }

O bloqueio do lado do cliente tende a ser usado mais extensivamente em abordagens não orientadas a
objetos para programação multithread. Às vezes, esse estilo é mais flexível e pode ser útil em sistemas OO quando as
instâncias de uma classe são projetadas para serem incorporadas a outras (consulte § 2.4.5) e, portanto, devem abrir mão
da responsabilidade interna pelas decisões de sincronização.

Mas o bloqueio do lado do cliente evita possíveis problemas de interferência às custas da quebra do
encapsulamento. A correção aqui depende de um conhecimento especial do funcionamento interno
da classe ExpandableArray que pode falhar se a classe for modificada posteriormente. Ainda assim, isso pode ser
aceitável em subsistemas fechados. O bloqueio do lado do cliente também pode ser uma opção razoável quando as
classes documentam esses usos como sancionados. Isso também restringe todas as modificações e subclasses
futuras para apoiá-los também.

2.2.3.3 Iteradores versionados

Uma terceira abordagem para passagem é para uma classe de coleção dar suporte a iteradores de falha rápida
que lançam uma exceção se a coleção for modificada no meio de uma passagem. A maneira mais simples de organizar
isso é manter um número de versão que é incrementado a cada atualização da coleção. O iterador pode verificar esse
valor sempre que for solicitado o próximo elemento e lançar uma exceção se ele for alterado.
O campo do número da versão deve ser largo o suficiente para que nunca possa ser quebrado enquanto uma travessia
estiver em andamento. Um int normalmente é suficiente.
Machine Translated by Google

Essa estratégia é usada nas classes java.util.Iterator na estrutura de coleções. Podemos aplicá-lo
aqui a uma subclasse de ExpandableArray que atualiza os números de versão como uma ação
posterior (consulte § 1.4.3):

class ExpandableArrayWithIterator extends ExpandableArray {


versão int protegida = 0;

public ExpandableArrayWithIterator(int cap) { super(cap); }

público sincronizado void removeLast() lança


NoSuchElementException { super.removeLast();
++versão;
// anunciar atualização
}

public void add(Objeto x) { super.add(x); ++versão; }

public sincronizado Iterator iterator() {


return new EAIterator(); }

classe protegida EAIterator implementa Iterator { protected final int


currentVersion; protegido int currentIndex = 0;

EAIterator() { currentVersion = versão; }

Public Object next() {


sincronizado(ExpandableArrayWithIterator.this) {
if (versãoatual != versão)
lançar novo ConcurrentModificationException(); else if (currentIndex
== tamanho)
lançar novo NoSuchElementException(); else return

data[currentIndex++];
}
}

public boolean hasNext()


{ sincronizado(ExpandableArrayWithIterator.this) {
return (currentIndex < tamanho); }

public void remove() { // semelhante


Machine Translated by Google

}}

Aqui, o loop de impressão seria expresso como:

for (Iterator it = v.iterator(); it.hasNext();) {


tente
{ System.out.println(it.next());

} catch (NoSuchElementException ex) { /* ... falha ... */ } catch


(ConcurrentModificationException ex) {
/* ... falhar ... */

}}

Mesmo aqui, as opções para lidar com as falhas costumam ser muito
limitadas. Uma ConcurrentModificationException geralmente significa interações não planejadas e
indesejadas entre threads que devem ser corrigidas em vez de corrigidas.

A abordagem do iterador com versão encapsula as opções de design subjacentes à estrutura de dados, ao
preço de conservadorismo ocasionalmente indevido. Por exemplo, uma operação de adição intercalada
não interferiria na semântica necessária de uma travessia típica, mas faria com que uma exceção fosse lançada
aqui. Os iteradores com versão ainda são uma boa escolha padrão para classes de coleção, em parte
porque é relativamente fácil colocar em camadas a travessia de agregação ou o bloqueio do lado do cliente sobre esses iteradores, ma
versa.

2.2.3.4 Visitantes

O padrão Visitor descrito no livro Design Patterns estende a noção de iteradores para fornecer
suporte para clientes que executam operações em conjuntos de objetos conectados de forma
arbitrária, formando assim os nós de algum tipo de árvore ou gráfico em vez da lista sequencial vista em ExpandableArr
(Menos relevante aqui, o padrão Visitor também suporta operações polimórficas em cada nó.)

As opções e preocupações para os visitantes e outros sentidos estendidos de travessia são semelhantes
e, às vezes, podem ser reduzidos aos vistos em iteradores simples. Por exemplo, você pode primeiro criar uma
lista de todos os nós para percorrer e, em seguida, aplicar qualquer uma das técnicas acima para percorrer
a lista. No entanto, os bloqueios aqui bloqueariam apenas a lista, não os próprios nós. Esta é geralmente a
melhor política. Mas se você precisar garantir que todos os nós estejam bloqueados durante toda a
travessia, considere formas de confinamento (consulte § 2.3.3) ou bloqueio de contenção (consulte § 2.4.5).

Por outro lado, se a travessia for organizada por todos os nós que suportam um método nextNode e você não
quiser acabar segurando simultaneamente todos os bloqueios para todos os nós encontrados
durante a travessia, a sincronização de cada nó deve ser liberada antes de prosseguir para o próximo nó,
conforme descrito em § 2.4.1 e § 2.5.1.4.
Machine Translated by Google

2.2.4 Estática e Singletons

Conforme descrito no livro Design Patterns, uma classe Singleton suporta intencionalmente apenas uma instância.
É conveniente declarar essa única instância como estática, caso em que ambos os métodos de classe e
instância podem usar o mesmo bloqueio.

Aqui está uma maneira de definir uma classe singleton totalmente sincronizada que adia a construção
da instância até que ela seja acessada pela primeira vez por meio do método de instância . Essa classe
representa um contador que pode ser usado para atribuir números de sequência global a objetos,
transações, mensagens, etc., em diferentes classes em um aplicativo. (Apenas para ilustrar o cálculo durante
a inicialização, o valor inicial é definido como um número escolhido aleatoriamente com pelo menos 262 sucessores positivos.)

class LazySingletonCounter { private


final long initial; contagem longa
privada;

private LazySingletonCounter() { inicial =


Math.abs(new java.util.Random().nextLong() / 2); contagem = inicial; }

private static LazySingletonCounter s = null;

private static final Object classLock =


LazySingletonCounter.class;

public static LazySingletonCounter instance() {


sincronizado(classLock) { if (s ==
null) s = new
LazySingletonCounter(); retornar s; }

public long next() {


sincronizado(classLock) { return count++; } }

public void reset() {


sincronizado(classLock) { contagem = inicial; } } }

A mecânica de bloqueio vista aqui (ou qualquer uma das várias variantes secundárias) evita situações em
que dois threads diferentes invocam o método de instância quase ao mesmo tempo, fazendo com que duas
instâncias sejam criadas. Apenas uma dessas instâncias seria vinculada a s e retornada na próxima vez que
a instância fosse invocada. Conforme discutido em § 2.4.1, em alguns casos este e outros enfraquecimentos
semânticos intencionais podem ser aceitáveis; na maioria dos casos, no entanto, isso seria um erro grave.
Machine Translated by Google

Uma maneira mais fácil de evitar esse tipo de erro é evitar a inicialização preguiçosa. Como as JVMs executam
o carregamento dinâmico de classes, geralmente não há necessidade de oferecer suporte à inicialização
preguiçosa de singletons. Um campo estático não é inicializado até que a classe seja carregada em tempo de
execução. Embora não haja garantias sobre exatamente quando uma classe será carregada (além de que ela será
carregada no momento em que for acessada pela execução do código), a inicialização completa de estática tem menos
probabilidade de impor uma sobrecarga significativa de inicialização do que na maioria das outras linguagens . Portanto,
a menos que a inicialização seja muito cara e raramente necessária, geralmente é preferível adotar a abordagem
mais simples de declarar um singleton como um campo final estático . Por exemplo:

class EagerSingletonCounter { private


final long initial; contagem longa
privada;

private EagerSingletonCounter() { inicial =


Math.abs(new java.util.Random().nextLong() / 2); contagem = inicial; }

private static final EagerSingletonCounter s = new


EagerSingletonCounter();

public static EagerSingletonCounter instance() { return s; } public sincronizado long


next() { return count++; } public sincronizado void reset() { count =
inicial; } }

Mais simples ainda, se não houver um motivo convincente para confiar em instâncias, você pode definir e usar
uma versão com todos os métodos estáticos , como em:

class StaticCounter { private


static final long initial = Math.abs(new
java.util.Random().nextLong() / 2); contagem longa estática privada =
inicial; } // desativa a construção da instância private
long next() { return count++; } public StaticCounter() { public estático sincronizado
static sincronizado void reset() { count = inicial; } }

Além disso, considere o uso de ThreadLocal (consulte § 2.3.2) em vez de um Singleton em situações onde é mais
apropriado criar uma instância de uma classe por thread do que uma instância por programa.

2.2.5 Impasse

Embora objetos atômicos totalmente sincronizados sejam sempre seguros, os threads que os usam nem sempre estão ativos.
Considere por exemplo uma classe Cell contendo um método que troca valores com outra Cell:

classe Célula // Não use


{ valor longo privado;
getValue() longo sincronizado { valor de retorno; }
Machine Translated by Google

void sincronizado setValue(long v) { value = v; }

sincronizado void swapValue(Cell other) { long t =


getValue(); longo v =
outro.getValue(); definirValor(v);
outro.setValue(t); } }

SwapValue é uma ação multipartidária sincronizada que adquire intrinsecamente bloqueios


em vários objetos. Sem maiores precauções, é possível que duas threads
diferentes, uma executando a.swapValue(b) e a outra executando b.swapValue(a), entrem em
conflito ao encontrar o seguinte rastreamento:

Tópico 1 Tópico 2

adquirir bloqueio para a ao


inserir
a.swapValue(b) passar bloqueio para adquirir bloqueio para b ao
a (desde que já retido) ao inserir b.swapValue(a)
inserir t = getValue() bloco aguardando passar bloqueio para b (desde que já
bloqueio para b ao inserir v = other.getValue()retido) ao inserir t =
getValue() bloco aguardando bloqueio
para a ao inserir v = other.getValue()

Neste ponto, ambos os threads são bloqueados para sempre.

De forma mais geral, o deadlock é possível quando dois ou mais objetos são mutuamente acessíveis a
partir de dois ou mais threads, e cada thread mantém um bloqueio enquanto tenta obter outro bloqueio
já mantido por outro thread.

2.2.6 Ordenação de Recursos

A necessidade de evitar ou se recuperar de impasses e outras falhas de vivacidade motiva o uso de outras
técnicas de exclusão apresentadas neste capítulo. No entanto, uma técnica simples, a ordenação de recursos,
pode ser aplicada a classes como Cell sem alterar sua estrutura.

A ideia por trás da ordenação de recursos é associar uma tag numérica (ou qualquer outro tipo de
dados estritamente ordenável) a cada objeto que pode ser mantido em um bloco ou método
sincronizado aninhado. Se a sincronização for sempre executada na ordem de menor ordem em relação às
tags de objeto, nunca poderão surgir situações em que uma thread tenha o bloqueio de sincronização para x enquanto espera por y e
Machine Translated by Google

o bloqueio para y enquanto espera por x. Em vez disso, ambos obterão os bloqueios na mesma ordem, evitando
assim essa forma de impasse. De forma mais geral, a ordenação de recursos pode ser usada sempre que houver a
necessidade de quebrar arbitrariamente a simetria ou forçar a precedência em um design simultâneo.

Em alguns contextos (ver, por exemplo, § 2.4.5), pode haver razões para impor algumas regras de ordenação
específicas em torno de um conjunto de fechaduras. Mas em outros, você pode usar qualquer tag conveniente
para fins de ordem de bloqueio. Por exemplo, você pode usar o valor retornado
por System.identityHashCode. Esse método sempre retorna a implementação padrão de Object.hashCode, mesmo
se uma classe substituir o método hashCode . Embora não haja garantia de que identityHashCode seja exclusivo, na
prática, os sistemas de tempo de execução dependem de códigos para serem distintos com uma probabilidade muito
alta. Para ficar ainda mais seguro sobre isso, você pode sobrescrever o método hashCode ou introduzir outro
método de tag para garantir a exclusividade em quaisquer classes que empregam ordenação de recursos. Por
exemplo, você pode atribuir a cada objeto um número de sequência usando uma das classes em § 2.2.4.

Uma verificação adicional, a detecção de alias, pode ser aplicada em métodos que usam sincronização aninhada para
lidar com casos em que duas (ou mais) das referências estão realmente vinculadas ao mesmo objeto. Por exemplo, em
swapValue, você pode verificar se uma célula está sendo solicitada a trocar consigo mesma. Este tipo de verificação é
estritamente opcional aqui (mas veja § 2.5.1). O acesso ao bloqueio de sincronização é por thread, não por chamada.
Tentativas adicionais de sincronizar em objetos já retidos ainda funcionarão. No entanto, a verificação de alias de
rotina é uma maneira útil de prevenir a funcionalidade downstream, a eficiência e as complicações baseadas na
sincronização. Pode ser aplicado antes de usar a sincronização em torno de dois ou mais objetos, a menos que sejam
de tipos distintos e não relacionados. (Duas referências de dois tipos declarados não relacionados não podem estar se
referindo ao mesmo objeto de qualquer maneira, então não há razão para verificar.)

Uma versão melhor de swapValue, aplicando tanto a ordenação de recursos quanto a detecção de alias, pode ser escrita
como:

public void swapValue(Cell other) { if (other ==


this) // verificação de alias
retornar;
else if (System.identityHashCode(this) <
System.identityHashCode(other))
this.doSwapValue(other); else

other.doSwapValue(this);
}

void sincronizado protegido doSwapValue(Cell other) { // igual à versão pública


original: long t = getValue(); longo v =
outro.getValue();
definirValor(v); outro.setValue(t); }

Como um pequeno ajuste de eficiência, poderíamos simplificar ainda mais o código dentro do doSwapValue primeiro
para adquirir os bloqueios necessários e, em seguida, acessar diretamente os campos de valor. Isso evita uma
chamada automática para um método sincronizado enquanto já mantém o bloqueio necessário, com a pequena
despesa de adicionar linhas de código que precisariam ser alteradas se a natureza dos campos fosse modificada:
Machine Translated by Google

// versão ligeiramente mais rápida


protegida sincronizada void doSwapValue(Cell other) {
sincronizado(outros) { longo t =
valor; valor =
outro.valor; outro.valor = t; }

Observe que o bloqueio para this é obtido por meio do qualificador de método sincronizado , mas o bloqueio para
other é adquirido explicitamente. Uma melhoria de desempenho muito pequena (talvez inexistente) pode ser
obtida dobrando o código em doSwapValue em swapValue, lembrando-se de adquirir ambos os bloqueios
explicitamente.

Problemas de ordem de bloqueio não são de forma alguma restritos a métodos que usam sincronização
aninhada. O problema surge em qualquer sequência de código na qual um método sincronizado que mantém o
bloqueio em um objeto, por sua vez, chama um método sincronizado em outro objeto. No entanto, há
menos oportunidade de aplicar a ordenação de recursos em chamadas em cascata: No caso geral, um objeto não
pode saber com certeza quais outros objetos estarão envolvidos em chamadas downstream e se eles requerem
sincronização. Esta é uma das razões pelas quais o deadlock pode ser um problema tão difícil em sistemas
abertos (consulte o § 2.5) quando você não pode liberar a sincronização durante as chamadas (consulte o § 2.4.1).

2.2.7 O Modelo de Memória Java

Considere a classe minúscula, definida sem nenhuma sincronização:

classe final SetCheck { private int


a = 0; b longo privado = 0;

void set() { a = 1; b
= -1; }

verificação booleana() {
retornar ((b == 0) ||
(b == -1 && a == 1));

}}

Em uma linguagem puramente sequencial, a verificação do método nunca poderia retornar false. Isso ocorre
mesmo que compiladores, sistemas de tempo de execução e hardware possam processar esse código de uma
maneira que você não espera intuitivamente. Por exemplo, qualquer um dos itens a seguir pode se aplicar à
execução do conjunto de métodos:

O compilador pode reorganizar a ordem das instruções, de modo que b pode ser atribuído antes de a.
Se o método for embutido, o compilador pode reorganizar ainda mais os pedidos em relação a outros
declarações.
Machine Translated by Google

O processador pode reorganizar a ordem de execução das instruções de máquina correspondentes às


instruções, ou mesmo executá-las ao mesmo tempo.
O sistema de memória (conforme governado pelas unidades de controle de cache) pode reorganizar a
ordem na qual as gravações são confirmadas nas células de memória correspondentes às variáveis.
Essas gravações podem se sobrepor a outros cálculos e ações de memória.
O compilador, processador e/ou sistema de memória podem intercalar os efeitos de nível de máquina das
duas instruções. Por exemplo, em uma máquina de 32 bits, a palavra de ordem superior de b pode
ser escrita primeiro, seguida pela gravação em a, seguida pela gravação na palavra de ordem inferior de b.
O compilador, processador e/ou sistema de memória pode fazer com que as células de memória que
representam as variáveis não sejam atualizadas até algum tempo depois (se alguma vez) uma verificação
subseqüente for chamada, mas, em vez disso, manter os valores correspondentes (por exemplo, nos
registradores da CPU) em de forma que o código ainda tenha o efeito pretendido.

Em uma linguagem seqüencial, nada disso importa, desde que a execução do programa obedeça à semântica
serial[2]. Os programas sequenciais não podem depender dos detalhes do processamento interno de
instruções dentro de blocos de código simples, portanto, eles são livres para serem manipulados de todas essas
maneiras. Isso fornece flexibilidade essencial para compiladores e máquinas. A exploração de tais oportunidades
(por meio de CPUs superescalares em pipeline, caches multiníveis, balanceamento de carga/armazenamento,
alocação de registro interprocedural e assim por diante) é responsável por uma quantidade significativa
de grandes melhorias na velocidade de execução observadas na computação na última década. A propriedade
como se fosse serial dessas manipulações protege os programadores sequenciais da necessidade de saber se
ou como elas ocorrem. Os programadores que nunca criam seus próprios threads quase nunca são afetados por esses problemas.

[2] Um pouco mais precisamente, a semântica as-if-serial (também conhecida como ordem do programa) pode
ser definida como qualquer travessia de execução do grafo formado ordenando apenas aquelas operações
que têm valor ou dependências de controle em relação umas às outras sob uma linguagem expressão de base e semântica de instrução.

As coisas são diferentes na programação concorrente. Aqui, é totalmente possível que a verificação seja chamada
em um thread enquanto o conjunto está sendo executado em outro, caso em que a verificação pode estar "espionando"
a execução otimizada do conjunto. E se ocorrer alguma das manipulações acima, é possível que o check
retorne false. Por exemplo, conforme detalhado abaixo, a verificação pode ler um valor para o b longo que não é 0
nem -1, mas um valor intermediário meio escrito. Além disso, a execução fora de ordem das instruções em set pode
fazer com que a verificação leia b como -1 , mas, em seguida, leia a como ainda 0.

Em outras palavras, não apenas as execuções simultâneas podem ser intercaladas, mas também podem ser
reordenadas e manipuladas de uma forma otimizada que tenha pouca semelhança com seu código-fonte. À
medida que a tecnologia do compilador e do tempo de execução amadurece e os multiprocessadores se
tornam mais predominantes, esses fenômenos se tornam mais comuns. Eles podem levar a resultados
surpreendentes para programadores com experiência em programação sequencial (em outras palavras, quase todos
os programadores) que nunca foram expostos às propriedades de execução subjacentes do código
supostamente sequencial. Isso pode ser a fonte de erros sutis de programação simultânea.

Em quase todos os casos, existe uma maneira óbvia e simples de evitar a contemplação de todas as
complexidades que surgem em programas concorrentes devido à mecânica de execução otimizada: Use a
sincronização. Por exemplo, se ambos os métodos na classe SetCheck forem declarados como sincronizados,
você pode ter certeza de que nenhum detalhe de processamento interno pode afetar o resultado pretendido desse código.

Mas às vezes você não pode ou não quer usar a sincronização. Ou talvez você deva raciocinar sobre o código de
outra pessoa que não o utiliza. Nesses casos, você deve confiar nas garantias mínimas sobre a semântica resultante
especificada pelo Java Memory Model. Este modelo permite os tipos de manipulações listados acima,
mas limita seus efeitos potenciais na semântica de execução e adicionalmente
Machine Translated by Google

aponta para algumas técnicas que os programadores podem usar para controlar alguns aspectos dessa semântica
(a maioria das quais é discutida em § 2.4).

O Java Memory Model faz parte de The Java Language Specification, descrito principalmente no capítulo 17 de
JLS. Aqui, discutimos apenas a motivação básica, as propriedades e as consequências de programação do modelo.
O tratamento aqui apresentado reflete alguns esclarecimentos e atualizações que faltam na primeira edição do
JLS[3].

[3]
No momento em que este livro foi escrito, o modelo de memória e outras seções relevantes do JLS ainda estavam sendo
atualizados para abranger a plataforma Java 2. Por favor, verifique o suplemento on-line para quaisquer alterações que
afetem o material desta seção.

As suposições subjacentes ao modelo podem ser vistas como uma idealização de uma máquina SMP padrão do tipo
descrito em § 1.2.4:

Para fins do modelo, cada encadeamento pode ser considerado como sendo executado em uma CPU diferente
de qualquer outro encadeamento. Mesmo em multiprocessadores, isso não é frequente na prática, mas o fato
de esse mapeamento de CPU por thread estar entre as formas legais de implementar threads explica
algumas das propriedades inicialmente surpreendentes do modelo. Por exemplo, como as CPUs possuem
registradores que não podem ser acessados diretamente por outras CPUs, o modelo deve permitir casos em
que uma thread não sabe sobre os valores que estão sendo manipulados por outra thread. No entanto, o
impacto do modelo não se restringe de forma alguma aos multiprocessadores. As ações de compiladores e
processadores podem levar a preocupações idênticas mesmo em sistemas de CPU única.

O modelo não aborda especificamente se os tipos de táticas de execução discutidos acima são executados por
compiladores, CPUs, controladores de cache ou qualquer outro mecanismo. Ele nem mesmo os discute em
termos de classes, objetos e métodos familiares aos programadores. Em vez disso, o modelo define uma relação
abstrata entre as threads e a memória principal. Cada thread é definida para ter uma memória de trabalho
(uma abstração de caches e registradores) na qual armazenar valores. O modelo garante algumas propriedades
envolvendo as interações das sequências de instruções correspondentes aos métodos e células de
memória correspondentes aos campos. A maioria das regras é formulada em termos de quando os valores
devem ser transferidos entre a memória principal e a memória de trabalho por thread. As regras abordam
três questões interligadas:

Atomicidade. Quais instruções devem ter efeitos indivisíveis. Para fins do modelo, essas regras precisam ser
declaradas apenas para leituras e gravações simples de células de memória que representam instâncias de campos
e variáveis estáticas, incluindo também elementos de array, mas não incluindo variáveis locais dentro de métodos.
Machine Translated by Google

Visibilidade. Em que condições os efeitos de um segmento são visíveis para outro. Os efeitos de interesse aqui são
gravações em campos, conforme visto por meio de leituras desses campos.

Encomenda. Sob quais condições os efeitos das operações podem aparecer fora de ordem para qualquer thread.
Os principais problemas de ordenação envolvem leituras e gravações associadas a sequências de instruções
de atribuição.

Quando a sincronização é usada de forma consistente, cada uma dessas propriedades tem uma caracterização simples:
todas as alterações feitas em um método ou bloco sincronizado são atômicas e visíveis em relação a outros métodos e
blocos sincronizados que empregam o mesmo bloqueio e o processamento de métodos ou blocos
sincronizados em qualquer determinado segmento está na ordem especificada pelo programa. Mesmo que o
processamento de instruções dentro de blocos possa estar fora de ordem, isso não importa para outras threads que
utilizam sincronização.

Quando a sincronização não é usada ou é usada de forma inconsistente, as respostas se tornam mais complexas.
As garantias feitas pelo modelo de memória são mais fracas do que a maioria dos programadores espera intuitivamente
e também são mais fracas do que aquelas normalmente fornecidas em qualquer implementação de JVM. Isso impõe
obrigações adicionais aos programadores que tentam garantir as relações de consistência do objeto que estão no
cerne das práticas de exclusão: os objetos devem manter invariantes conforme visto por todos os encadeamentos
que dependem deles, não apenas pelo encadeamento que executa qualquer modificação de estado.

As regras e propriedades mais importantes especificadas pelo modelo são discutidas abaixo.

2.2.7.1 Atomicidade

Acessos e atualizações às células de memória correspondentes a campos de qualquer tipo, exceto long ou
double, são garantidos como atômicos. Isso inclui campos que servem como referências a outros objetos.
Além disso, a atomicidade se estende a voláteis longos e duplos. (Embora longs e doubles não voláteis não sejam
atômicos garantidos, é claro que eles podem ser.)

As garantias de atomicidade garantem que, quando um campo não longo/duplo for usado em uma expressão, você
obterá seu valor inicial ou algum valor que foi gravado por algum encadeamento, mas não uma confusão de bits
resultante de dois ou mais encadeamentos tentando escrever valores ao mesmo tempo. No entanto, como visto
abaixo, a atomicidade por si só não garante que você obterá o valor mais recente escrito por qualquer thread. Por
esse motivo, as garantias de atomicidade per se normalmente têm pouco impacto no design de programas
concorrentes.

2.2.7.2 Visibilidade

As alterações nos campos feitas por um encadeamento são visíveis para outros encadeamentos somente nas
seguintes condições:

1. Um thread de gravação libera um bloqueio de sincronização e um thread de leitura subsequentemente adquire


esse mesmo bloqueio de sincronização.

Em essência, liberar um bloqueio força uma descarga de todas as gravações da memória de trabalho
empregadas pelo thread e adquirir um bloqueio força um (re)carregamento dos valores dos campos acessíveis.
Embora as ações de bloqueio forneçam exclusão apenas para as operações executadas em um método
ou bloco sincronizado , esses efeitos de memória são definidos para cobrir todos os campos usados
pelo thread que executa a ação.
Machine Translated by Google

Observe o duplo significado de sincronizado: ele lida com bloqueios que permitem protocolos de sincronização
de nível superior, ao mesmo tempo em que lida com o sistema de memória (às vezes por meio de
instruções de máquina de barreira de memória de baixo nível) para manter as representações de valor em
sincronia entre os threads. Isso reflete uma maneira pela qual a programação concorrente tem mais
semelhança com a programação distribuída do que com a programação sequencial. O último sentido de
sincronizado pode ser visto como um mecanismo pelo qual um método em execução em uma thread indica que
deseja enviar e/ou receber alterações em variáveis de e para métodos em execução em outras threads.
Desse ponto de vista, o uso de bloqueios e a passagem de mensagens podem ser vistos apenas como variantes
sintáticas um do outro.

2. Se um campo for declarado como volátil, qualquer valor gravado nele será liberado e tornado visível por
o thread do gravador antes que o thread do gravador execute qualquer outra operação de memória (ou seja,
para os propósitos em questão, ele é liberado imediatamente). As threads do leitor devem recarregar
os valores dos campos voláteis a cada acesso.
3. Na primeira vez que um thread acessa um campo de um objeto, ele vê o valor inicial[4] do campo ou um valor
desde que escrito por algum outro thread.

[4]
No momento em que este livro foi escrito, o JLS ainda não afirmava claramente que o valor inicial visível lido para um
O campo final inicializado é o valor atribuído em seu inicializador ou construtor. No entanto, esse
esclarecimento antecipado é assumido ao longo deste livro. Os valores padrão iniciais visíveis de campos não finais
são zero para escalares e nulo para referências.

Entre outras consequências, é má prática disponibilizar a referência a um objeto construído de


forma incompleta (ver § 2.1.2). Também pode ser arriscado iniciar novos threads dentro de um construtor,
especialmente em uma classe que pode ser uma subclasse. Thread.start tem os mesmos efeitos de memória
que uma liberação de bloqueio pelo thread que chama start, seguido por um bloqueio adquirido pelo thread
iniciado. Se uma superclasse Runnable invocar new Thread(this).start() antes da execução dos construtores da
subclasse, então o objeto pode não ser totalmente inicializado quando o método run é executado. Da mesma
forma, se você criar e iniciar um novo thread T e, em seguida, criar um objeto X usado pelo thread T, não
poderá ter certeza de que os campos de X serão visíveis para T , a menos que você empregue sincronização
envolvendo todas as referências ao objeto X. Ou, quando aplicável, você pode criar X antes de iniciar T.

4. Quando um thread termina, todas as variáveis escritas são descarregadas na memória principal.

Por exemplo, se um thread sincroniza na terminação de outro thread usando Thread.join, é


garantido que ele verá os efeitos feitos por esse thread (consulte § 4.3.2).

Observe que os problemas de visibilidade nunca surgem ao passar referências a objetos entre métodos no mesmo
thread.

O modelo de memória garante que, dada a eventual ocorrência das operações acima, uma determinada
atualização de um determinado campo feita por uma thread acabará sendo visível para outra. Mas eventualmente
pode ser um tempo arbitrariamente longo. Longos trechos de código em threads que não usam
sincronização podem estar totalmente fora de sincronia com outros threads em relação aos valores dos campos. Em
particular, é sempre errado escrever loops esperando por valores escritos por outras threads, a menos que os
campos sejam voláteis ou acessados via sincronização (ver § 3.2.6).

O modelo também permite visibilidade inconsistente na ausência de sincronização. Por exemplo, é possível obter
um valor novo para um campo de um objeto, mas um valor obsoleto para outro. Da mesma forma, é
Machine Translated by Google

possível ler um valor novo e atualizado de uma variável de referência, mas um valor obsoleto de um dos campos do
objeto que agora está sendo referenciado.

No entanto, as regras não exigem falhas de visibilidade entre os encadeamentos, elas apenas permitem que essas falhas
ocorram. Este é um aspecto do fato de que não usar sincronização em código multithread não garante violações de
segurança, apenas as permite. Na maioria das implementações e plataformas JVM atuais, mesmo aquelas que empregam
vários processadores, raramente ocorrem falhas de visibilidade detectáveis. O uso de caches comuns entre threads
que compartilham uma CPU, a falta de otimizações agressivas baseadas em compilador e a presença de hardware de
consistência de cache forte geralmente fazem com que os valores ajam como se fossem propagados imediatamente
entre os threads. Isso torna impraticável testar a ausência de erros baseados em visibilidade, pois tais erros podem
ocorrer extremamente raramente, ou apenas em plataformas às quais você não tem acesso, ou apenas naquelas que
ainda não foram construídas. Esses mesmos comentários se aplicam a falhas de segurança multithread em geral.
Programas simultâneos que não usam sincronização falham por vários motivos, incluindo problemas de consistência
de memória.

2.2.7.3 Pedidos

As regras de ordenação se enquadram em dois casos, dentro do encadeamento e entre encadeamentos:

Do ponto de vista do encadeamento que executa as ações em um método, as instruções prosseguem da maneira
normal como se fosse serial que se aplica às linguagens de programação sequencial.
Do ponto de vista de outros encadeamentos que podem estar "espionando" este encadeamento executando
simultaneamente métodos não sincronizados, quase tudo pode acontecer. A única restrição útil é que as
ordenações relativas de métodos e blocos sincronizados , bem como operações em campos voláteis , sejam
sempre preservadas.

Novamente, essas são apenas as propriedades mínimas garantidas. Em qualquer programa ou plataforma, você pode
encontrar ordenações mais rígidas. Mas você não pode confiar neles e pode achar difícil testar o código que falharia em
implementações de JVM que possuem propriedades diferentes, mas ainda estão em conformidade com as regras.

Observe que o ponto de vista dentro do thread é adotado implicitamente em todas as outras discussões de semântica no
JLS. Por exemplo, a avaliação da expressão aritmética é executada na ordem da esquerda para a direita (JLS seção
15.6), conforme visualizado pelo encadeamento que executa as operações, mas não necessariamente conforme
visualizado por outros encadeamentos.

A propriedade as-if-serial dentro do thread é útil apenas quando apenas um thread por vez está manipulando variáveis,
devido à sincronização, exclusão estrutural ou puro acaso. Quando vários threads estão todos executando código não
sincronizado que lê e escreve campos comuns, intercalações arbitrárias, falhas de atomicidade, condições de
corrida e falhas de visibilidade podem resultar em padrões de execução que tornam a noção de como se serial quase
sem sentido em relação a qualquer dado fio.

Embora o JLS aborde alguns reordenamentos legais e ilegais específicos que podem ocorrer, as interações com essas
outras questões reduzem as garantias práticas a dizer que os resultados podem refletir praticamente qualquer possível
intercalação de praticamente qualquer reordenamento possível. Portanto, não faz sentido tentar raciocinar sobre as
propriedades de ordenação de tal código.

2.2.7.4 Volátil

Em termos de atomicidade, visibilidade e ordenação, declarar um campo como volátil é quase idêntico em efeito a usar
uma pequena classe totalmente sincronizada protegendo apenas esse campo por meio de métodos get/set, como em:
Machine Translated by Google

classe final VFloat {


valor flutuante privado;

void sincronizado final set(float f) { value = f; } final sincronizado float get()


{ valor de retorno; } }

Declarar um campo como volátil difere apenas porque nenhum bloqueio está envolvido. Em particular, as
operações compostas de leitura/gravação, como a operação "++'' em variáveis voláteis , não são
executadas atomicamente.

Além disso, os efeitos de ordenação e visibilidade envolvem apenas o único acesso ou atualização ao próprio
campo volátil . Declarar um campo de referência como volátil não garante a visibilidade dos campos não voláteis
que são acessados por meio dessa referência. Da mesma forma, declarar um campo de matriz como volátil não
garante a visibilidade de seus elementos. A volatilidade não pode ser propagada manualmente para arrays
porque os próprios elementos do array não podem ser declarados como voláteis.

Como nenhum bloqueio está envolvido, declarar campos como voláteis provavelmente será mais barato do
que usar a sincronização ou, pelo menos, não mais caro. No entanto, se os campos voláteis forem
acessados com frequência dentro dos métodos, é provável que seu uso leve a um desempenho mais lento do
que o bloqueio de todos os métodos.

Declarar campos como voláteis pode ser útil quando você não precisa de bloqueio por nenhum outro motivo, mas
os valores devem ser acessíveis com precisão em vários encadeamentos. Isso pode ocorrer quando:

O campo não precisa obedecer a nenhum invariante em relação a outros.


As gravações no campo não dependem de seu valor atual.
Nenhum thread jamais escreve um valor ilegal em relação à semântica pretendida.
As ações dos leitores não dependem de valores de outros campos não voláteis.

O uso de campos voláteis pode fazer sentido quando se sabe de alguma forma que apenas um thread pode alterar
um campo, mas muitos outros threads podem lê-lo a qualquer momento. Por exemplo, uma classe Thermometer
pode declarar seu campo de temperatura como volátil. Conforme discutido em § 3.4.2, um volátil
pode ser útil como um sinalizador de conclusão. Exemplos adicionais são ilustrados em § 4.4, onde o uso de
estruturas executáveis leves automatiza alguns aspectos da sincronização, mas declarações voláteis são
necessárias para garantir que os valores do campo de resultado sejam visíveis nas tarefas.

2.2.8 Leituras Adicionais


Os recursos das arquiteturas de computador que afetam os programas multithread são descritos em:

Schimmel, Curt. Sistemas UNIX para Arquiteturas Modernas Multiprocessamento Simétrico e Cache para
Programadores de Kernel, Addison-Wesley, 1994.

Patterson, David e John Hennessy. Computer Organization and Design: The Hardware/Software Interface,
Morgan Kaufmann, 1997. Veja também seu suplemento online com links para recursos adicionais sobre
arquiteturas de máquinas específicas.
Machine Translated by Google

Os modelos de consistência de memória são objeto de atenção cada vez maior à medida que os programas
multiprocessadores e multithread se tornam mais comuns e suas interações se tornam mais preocupantes. Pelo
menos no que diz respeito ao bloqueio, o modelo de memória Java é o mais próximo da família de modelos de
consistência de liberação. Para uma visão geral, consulte:

Adve, Sarita e K. Gharachorloo. "Modelos de consistência de memória compartilhada: um tutorial", IEEE


Computer, dezembro de 1996, 66-76. Veja também os acompanhamentos, incluindo: "Recent Advances in
Memory Consistency Models for Hardware Shared-Memory Systems" Proceedings of the IEEE, edição especial sobre
memória compartilhada distribuída, 1999.

2.3 Confinamento

O confinamento emprega técnicas de encapsulamento para garantir estruturalmente que no máximo uma atividade
por vez possa acessar um determinado objeto. Isso garante estaticamente que a acessibilidade de um determinado
objeto seja exclusiva para um único thread sem a necessidade de contar com bloqueio dinâmico em cada
acesso. A tática principal é definir métodos e classes que estabeleçam domínios de propriedade à prova de
vazamentos, garantindo que apenas um thread, ou um thread por vez, possa acessar um objeto confinado.

As práticas de confinamento são semelhantes a outras medidas de segurança que garantem que nenhuma
informação confidencial escape de um domínio. O vazamento de informação de interesse aqui é o acesso a objetos,
quase sempre por meio de referências a esses objetos. Esse problema apresenta os mesmos tipos de desafios
vistos em outros aspectos da segurança: às vezes é difícil demonstrar que nem mesmo um vazamento é possível, mas
o confinamento não pode ser confiável a menos que um projeto seja comprovadamente à prova de vazamentos.
No entanto, essa tarefa é menos crítica do que em alguns outros aspectos da segurança, pois existem
estratégias de backup. Assim, quando você não puder garantir o confinamento, poderá empregar outras técnicas de exclusão descritas neste ca

O confinamento depende do escopo, controle de acesso e recursos de segurança de um determinado idioma


que oferece suporte à ocultação e encapsulamento de dados. No entanto, os sentidos de confinamento
necessários para garantir a singularidade não podem ser completamente reforçados pelos mecanismos da
linguagem. Existem quatro categorias para verificar se uma referência r para um objeto x pode escapar de um
método m em execução dentro de alguma atividade:

m passa r como um argumento em uma chamada de método ou construtor de


objeto. m passa r como o valor de retorno de uma chamada de
método. m registra r em algum campo acessível a partir de outra atividade (no caso mais flagrante,
campos estáticos acessíveis em qualquer lugar). m
libera (em qualquer uma das formas acima) outra referência que pode, por sua vez, ser percorrida para acessar
r.
Machine Translated by Google

Às vezes, vazamentos selecionados podem ser tolerados se você puder garantir de alguma forma que os escapes sejam
permitidos apenas para métodos que não podem causar mudanças de estado (atribuições de campo) nos objetos de
interesse (consulte § 2.4.3 ).

Em algumas classes e subsistemas fechados (ver § 1.3.4), estes assuntos podem ser exaustivamente verificados. Em
sistemas abertos, a maioria das restrições só pode ser mantida como regras de projeto, auxiliadas por ferramentas
e revisões.

Esta seção discute quatro tipos de confinamento. A primeira e mais simples, confinamento de método, envolve práticas de
programação comuns em torno de variáveis locais. O segundo, confinamento de thread, apresenta técnicas para
restringir o acesso dentro de threads. O terceiro, confinamento de objeto, usa técnicas de encapsulamento OO para
fornecer as garantias mais fortes necessárias para garantir a exclusividade de acesso para métodos que entram em objetos.
O quarto, confinamento de grupo, estende essas técnicas para conjuntos colaborativos de objetos operando em vários
threads.

2.3.1 Confinamento entre métodos

Se uma determinada invocação de método cria um objeto e não o deixa escapar, pode-se ter certeza de que nenhum
outro thread irá interferir (ou mesmo saber sobre) o uso desse objeto. Ocultar o acesso dentro de escopos locais é uma
tática de encapsulamento comum em todas as formas de programação.

Com apenas um mínimo de cuidado, essas técnicas podem ser estendidas a sequências de invocações de método.
Por exemplo, considere a seguinte classe que usa java.awt.Point. Essa classe Point é definida como uma classe de
estilo de registro simples com campos x e y públicos , portanto, não seria sensato compartilhar instâncias entre threads.

plotter de classe { // ... // Fragmentos

public void showNextPoint() { Ponto p = new


Ponto(); px = calcularX(); py =
computaY(); exibir(p); }

protected void display(Point p) { // de alguma


forma organiza para mostrar p. } }

Aqui o método showNextPoint cria um ponto local. Ele permite que o Point escape para display(p) apenas em uma
chamada final, depois que showNextPoint tem certeza de nunca acessá-lo novamente, mesmo que o Point seja acessado
posteriormente de outro thread. (O acesso de outro thread pode ocorrer aqui: Essencialmente, todos os programas
baseados em gráficos de alguma forma dependem do thread de evento AWT, consulte § 1.1.1.3 e § 4.1.4 , embora seja
improvável que o thread modifique o objeto Point .)

Este é um exemplo de protocolo de transferência que garante que, a qualquer momento, no máximo um método de
execução ativa possa acessar um objeto. Essa versão de chamada final é a forma mais simples e geralmente a melhor.
Machine Translated by Google

Usos semelhantes são vistos em métodos de fábrica que constroem e inicializam um objeto e finalmente o retornam,
como visto, por exemplo, no método ParticleApplet.makeThread em § 1.1.1.3.

2.3.1.1 Sessões

Muitas sequências de transferência são estruturadas como sessões nas quais algum método de entrada pública
constrói objetos que serão confinados a uma sequência de operações que compreende um serviço. Esse método
de entrada também deve ser responsável por quaisquer operações de limpeza necessárias após a conclusão da
sequência. Por exemplo:

class SessionBasedService { // ... // Fragmentos


public
void service() {
Saída OutputStream = nulo; tente

{ output = new FileOutputStream("..."); doService(saída);

} catch (IOException e)
{ handleIOFailure(); }

finalmente
{ tente { if (saída != null) saída.close(); } catch (IOException
ignore) {} // ignora exceção no fechamento }

void doService(OutputStream s) throws IOException { s.write(...); // ...


possivelmente
mais transferências ... } }

Quando você tem uma escolha entre eles, quase sempre é preferível executar a limpeza nas
cláusulasfinalmente em vez de depender da finalização (isto é, sobrescrever Object.finalize). O uso de finalmente
fornece uma garantia mais forte sobre quando a limpeza ocorrerá, o que ajuda a conservar recursos
possivelmente escassos, como arquivos. Por outro lado, os finalizadores geralmente são acionados
de forma assíncrona como resultado da coleta de lixo, se houver.

2.3.1.2 Protocolos alternativos

As transferências de chamada final não se aplicam se um método deve acessar um objeto após uma chamada ou
deve fazer várias chamadas. Regras de design adicionais são necessárias para cobrir casos como uma classe
Plotter revisada com o método:

public void showNextPointV2() { Ponto p =


new Ponto(); px = calcularX();
py = computaY();
exibir(p);
Machine Translated by Google

registroDistância(p); // adicionado }

As opções incluem:

Cópias do chamador. Quando os objetos transmitidos representam valores de dados, como pontos nos quais a
identidade do objeto não importa, o chamador pode fazer uma cópia do objeto para uso do receptor.
Aqui, por exemplo,

exibir(p);

seria substituído por:

display(novo Ponto(px, py));

Cópias do receptor. Se um método não sabe nada sobre as restrições de uso em torno de uma referência de
objeto enviada como um argumento (e, novamente, se a identidade do objeto não importa), ele pode fazer uma
cópia conservadora para seu próprio uso local. Aqui, por exemplo, o método display poderia ter como primeira linha:

Point localPoint = new Point(px, py);

Usando argumentos escalares. As incertezas sobre as responsabilidades do chamador e do destinatário podem ser
eliminadas não enviando referências, mas enviando argumentos escalares que forneçam informações suficientes para
o destinatário construir um objeto, se desejado. Aqui, por exemplo, poderíamos reparametrizar a exibição para:

display void protegido(int xcoord, int ycoord) { ... }

e a chamada para:

exibir(px, py);

Confiar. Um receptor (ou melhor, seu autor) pode prometer não modificar ou transmitir objetos acessíveis por meio
de argumentos de referência. Deve, por sua vez, garantir a falta de acesso indesejado em qualquer chamada downstream.

Se nada disso puder ser providenciado, então o confinamento puro não tem garantia de sucesso, e outras
soluções descritas neste capítulo devem ser aplicadas. Por exemplo, se o uso de java.awt.Point não for necessário
aqui, você pode usar uma classe ImmutablePoint para garantir a falta de modificação (consulte § 2.4.4).

2.3.2 Confinamento nas roscas

de confinamento baseadas em threads mais [5] estendem aqueles para sequências de método. Na verdade, as técnicas
simples e muitas vezes a melhor técnica são usar um design de thread por sessão (consulte § 4.1) que é idêntico
ao confinamento baseado em sessão. Por exemplo, você pode inicializar transferências na base de um método de execução :

[5]
Mesmo que esta seção pressuponha apenas o conhecimento dos usos básicos do Thread apresentados
no Capítulo 1, você pode achar útil uma rápida olhada no Capítulo 4 .
Machine Translated by Google

class ThreadPerSessionBasedService { // fragmentos


// ...
public void service() { Runnable
r = new Runnable() { public void run()
{ OutputStream output =
null; tente { output = new

FileOutputStream("..."); doService(saída);

} catch (IOException e)
{ handleIOFailure(); }

finalmente
{ tente { if (saída != null) saída.close(); } catch (IOException
ignore) {} } } }; novo Thread(r).start(); }

void doService(OutputStream s) throws IOException { s.write(...); // ...


possivelmente
mais transferências ... } }

Algumas abordagens para design de software simultâneo (como CSP, consulte § 4.5.1) organizam ou exigem que todos os
campos acessíveis em um thread sejam estritamente confinados a esse thread. Isso imita o isolamento forçado de
espaços de endereçamento visto em processo versus programação concorrente baseada em thread (consulte § 1.2.2).

No entanto, observe que geralmente é impossível limitar o acesso a todos os objetos usados em um determinado encadeamento.
Todos os encadeamentos em execução em uma determinada JVM devem, em última análise, compartilhar o acesso
a pelo menos alguns recursos subjacentes, por exemplo, aqueles controlados por meio de métodos na classe java.lang.System .

2.3.2.1 Campos específicos do segmento

Além de receber referências confinadas ao longo das cadeias de chamadas, as invocações de método em execução
em um único thread podem acessar o objeto Thread que representa o thread em que estão sendo executados e qualquer outra
informação percorrível a partir daí. O método estático Thread.currentThread() pode ser chamado de qualquer método e retorna
o objeto Thread do chamador.

Você pode explorar isso adicionando campos às subclasses Thread e fornecendo métodos para acessá-los somente de
dentro do thread atual. Por exemplo:

class ThreadWithOutputStream extends Thread {


Machine Translated by Google

saída OutputStream privada;

ThreadWithOutputStream(Runnable r, OutputStream s) {
super(r);
saída = s; }

static ThreadWithOutputStream current() lança


ClassCastException { return
(ThreadWithOutputStream) (currentThread()); }

static OutputStream getOutput() { return current().output; }

static void setOutput(OutputStream s) { current().output = s;} }

Esta classe poderia ser utilizada, por exemplo, em:

classe ServiceUsingThreadWithOutputStream { // Fragmentos


// ...
public void service() throws IOException {
OutputStream output = new FileOutputStream("..."); Executável r = new
Executável() {
public void run() { try
{ doService(); } catch (IOException e) { ... } } }; novo

ThreadWithOutputStream(r, output).start(); }

void doService() lança IOException {


ThreadWithOutputStream.current().getOutput().write(...); // ... } }

2.3.2.2 ThreadLocal

A classe de utilitário java.lang.ThreadLocal remove um obstáculo ao uso de técnicas específicas de


encadeamento, sua dependência de subclasses especiais de Thread . Essa classe permite que variáveis específicas
de encadeamento sejam adicionadas de maneira ad hoc a praticamente qualquer código.

A classe ThreadLocal mantém internamente uma tabela associando dados ( referências de Object) com instâncias
de Thread . ThreadLocal oferece suporte aos métodos set e get para acessar os dados mantidos pelo Thread
atual. A classe java.lang.InheritableThreadLocal estende ThreadLocal para propagar automaticamente
variáveis por encadeamento para quaisquer encadeamentos que, por sua vez, são criados pelo encadeamento
atual.
Machine Translated by Google

A maioria dos projetos que empregam ThreadLocal podem ser vistos como extensões do padrão Singleton (consulte
§ 2.2.4) . Em vez de construir uma instância de um recurso por programa, a maioria dos aplicativos de
ThreadLocals constrói uma instância por thread. As variáveis ThreadLocal são normalmente declaradas como
estáticas e geralmente têm visibilidade no escopo do pacote para que possam ser acessadas por qualquer um de um
conjunto de métodos em execução em um determinado thread.

Um ThreadLocal pode ser usado em nosso exemplo em execução da seguinte maneira:

classe ServiçoUsingThreadLocal { // Fragmentos


saída ThreadLocal estática = new ThreadLocal();

public void service() { try { final

OutputStream s = new FileOutputStream("..."); Runnable r = new Runnable() { public


void run() { output.set(s); tente { doService(); }
catch (IOException e) { ... }
finalmente { tente
{ s.close(); } captura
(IOException ignora) {}

}
}
};
novo Thread(r).start();

} catch (IOException e) { ...} }

void doService() lança IOException {


((OutputStream)(output.get())).write(...); // ...

}
}

2.3.2.3 Aplicações e consequências

As subclasses ThreadLocals e Thread contendo campos específicos de thread são normalmente usadas somente
quando não há outra boa opção disponível. As vantagens e desvantagens em comparação com outras
abordagens, como projetos baseados em sessão, incluem:

Hospedar referências de objetos em (ou associados a) objetos Thread permite que os métodos em execução
no mesmo thread os compartilhem livremente sem a necessidade de passá-los explicitamente como parâmetros.
Esta pode ser uma boa opção para manter informações contextuais como o
AccessControlContext do encadeamento atual (como é feito nos pacotes java.security ) ou o diretório de
trabalho atual a ser usado para abrir um conjunto de arquivos relacionados.
ThreadLocal também pode ser útil para construir pools de recursos por thread (consulte § 3.4.1.2).
Machine Translated by Google

O uso de variáveis específicas de encadeamento tende a ocultar parâmetros que influenciam o comportamento e podem
dificultar a verificação de erros ou vazamentos. Nesse sentido, as variáveis específicas do encadeamento apresentam os
mesmos problemas de rastreabilidade, embora menos extremos, que as variáveis globais estáticas.
É simples garantir que uma mudança de status em uma variável específica de thread (por exemplo, fechar um
arquivo de saída e abrir outro) afete todo o código relevante. Por outro lado, pode ser difícil garantir que todas essas
mudanças sejam devidamente coordenadas.
Nenhuma sincronização é necessária para leituras ou gravações em campos específicos do thread de dentro do thread. No
entanto, os caminhos de acesso via currentThread ou tabelas ThreadLocal internas provavelmente não serão mais baratos
do que chamadas de método sincronizadas não contidas. Portanto, o uso de técnicas específicas de encadeamento
geralmente melhora o desempenho apenas quando os objetos precisariam ser compartilhados e fortemente
disputados entre os encadeamentos.
O uso de variáveis específicas de encadeamento pode diminuir a capacidade de reutilização aumentando as dependências de código.
Este é um problema mais sério sob a abordagem da subclasse Thread . Por exemplo, o método doService é inutilizável,
a menos que seja executado em um ThreadWithOutputStream.

Qualquer tentativa de uso fora desse contexto resultará em uma ClassCastException ao invocar o método current.

Às vezes , adicionar informações de contexto via ThreadLocal é a única maneira de permitir que os componentes
funcionem com o código existente que não propaga as informações necessárias ao longo das sequências de chamada
(consulte § 3.6.2).
É difícil associar dados com contextos de execução ao usar estruturas executáveis leves que são baseadas apenas
indiretamente na classe Thread, em particular, pools de threads de trabalho (consulte § 4.1.4).

2.3.3 Confinamento Dentro de Objetos

Mesmo quando você não pode limitar o acesso a um objeto dentro de um determinado método ou thread e, portanto, deve usar o
bloqueio dinâmico, você pode limitar todos os acessos internos a esse objeto para que nenhum bloqueio adicional seja necessário
quando um thread entrar em um de seus métodos. Desta forma, o controle de exclusão para o objeto contêiner Host externo se
propaga automaticamente para suas Partes internas. Para que isso funcione, as referências às Partes não devem vazar. (Consulte o §
2.4.5 para estratégias que podem ser aplicadas quando o vazamento de contêineres não pode ser evitado.)

O confinamento de objetos é visto em programas OO de todos os tipos. O principal requisito adicionado em contextos simultâneos
é garantir a sincronização em todos os pontos de entrada no objeto Host. Isso emprega as mesmas técnicas usadas na construção
de objetos totalmente sincronizados (§ 2.2.2) contendo instâncias de tipos escalares primitivos, como double. Mas aqui eles são
aplicados a classes que contêm referências a outros objetos.
Machine Translated by Google

Em projetos baseados em confinamento, o objeto Host pode ser pensado como possuindo as Partes internas.
Por outro lado, os objetos Parte podem ser pensados como sendo "fisicamente" contidos em seu Host:

O objeto Host constrói novas instâncias de cada objeto Part em sua própria construção, atribuindo
referências a campos não públicos. Uma nova construção garante que as referências aos objetos Part não
sejam compartilhadas por nenhum outro objeto. Alternativamente, o construtor pode atuar como um ponto
de transferência.
Como em qualquer técnica de confinamento, o objeto Host nunca deve vazar referências a nenhum objeto
Part: ele nunca deve passar as referências como argumentos ou valores de retorno de qualquer método e
deve garantir que os campos que contêm as referências sejam inacessíveis. Além disso, os objetos Part
não devem vazar suas próprias identidades, por exemplo, enviando isso como um argumento de
retorno de chamada (consulte § 4.3.1) para um método externo. Isso garante que os objetos Part sejam
acessíveis externamente apenas por meio de métodos no objeto Host.
Na variante mais conservadora, contenção fixa, o objeto Host nunca reatribui campos de referência que apontem
para os objetos Parte internos. Isso evita a necessidade de sincronização em torno das atualizações de campo
no objeto Host. A contenção fixa implementa o sentido principal de agregação discutido no livro
Design Patterns e denotado por símbolos de diamante UML.
A menos que o objeto Host esteja, por sua vez, confinado dentro de outro, todos os métodos apropriados do
objeto host são sincronizados. (Consulte o § 2.3.3.2 para obter uma abordagem para definir as versões
sincronizadas e não sincronizadas das classes.) Isso garante que todos os acessos às Partes (e todos os
objetos recursivamente construídos dentro delas) mantenham a exclusão. Observe que as Partes
mantidas recursivamente em um único domínio de confinamento podem invocar métodos umas nas outras
sem empregar sincronização; apenas os acessos externos requerem sincronização.

Apesar das restrições exigentes (e às vezes difíceis de verificar) que devem cumprir, as técnicas de confinamento de
objetos são muito comuns, em parte devido à sua utilidade na construção de adaptadores e outros projetos baseados
em delegação.

2.3.3.1 Adaptadores

Os adaptadores (consulte o § 1.4.2) podem ser usados para envolver objetos de solo não sincronizados em
objetos hospedeiros totalmente sincronizados. Isso leva aos designs de estilo de delegação mais simples possíveis:
aqueles em que os Adaptadores apenas encaminham todas as mensagens para seus delegados. Adaptadores
sincronizados podem ser usados para incluir código "legado" originalmente escrito para configurações sequenciais,
bem como código carregado dinamicamente que você não confia para ser seguro em contextos multithread.

Um adaptador também pode fornecer um único ponto de entrada seguro em um conjunto de funcionalidades
altamente otimizado (talvez até mesmo em código nativo ) e computacionalmente intensivo que, por uma questão
de eficiência, não executa nenhum controle interno de simultaneidade. Observe, no entanto, que nenhuma quantidade
de encapsulamento pode lidar com o código nativo que acessa internamente os campos de maneira insegura em diferentes threads.

Dada uma ou mais classes de terra desprotegidas, você pode definir uma classe de adaptador sincronizada com um
campo, digamos delegado, mantendo uma referência a um objeto de terra, para o qual encaminha solicitações e
retransmite respostas. (Observe que se algum método de solo contiver uma resposta do formulário return this, deve ser
traduzido como return this no Adapter.) As referências de delegado não precisam ser finais, mas se forem atribuíveis, deve-
se tomar cuidado para que o Adapter obtenha exclusividade acesso. Por exemplo, um adaptador pode ocasionalmente
atribuir a referência a um novo delegado construído internamente.

Conforme mencionado na § 1.4, quando é importante garantir que os adaptadores sejam tratados como idênticos aos
seus objetos terrestres mantidos internamente, você pode substituir os métodos equals e hashCode adequadamente.
Machine Translated by Google

No entanto, não há razão para fazer isso em projetos baseados em confinamento, pois os objetos internos nunca
vazam e, portanto, nunca serão comparados.

Como um aplicativo simples, os adaptadores sincronizados podem ser usados para colocar métodos de
acesso e atualização sincronizados em torno de uma classe contendo variáveis de instância públicas , como
uma classe de ponto aberta:

classe BarePoint { public


double x; público
duplo y; }

classe SynchedPoint {

delegado BarePoint final protegido = new BarePoint();

público sincronizado duplo getX() { return delegado.x;} público sincronizado


duplo getY() { retornar delegado.y; } público sincronizado void setX(duplo v)
{ delegado.x = v; } público void sincronizado setY(duplo v) { delegado.y = v; } }

A estrutura java.util.Collection usa um esquema baseado em adaptador para permitir a sincronização em


camadas de classes de coleção. Exceto para Vector e Hashtable, as classes de coleção básicas (como
java.util.ArrayList) não são sincronizadas. No entanto, classes anônimas sincronizadas do Adapter
podem ser construídas em torno das classes básicas usando, por exemplo:

List l = Collections.synchronizedList(new ArrayList());

2.3.3.2 Subclasse

Quando as instâncias de uma determinada classe sempre devem ser confinadas dentro de outras, não há motivo
para sincronizar seus métodos. Mas quando algumas instâncias são confinadas e outras não, a prática mais segura
é sincronizá-las adequadamente, mesmo que o bloqueio não seja necessário em todos os contextos de uso.
(Consulte, no entanto, § 2.4.5 e § 3.3.4 para situações em que outras táticas podem ser aplicadas.)
Machine Translated by Google

À medida que compiladores, ferramentas e sistemas de tempo de execução continuam a melhorar, eles são
cada vez mais capazes de otimizar ou minimizar a sobrecarga do bloqueio supérfluo. No entanto, quando
necessário ou desejável, você pode organizar isso manualmente definindo várias versões de uma
classe e instanciando a versão apropriada para um determinado contexto de uso. Entre as opções mais simples
está a criação de subclasses (consulte § 1.4.3): criar uma classe base desprotegida e, em seguida, sobrescrever
cada método m como um método sincronizado chamando super.m. Por exemplo:

endereço de classe // Fragmentos


{ rua String protegida; cidade de
String protegida;

public String getStreet() { return street; } public void


setRua(String s) { rua = s; } // ... public void printLabel(OutputStream
s) { ... } }

class SynchronizedAddress extends Address {


// ...
público sincronizado String getStreet() { return
super.getStreet(); } public

sincronizado void setStreet(String s) {


super.setRua(s); }

public sincronizado void printLabel(OutputStream s) {


Machine Translated by Google

super.printLabel(s); } }

2.3.4 Confinamento em Grupos

Grupos de objetos acessíveis em vários encadeamentos podem juntos garantir que apenas um deles por
vez possa acessar um determinado objeto de recurso. Aqui, cada recurso é sempre propriedade de apenas um
objeto, mas a propriedade pode mudar de mãos ao longo do tempo. Os protocolos para manter a propriedade
exclusiva são semelhantes aos de transferência de referências entre invocações de método discutidas em
§ 2.3.1, mas requerem mais estrutura para gerenciar a conformidade entre grupos de objetos e threads.

Recursos mantidos exclusivamente são análogos de objetos físicos no sentido de que:

Se você tiver um, poderá fazer algo (com ele) que não poderia fazer de outra forma.
Se você tem, então ninguém mais tem.
Se você dá para outra pessoa, então você não tem mais.
Se você destruí-lo, ninguém jamais o terá.

Qualquer tipo de objeto pode ser visto como um recurso se for usado dessa maneira. Uma forma mais
concreta de caracterizar essa política é que no máximo um campo de um objeto se refere a qualquer recurso
exclusivo em um determinado momento. Esse fato pode ser explorado para garantir o confinamento dentro de
qualquer atividade, reduzindo assim a necessidade de sincronização dinâmica em objetos de recursos.

Em alguns contextos e sentidos, os protocolos que envolvem recursos exclusivos foram denominados
tokens, bastões, objetos lineares, capacidades e, às vezes, apenas recursos. Vários algoritmos simultâneos e
distribuídos dependem da ideia de que apenas um objeto por vez possui um token. Para um exemplo baseado
em hardware, as redes token-ring mantêm um único token que circula continuamente entre os nós.
Cada nó pode enviar mensagens somente quando tiver o token.

Embora a maioria dos protocolos de transferência seja muito simples, a implementação pode ser propensa a
erros: os campos que contêm referências a objetos simplesmente não agem como objetos físicos
quando se trata da noção de posse. Por exemplo, instruções na forma xr = ys não fazem com que o
proprietário y que contém o campo s perca a posse após a conclusão da operação. Em vez disso, a atribuição
resulta em r e s ainda sendo vinculados. (Esse estado de coisas é análogo aos problemas da vida real
ao lidar com direitos de propriedade intelectual e outras formas de permissão que não envolvem
intrinsecamente operações de transferência física.) Esse problema levou a uma vasta gama de
soluções, desde convenções informais até pesado aparato jurídico.

Para melhorar a confiabilidade, você pode encapsular protocolos em métodos que executam as seguintes
operações, para objetos Recurso distintos r e s e objetos Proprietário x e y que podem mantê-los no campo
ref. Para dar ênfase, os bloqueios necessários são mostrados usando blocos sincronizados :

Adquirir. O proprietário x estabelece a posse inicial de r. Isso geralmente é o resultado da construção ou


inicialização de r e configuração:

sincronizado(este) { ref = r; }

Esquecer. Proprietário x faz com que o Recurso r não seja possuído por nenhum Proprietário. Isso
geralmente é realizado pelo Proprietário atual , realizando:
Machine Translated by Google

sincronizado(este) { ref = null; }

Colocar (dar). O Proprietário y envia ao Proprietário x uma mensagem contendo uma referência ao
Recurso r como um argumento, após o qual y não tem mais posse de r, mas x tem.

x y
void put(Recurso s) void anAction(Proprietário x) { //...
{ sincronizado(este) { ref = s; } } Recursos;
sincronizado(este) { s = ref; ref
= nulo; }
x.put(s); }

Pegar. O Proprietário y solicita um Recurso do Proprietário x, que então envia r como valor de
retorno, cedendo a posse.

x y
Tomada de recurso() { void anAction(Owner x) { // Recurso r =
sincronizado(este) { x.take(); sincronizado(este) { ref =
Recurso r = ref; ref = nulo; r; } }
retorno r; } }

Intercâmbio. O Proprietário y troca seus Recursos s pelo Recurso r do Proprietário x . Esta operação
também pode ser usada para realizar um take via s = exchange(null), ou um put via exchange(r) , ignorando
o resultado.

x y
Troca de recursos(Recursos){ void anAction(Owner x) { //
sincronizado(este) { sincronizado(este) {
Recurso r = ref; ref = s; ref = x.exchange(ref); }
retorno r;
}

}}

Uma aplicação de tais protocolos surge quando um objeto, digamos um OutputStream, está quase
completamente confinado em seu objeto host, mas deve ser usado ocasionalmente por outros clientes.
Nesses casos, você pode permitir que os clientes peguem o objeto interno, operem nele e depois o
coloquem de volta. Nesse ínterim, o objeto host ficará temporariamente incapacitado, mas pelo menos
você tem certeza de não encontrar violações de integridade.

2.3.4.1 Anéis
Machine Translated by Google

No caso geral, o gerenciamento de recursos pode envolver a manutenção de pools (ver § 3.4.1), usar
redes de troca de mensagens que adotem determinadas políticas de troca (ver § 3.4.3) ou fluxo (ver § 4.2) , ou
adotar protocolos que ajudem a evitar impasse e esgotamento de recursos (ver § 4.5.1). Mas protocolos de
transferência mais simples podem ser usados quando você só precisa garantir que um grupo
interconectado de objetos cooperantes confina estritamente um recurso. Uma maneira de implementar
isso é organizar um conjunto de objetos pares em um anel no qual cada nó se comunica apenas com um único vizinho.

Como um exemplo irrealisticamente simplificado, considere um conjunto de objetos PrintService


organizados como nós em um anel, transmitindo direitos para usar uma impressora. Se um nó for
solicitado a imprimir, mas não tiver acesso no momento, ele tentará obtê-lo de seu vizinho. Essa solicitação
é enviada em cascata para um nó que possui uma impressora. Definir os métodos relevantes como
sincronizados garante que os nós não desistam da impressora até que tenham terminado com ela. Aqui está um instantâneo de uma co

Esse design produz os efeitos desejados apenas se todos os nós obedecerem ao protocolo de transferência,
as conexões forem configuradas adequadamente e pelo menos um nó tiver uma impressora. Um exemplo de
método de inicialização mostra uma maneira de estabelecer a estrutura necessária. Muitas extensões
adicionais seriam necessárias para permitir conexões dinâmicas de novos objetos PrintService , para
suportar mais de uma impressora e para lidar com situações em que nenhuma impressora está disponível.

class Impressora
{ public void printDocument(byte[] doc) { /* ... */ } // ... }

class Serviço de impressão {

vizinho PrintService protegido = nulo; // nó a ser obtido da impressora protegida printer


= null;

public void print(byte[] doc) { getPrinter().printDocument(doc); }

Impressora protegida getPrinter() { // PRE: bloqueio de sincronização


mantido if (printer == null) // precisa pegar do vizinho
impressora = vizinho.takePrinter();
Machine Translated by Google

impressora de
retorno; }

sincronizado Printer takePrinter() { // chamado de outros if (printer != null) {

Impressora p = impressora; // implementa o protocolo take printer = null;


retornar p;

}
senão return vizinho.takePrinter(); // propagar
}

// métodos de inicialização chamados apenas durante a inicialização

void sincronizado setNeighbor(PrintService n) {


vizinho = n; }

sincronizado void darImpressora(Impressora p) { impressora =


p; }

// Exemplo de código para inicializar um anel de novos serviços

public static void startUpServices(int nServices, Printer p)


lança IllegalArgumentException {

if (nServiços <= 0 || p == nulo)


lançar novo IllegalArgumentException();

PrintService primeiro = new PrintService();


PrintService pred = primeiro;

for (int i = 1; i < nServiços; ++i) { ServiçoImpressão s =


new ServiçoImpressão(); s.setNeighbor(pred); pred =
s; }

primeiro.setNeighbor(pred);
first.givePrinter(p); } }

2.3.5 Leituras Adicionais


A linguagem de programação Hermes foi pioneira em várias construções de linguagem e técnicas
para estruturar programas simultâneos e distribuídos, incluindo transferência de referência como um primitivo. Ver:
Machine Translated by Google

Strom, Robert, David Bacon, Arthur Goldberg, Andy Lowry, Daniel Yellin e Shaula Yemini.
Hermes: A Language for Distributed Computing, Prentice Hall, 1991.

A linguagem de definição de interface do sistema operacional Spring incorporou políticas de transferência como
qualificadores de argumento para métodos. Ver:

Uma coleção de primavera, SunSoft Press, 1994.

Técnicas baseadas em referências únicas também desempenharam papéis em outros métodos de projeto e análise
OO. Veja, por exemplo:

Hogg, John, Doug Lea, RC Holt, Alan Wills e Dennis de Champeaux. "A Convenção de Genebra sobre o Tratamento de
Aliasing de Objetos", OOPS Messenger, abril de 1992.

Para uma abordagem formal de confinamento em sistemas distribuídos, consulte:

Cardelli, Luca e Andrew Gordon. "Mobile Ambients", em Maurice Nivat (ed.), Foundations of Software Science and
Computational Structures, Springer LNCS 1378, 1998.

2.4 Estruturação e Refatoração de Classes


Pode ser difícil equilibrar as forças de design em torno do controle de acesso exclusivo durante o design inicial da classe.
A maioria das classes usadas em programas simultâneos passa por refatorações iterativas para abordar questões como:

Usar apenas alguns bloqueios de ponto de entrada, como visto na maioria dos projetos baseados em
confinamento, tende a funcionar bem quando há poucos encadeamentos, devido à sobrecarga reduzida.
Mas o desempenho pode degradar rapidamente sob contenção, especialmente em multiprocessadores. Quando
muitos threads disputam o mesmo bloqueio de ponto de entrada, a maioria dos threads passa a maior parte
do tempo esperando pelo bloqueio, aumentando as latências e limitando as oportunidades de paralelismo. A
maioria dos sistemas evolui para usar bloqueios de granularidade mais fina à medida que crescem. Os casos
mais conhecidos são os sistemas operacionais que antes usavam um único bloqueio gigante como ponto de
entrada para um kernel, mas cada vez mais usam bloqueios de escopo limitado e de curta duração, em parte para melhor suporte ao multipro
O uso de muitos bloqueios pode adicionar sobrecarga e aumentar as chances de falhas inesperadas de
ativação.

Usar um bloqueio para proteger mais de um aspecto da funcionalidade pode resultar em contenção
desnecessária.
Manter bloqueios por longos períodos convida a problemas de vivacidade e desempenho e complica o
processamento de exceções.
Bloquear métodos individuais nem sempre mantém a semântica pretendida. Por exemplo, quando dois atributos
relacionados são obtidos chamando dois acessadores bloqueados diferentes, os valores obtidos podem
não obedecer aos relacionamentos pretendidos se ocorrer uma transição de estado entre as chamadas.

Não existe uma única estratégia ótima. No entanto, várias técnicas e padrões podem ser utilizados para proporcionar um
melhor equilíbrio entre essas forças. Esta seção descreve estratégias para remover sincronização desnecessária,
dividir a sincronização para corresponder à funcionalidade, exportar operações somente leitura por meio de adaptadores,
isolar representações de estado para reduzir os custos de acesso ou melhorar o paralelismo potencial e agrupar objetos
para usar bloqueios comuns para espelhar designs em camadas. Embora qualquer um deles possa ser usado durante
o projeto inicial de classes, vários deles dependem de manipulações técnicas que são difíceis (e às vezes
imprudentes) de explorar durante os primeiros esforços de projeto.
Machine Translated by Google

2.4.1 Reduzindo a Sincronização

Quando o bloqueio apresenta problemas de vivacidade ou desempenho para uma determinada classe ou programa,
geralmente a melhor solução é refatorar o projeto para usar uma ou mais das outras abordagens apresentadas neste
capítulo. No entanto, há casos em que a lógica básica de um projeto baseado em sincronização pode ser mantida mesmo
quando alguns dos qualificadores ou blocos do método sincronizado são removidos, embora às vezes às custas de garantias
semânticas enfraquecidas.

2.4.1.1 Acessadores

A sincronização de um método acessador de campo às vezes (mas nem sempre) adiciona uma sobrecarga perceptível aos
programas. Duas considerações entram em qualquer decisão sobre se a sincronização de um método acessador pode ser
removida:

Legalidade. O valor do campo subjacente nunca assume um valor ilegal; ou seja, você pode garantir que o campo nunca, mesmo
momentaneamente, interrompa invariantes. (Isso, por definição, exclui os campos do tipo long e double.)

Estagnação. Os clientes não precisam necessariamente do valor atualizado mais recentemente de um campo, mas podem
conviver com valores possivelmente obsoletos (consulte § 2.2.7).

Se um campo nem sempre é válido, as opções são:

Sincronize o acessador (assim como todos os métodos de atualização).


Certifique-se de alguma forma que os clientes percebam quando obtiveram um valor ilegal e tomem medidas
evasivas (por exemplo, por meio de verificações duplas, consulte § 2.4.1.2).
Omita o método do acessador. Isso se aplica surpreendentemente com frequência. Pergunte a si mesmo por que
qualquer cliente gostaria de saber o valor de um campo e o que ele poderia fazer com esse valor. Como os
atributos do objeto podem mudar de forma assíncrona em programas simultâneos, um valor obtido por um cliente
em uma linha de código pode ter mudado antes que a próxima linha de código seja executada. Assim, os métodos
acessadores não são frequentemente úteis em programas concorrentes. Além disso, como eles não são úteis com
frequência, é improvável que a sincronização seja um problema de desempenho, mesmo que você não remova os
métodos de acesso nesses casos.

Se um campo for sempre legal, mas desatualizado não for aceitável, você terá a opção adicional:

Remova a sincronização do acessador e qualifique a variável de instância como volátil.


No entanto, isso funciona como esperado apenas para tipos escalares, não referências a arrays ou objetos que
ajudam a manter representações de objetos: acessar uma referência volátil não garante automaticamente a
visibilidade dos campos ou elementos acessíveis a partir dessa referência. (No caso de referências a objetos , você
pode, se necessário, garantir que os campos acessados sejam voláteis.) A principal desvantagem dessa abordagem
é que declarações voláteis impedem otimizações do compilador de métodos usando esses campos e, portanto,
podem levar a um desempenho líquido perda.

Se um campo for sempre legal e desatualizado for aceitável, você também terá as opções:

Remova a sincronização do acessador sem nenhuma alteração adicional.


Se você gosta de viver perigosamente, apenas torne o campo público.
Machine Translated by Google

Como exemplo, considere o método size() da classe ExpandableArray (§ 2.2.2), que retorna o valor do campo
size . A inspeção de todos os métodos revela que o valor do campo size é sempre legal, nunca assume um valor
fora do intervalo 0..data.length. (Isto não seria verdade se, por exemplo, o tamanho fosse temporariamente
definido como -1 como um sinalizador indicador de redimensionamento dentro do método add .) Supondo
que essa restrição seja documentada como um requisito interno para todas as subclasses e modificações futuras,
a sincronização pode ser removido do acessador.

A decisão sobre staleness e necessidade de volátil é uma questão de julgamento sobre possíveis contextos de
uso que aqui interagem com (entre outras questões) a escolha de estratégias de passagem. Se os clientes
percorrem principalmente elementos usando loops indexados:

for (int i = 0; i < v.size(); ++i) // questionável


System.out.println(v.get(i));

então a obtenção de valores obsoletos de tamanho provavelmente não será aceitável. Por exemplo, um cliente
pode obter o valor zero mesmo quando há muitos elementos na matriz, ignorando totalmente o loop. Observe
que, se o loop for executado, a sincronização executada na primeira invocação do método get forçará um valor
mais recente de size a ser retornado na segunda e subsequentes chamadas para size(). (Lembre-se também
do § 2.2.3 que os clientes devem, em qualquer caso, estar preparados para a mudança de tamanho entre a
verificação do índice e o acesso ao elemento, portanto, esse estilo de travessia é problemático, na melhor das hipóteses.)

No entanto, se agregados ou iteradores forem usados, cada um executando sincronização interna, você poderá
argumentar para deixar o método size() não sincronizado e o campo size não volátil e anunciar o método
apenas como uma estimativa heurística do número atual de elementos.
Ainda assim, é improvável que isso seja aceitável para clientes de uma classe de uso
geral, como ExpandableArray. Mas um raciocínio semelhante pode ser invocado quando é aceitável que os
clientes obtenham valores que são garantidos como tão recentes quanto os últimos pontos de sincronização
dos threads de leitura e gravação.

2.4.1.2 Verificação dupla

Se os chamadores de acessadores de campo não sincronizados puderem de alguma forma perceber quando
acabaram de ler um valor ilegal, às vezes eles podem tomar medidas evasivas. Uma dessas ações é
acessar novamente o campo sob sincronização, determinar seu valor mais atual e, em seguida, tomar a ação
apropriada. Essa é a essência do idioma de verificação dupla.

Verificação dupla e suas variantes (incluindo versões de loop às vezes chamadas de teste e teste e configuração)
são vistas em latches (consulte § 3.4.2), bloqueios de rotação (consulte § 3.2.6) e protocolos de cache. Mas
a aplicação mais comum de verificação dupla é relaxar condicionalmente a sincronização em torno das verificações de inicialização.
Quando um valor não inicializado (para escalares, um valor zero padrão) é encontrado, o método de acesso
adquire um bloqueio, verifica novamente se a inicialização é realmente necessária (em vez de ler um valor
obsoleto) e, se for o caso, executa a inicialização enquanto ainda sob o bloqueio de sincronização, para
evitar várias instanciações. Por exemplo:

classe AnimationApplet estende Applet { // Fragmentos


// ... int
framesPerSecond; // zero padrão é um valor ilegal

void animar() { // ...


Machine Translated by Google

if (framesPerSecond == 0) { // a verificação não sincronizada


sincronizado(este) {
if (framesPerSecond == 0) { // a verificação dupla
String parâmetro = getParameter("fps");
framesPerSecond = Integer.parseInt(param);
}
}
}

// ... ações usando framesPerSecond ...

}}

Embora existam vários usos legítimos, a verificação dupla é extremamente delicada:

Geralmente não é aconselhável usar a verificação dupla para campos que contêm referências a objetos ou matrizes.
A visibilidade de uma referência lida sem sincronização não garante a visibilidade de campos não voláteis
acessíveis a partir da referência (ver § 2.2.7). Mesmo que essa referência não seja nula, os campos acessados por
meio da referência sem sincronização podem obter valores obsoletos.
É difícil, na melhor das hipóteses, usar um único campo sinalizador como um indicador de que todo um conjunto de
campos deve ser inicializado. As reordenações as-if-serial discutidas em § 2.2.7 podem fazer com que o sinalizador
seja visivelmente definido antes que os outros campos sejam visivelmente inicializados.

Os remédios para esses dois problemas quase sempre exigem algum tipo de bloqueio. Portanto, essas
considerações geralmente levam a evitar a inicialização preguiçosa com verificação dupla e a adoção de esquemas que
inicializam prontamente ou dependem de verificações totalmente sincronizadas (como visto, por exemplo, nas classes Singleton
em § 2.2.4) .

No entanto, em alguns outros casos, você pode usar uma técnica ainda mais fraca, verificação única. Aqui, a verificação,
inicialização e vinculação de campo são todas executadas sem sincronização e, portanto, dependem dos caprichos do acesso de
campo não sincronizado. Isso abre a possibilidade de múltiplas instanciações. Essa é uma opção plausível apenas
se a inicialização não tiver efeitos colaterais e não precisar envolver sincronização, e o uso de vários encadeamentos for raro.

2.4.1.3 Chamadas abertas

Conforme discutido em § 2.1, um método é sem estado se não acessar ou depender de nenhum campo de objeto mutável.
Métodos em objetos totalmente imutáveis são necessariamente sem estado, mas métodos sem estado também podem
ocorrer em outros tipos de classes, por exemplo, em métodos utilitários puramente computacionais e durante invocações de
método feitas a conhecidos (em oposição a objetos de suporte representacional, consulte § 1.3.1.2 ) .

Você não precisa sincronizar partes sem estado dos métodos. Isso permite que outras chamadas para
métodos sincronizados sejam executadas durante seções não sincronizadas, melhorando o desempenho e reduzindo a
interferência de bloqueio. No entanto, a sincronização pode ser dividida apenas quando as diferentes partes do método não são
de forma alguma dependentes, de modo que seja aceitável que outros métodos "vejam" e usem o objeto antes da conclusão
completa do método.

Para ilustrar, considere a seguinte classe de servidor genérico. Se helper.operation demorar muito, as chamadas para métodos
sincronizados , como getState , podem ser bloqueadas por um tempo inaceitável de espera para que o método esteja disponível.
Machine Translated by Google

class ServerWithStateUpdate { estado


duplo protegido; helper final
protegido helper = new Helper();

serviço nulo sincronizado público() {


estado = ...; // definido para algum novo valor
helper.operation(); }

public sincronizado double getState() { return state; } }

Se helper representa algum aspecto do estado do host, ou a chamada para helper.operation depende ou modifica o estado do host,

todo o método de serviço deve empregar sincronização. No entanto, se helper.operation for independente do host, o método de serviço

pode ser estruturado na forma sugerida pelas regras padrão em § 1.1.1.1:

Primeiro, atualize o estado (mantendo bloqueios).


Em seguida, envie mensagens (sem realizar bloqueios).

As mensagens enviadas sem bloqueios de espera também são conhecidas como chamadas abertas. Conforme discutido em § 4.1 e §
4.2, as classes com métodos desta forma estão entre os componentes mais bem comportados e prontamente combináveis
em sistemas concorrentes e baseados em eventos. Por exemplo, supondo que helper.operation atenda aos
nossos critérios, a classe acima pode ser reescrita como:

classe ServerWithOpenCall { estado


duplo protegido; helper final
protegido helper = new Helper();

protegido sincronizado void updateState() { estado = ...; //


definido para algum novo valor }

public void service()


{ updateState();
helper.operação(); } }

Ainda é possível usar chamadas abertas aqui, mesmo que o campo de referência do auxiliar seja mutável; por exemplo, usando a
sincronização de bloco:

classe ServidorComAssignableHelper {
duplo estado protegido; helper
protegido helper = new Helper(); void sincronizado
setHelper(Helper h) { helper = h; }

serviço public void() {


Ajudante h;
Machine Translated by Google

sincronizado(este) { estado = h =
auxiliar; } ...
h.operação(); }

public sincronizado void syncedService() { // veja abaixo


serviço(); }

O método syncedService aqui revela uma fraqueza em qualquer técnica envolvendo chamadas abertas.
A chamada para o serviço de dentro do syncedService resulta na retenção do bloqueio durante toda a duração
do serviço, incluindo a chamada para h.operation. Isso anula o propósito das reestruturações do método. Evitar
tais problemas requer a documentação da falta intencional de sincronização em classes usadas em
configurações simultâneas.

As estruturas de dados vinculadas por meio de referências imutáveis geralmente são passíveis de esses
tipos de manipulações. Por exemplo, considere uma classe LinkedCell na qual cada célula contém uma referência
a uma célula sucessora e para a qual exigimos que as referências de células sucessoras sejam corrigidas na construção.
Esse é um requisito comum para células que servem como listas no estilo Lisp. Métodos e seções de métodos
envolvendo apenas o sucessor não precisam ser sincronizados, o que acelera a travessia. Para maior clareza e
ênfase, os métodos aqui usam recursão; na prática, você provavelmente usaria iteração:

classe LinkedCell {
valor int protegido; LinkedCell
final protegido em seguida;

public LinkedCell(int v, LinkedCell t) { value = v;

próximo = t;
}

public sincronizado int valor() { valor de retorno; } public sincronizado void


setValue(int v) { valor = v; }

public int soma() { // soma todos os valores dos elementos


return (próximo == nulo) ? value() : value() + next.sum(); }

public boolean includes(int x) { // procura por x


return (valor() == x) ? verdadeiro:
(próximo == nulo)? false : next.includes(x);

}}

Observe novamente que um objeto permanece bloqueado quando um método sincronizado chama um método não
sincronizado. Portanto, não evitaria a sincronização para escrever a soma como:
Machine Translated by Google

sincronizado int ineficazUnsyncedSum() { // má ideia //


valor de retorno + nextSum(); } int sincronização ainda em espera

nextSum() { return (next == null)? 0: próximo.soma(); }

2.4.2 Dividindo a Sincronização

Quando as representações e o comportamento de uma classe podem ser particionados em


subconjuntos independentes, não interativos ou apenas não conflitantes, quase sempre vale a pena
refatorar a classe para usar objetos auxiliares distintos de granularidade mais fina cujas ações são delegadas pelo host.

Essa regra prática se aplica ao design orientado a objetos em geral. Mas carrega muito mais força na
programação OO concorrente. Um conjunto de operações sincronizadas pode travar ou apresentar outros
problemas de atividade ou desempenho baseados em bloqueio se todos estiverem esperando pelo único
bloqueio de sincronização associado a um único objeto. Mas eles podem ser livres de deadlock e/ou
executados com mais eficiência se estiverem aguardando vários bloqueios distintos. Como regra geral,
quanto mais finamente você puder subdividir a sincronização interna de uma determinada classe, melhores
serão suas propriedades de vivacidade em uma gama mais ampla de contextos. No entanto, isso às vezes vem à custa de maior comple
erro.

2.4.2.1 Divisão de turmas

Considere uma classe Shape simplificada que mantém as informações de localização e dimensão, juntamente
com os métodos demorados AdjustLocation e AdjustDimensions que os alteram
independentemente:

classe Forma // Incompleto


{ double protegido x = 0.0; duplo
protegido y = 0,0; largura dupla
protegida = 0,0; altura dupla protegida = 0,0;

público sincronizado duplo x() público { retorna x;}


sincronizado duplo y() público sincronizado { retorna y; }
duplo largura() { largura de retorno;} altura dupla sincronizada pública () { altura
de retorno; }

public sincronizado void AdjustLocation() {


x = longCálculo1(); y =
longCalculation2(); }

público sincronizado void ajusteDimensões() {


largura = longCálculo3(); altura =
longCálculo4(); }

// ... }
Machine Translated by Google

Sob as suposições de que AdjustLocation nunca lida com informações de dimensão e AdjustDimensions nunca
lida com localização, um melhor desempenho pode ser obtido revisando essa classe para que os chamadores de
AdjustLocation não precisem esperar por aqueles que chamam AdjustDimensions e vice-versa.

Dividir classes para reduzir a granularidade é um exercício direto na refatoração de classes:

Particionar alguma funcionalidade de uma classe Host em outra classe, digamos Helper.
Na classe Host , declare um campo exclusivo final referenciando um auxiliar que é inicializado para um novo
Auxiliar no construtor. (Em outras palavras, confine estritamente cada ajudante em seu hospedeiro.)
Na classe Host , encaminhe todos os métodos apropriados para o Helper como chamadas abertas, usando
métodos não sincronizados. Isso funciona porque os métodos não têm estado em relação à classe Host .

O resultado mais extremo dessas etapas é um design de host de passagem no qual todas as mensagens são
retransmitidas como chamadas abertas por meio de métodos não sincronizados simples:

Por exemplo, aqui está uma versão de passagem da classe Shape :

classe PassThroughShape {

loc AdjustableLoc final protegido = new AdjustableLoc(0, 0); final protegido AdjustableDim
dim = new AdjustableDim(0, 0);

público duplo x() público { return loc.x(); } { return


duplo y() loc.y(); }

largura dupla pública () altura { return dim.width(); } { return


dupla pública () dim.height(); }

public void AdjustLocation() { loc.adjust(); } public void


AdjustDimensions() { dim.adjust(); } } classe AdjustableLoc { protegido

duplo x; duplo protegido;

public AdjustableLoc(double initX, double initY) {


x = initX; y =
initY; }
Machine Translated by Google

public sincronizado duplo x() { return x;} public sincronizado


duplo y() { return y; }

público sincronizado void ajuste() { x =


longCalculation1(); y =
longCalculation2(); }

protected double longCalculation1() { /* ... */ } protected double


longCalculation2() { /* ... */ }

classe AjustávelDim {
largura dupla protegida; altura
dupla protegida;

public AdjustableDim(double initW, double initH) {


largura = initW;
altura = initH; }

largura dupla sincronizada pública() { largura de retorno;} altura dupla


sincronizada pública() { altura de retorno; }

público sincronizado void ajuste() {


largura = longCálculo3(); altura =
longCálculo4(); }

protegido double longCalculation3() {/* ... */} protegido double


longCalculation4() {/* ... */}

2.4.2.2 Bloqueios de divisão

Mesmo se você não quiser ou não puder dividir uma turma, ainda poderá dividir os bloqueios de
sincronização associados a cada subconjunto de funcionalidade. Essa técnica é equivalente àquela em que
você primeiro divide uma classe em auxiliares e, em seguida, dobra todas as representações e métodos
dos auxiliares, exceto seus bloqueios de sincronização de volta à classe hospedeira. No entanto, não há
necessidade de proceder exatamente dessa maneira.

Despojado de tudo, exceto seu bloqueio de sincronização, qualquer classe é reduzida a apenas java.lang.Object.
Esse fato explica a prática idiomática de usar instâncias da classe Object como auxiliares de sincronização.

Para recuperar o design subjacente sempre que vir um objeto usado para um bloqueio de sincronização, você
pode se perguntar para que tipo de objeto auxiliar um determinado bloqueio é substituto. No caso de bloqueio
Machine Translated by Google

divisão, cada objeto controla o acesso a um subconjunto de métodos, de modo que cada método em cada subconjunto
é sincronizado em bloco em um objeto de bloqueio comum.

As etapas básicas para dividir bloqueios são semelhantes àquelas para dividir objetos:

Para cada subconjunto independente de funcionalidade, declare um objeto final , digamos lock, inicializado no
construtor da classe Host e nunca reatribuído:
o O objeto lock pode ser de qualquer subclasse da classe Object. Se não for usado para
qualquer outro propósito, pode muito bem ser da própria classe Object . o
Se um subconjunto estiver exclusivamente associado a algum objeto existente referenciado exclusivamente de
um campo, você pode usar esse objeto como o bloqueio.
o Um desses bloqueios pode ser associado ao próprio objeto Host (this) .
Declare todos os métodos correspondentes a cada subconjunto como não sincronizados, mas envolva todo o
código com synchronized(lock) { ... }.

Entre as aplicações da divisão de bloqueio estão as tabelas de hash de tamanho fixo, nas quais cada bin da tabela
possui seu próprio bloqueio. (Essa estratégia não pode ser facilmente aplicada a tabelas de hash redimensionáveis
dinamicamente, como as usadas em java.util.Hashtable, pois elas não podem contar com a imutabilidade dos objetos de
bloqueio.) A divisão de bloqueio também é vista em classes que gerenciam cuidadosamente as operações de espera e
notificação, conforme discutido no § 3.7.2. Para um exemplo mais simples, aqui está uma versão dividida da classe Shape :

classe LockSplitShape // Incompleto


{ protegido duplo x = 0,0; duplo
protegido y = 0,0; largura dupla
protegida = 0,0; altura dupla protegida =
0,0;

objeto final protegido locationLock = new Object(); protegido final Object


dimensionLock = new Object();

público duplo x() {


sincronizado(locationLock) { return x;

}
}

público duplo y() {


sincronizado(locationLock) { return y; }

public void AdjustLocation()


{ sincronizado(LocationLock) { x =
longCalculation1(); y =
longCalculation2(); }

}
Machine Translated by Google

// e assim por diante

2.4.2.3 Isolando campos

Algumas classes gerenciam conjuntos de propriedades e atributos independentes, cada um dos quais
pode ser manipulado isoladamente dos outros. Por exemplo, uma classe Person pode ter
campos de idade, renda e isMarried que podem ser alterados independentemente de quaisquer
outras ações executadas no objeto Person como um todo. A decisão sobre se isso é aceitável, é
claro, depende da semântica de uso pretendida de uma determinada classe.

Você não pode simplesmente declarar esses campos como voláteis se precisar de proteção de sincronização
para evitar conflitos entre tentativas simultâneas de atualizá-los. No entanto, você pode usar uma forma simples
de divisão para descarregar a proteção de sincronização para objetos usados exclusivamente para proteger operações básicas em tipos bási
Essas classes desempenham um papel semelhante às classes java.lang.Double e java.lang.Integer, exceto que, em
vez de prometer imutabilidade, elas prometem atomicidade. Por exemplo, você pode criar uma classe como:

classe SynchronizedInt { valor int


privado;

public SynchronizedInt(int v) { valor = v; }

público sincronizado int get() { valor de retorno; }

public sincronizado int set(int v) { // retorna o valor anterior int oldValue = value; valor
= v;
return valorantigo; }

público sincronizado int increment() { return ++valor; }

// e assim por diante

(O pacote util.concurrent disponível no suplemento online contém um conjunto dessas classes, uma para cada
tipo básico, que também oferece suporte a outras operações utilitárias, como o método commit descrito em § 2.4.4.2.)

Essas classes podem ser usadas, por exemplo, em:

classe Pessoa { // ... // Fragmentos

protegido final SynchronizedInt idade = new SynchronizedInt(0);


Machine Translated by Google

protegido final SynchronizedBoolean isMarried =


novo SynchronizedBoolean(falso);

receita SynchronizedDouble final protegida = new


SynchronizedDouble(0.0);

public int getIdade() { return idade.get(); }

public void aniversário() { age.increment(); }

// ... }

2.4.2.4 Estruturas de dados vinculadas

As técnicas de divisão de bloqueio podem minimizar a contenção de acesso a objetos que servem como pontos
de entrada em estruturas de dados vinculadas, encontrando um meio termo entre as estratégias extremas de
sincronizar totalmente as classes de entrada (que podem limitar a simultaneidade) e sincronizar totalmente
todos os objetos de nó vinculados sendo controlados (o que pode ser ineficiente e pode levar a problemas de vitalidade).

Como em todas as técnicas de divisão de bloqueio, o objetivo principal é associar diferentes bloqueios a
diferentes métodos. Mas, no caso de estruturas vinculadas, isso geralmente leva a ajustes adicionais nas
estruturas de dados e nos próprios algoritmos. Não há receitas universalmente aplicáveis para dividir a
sincronização em classes que controlam o acesso a estruturas vinculadas, mas a aula a seguir ilustra algumas
táticas comuns.

A classe LinkedQueue a seguir pode servir como uma fila FIFO (first-in-first-out) ilimitada genérica. Ele
mantém uma sincronização separada para put e poll. O bloqueio putLock garante que apenas uma operação put
por vez possa prosseguir. O bloqueio pollLock também garante que apenas uma operação de pesquisa por vez
possa prosseguir. Um nó de cabeçalho sempre existe nessa implementação, de modo que um put e um poll
possam normalmente prosseguir de forma independente. Após cada votação, o primeiro nó anterior torna-se
o novo chefe. Além disso, os próprios nós acessados devem ser bloqueados para evitar conflitos quando
um put e um poll estão sendo executados simultaneamente em uma fila que estava anteriormente vazia ou está
prestes a ficar vazia, caso em que head e last ambos se referem ao mesmo nó de cabeçalho.
Machine Translated by Google

class LinkedQueue {
cabeçalho do nó protegido = novo nó (nulo); Nó protegido
por último = cabeça;

objeto final protegido pollLock = new Object(); objeto final protegido


putLock = new Object();

public void put(Object x) { Node node =


new Node(x); sincronizado (putLock)
{ sincronizado (último) { last.next = // insere no final da lista
node; último = nó; } }
// estende a lista

public Object poll() { // retorna nulo se estiver vazio


sincronizado (pollLock) { sincronizado
(head) { Object x = null; Nó
primeiro = head.next; //
chega ao primeiro nó real if (first != null) { x = first.object; primeiro.objeto =
nulo; // esquece o objeto
antigo head = first; //
primeiro se torna um novo head

} retorna x; } }

}
Machine Translated by Google

Nó de classe estática { // classe de nó local para fila


Objeto objeto;
Nó próximo = nulo;

Nó(Objeto x) { objeto = x; } } }

O suplemento online inclui classes de filas que refinam, estendem e otimizam ainda mais esse projeto
básico.

2.4.3. Adaptadores somente leitura

Em projetos baseados em confinamento (consulte § 2.3.3), um objeto Host não pode revelar a identidade de
nenhum de seus objetos Parte. Isso elimina a opção de retornar referências a peças em qualquer acessor
ou método de inspeção de propriedade.

Uma alternativa é devolver uma cópia da peça. Por exemplo, a classe SynchedPoint (§ 2.3.3) poderia adicionar um
método:

público sincronizado BarePoint getPoint() {


return new BarePoint(delegate.x, delegate.y); }

Quando Parts são instâncias de classes conhecidas por implementar um método clone apropriado , você
pode, em vez disso, retornar part.clone(). E, quando precisar retornar conjuntos arbitrários de valores, você pode
usar um array ad-hoc, por exemplo:

público sincronizado double[] getXY() {


return new double[] { delegado.x, delegado.y } } ;

No entanto, a cópia pode ser muito cara quando se trata de alguns objetos e não faz sentido quando se trata
de outros; por exemplo, objetos que mantêm referências a arquivos, threads ou outros recursos que não
devem ser copiados. Em muitos casos, você pode permitir seletivamente algum vazamento construindo e
retornando um objeto Adapter em torno da parte que expõe apenas as operações que os clientes podem usar
sem introduzir qualquer interferência potencial geralmente, operações somente leitura. A menos que esses
métodos lidem apenas com o estado imutável, eles requerem sincronização.

A versão mais segura desse esquema dá um pouco de trabalho para configurar:

Defina uma interface base descrevendo alguma funcionalidade não mutativa.


Opcionalmente, defina uma subinterface que suporte métodos de atualização adicionais usados na
classe de implementação mutável normal.
Defina um adaptador somente leitura que encaminhe apenas as operações exportadas. Para maior
segurança, declare que a classe imutável é final. O uso de final significa que quando você pensa que
tem um objeto imutável, você realmente não é de alguma subclasse que suporta operações
mutáveis também.
Machine Translated by Google

Essas etapas podem ser aplicadas à classe Account simples a seguir . Mesmo que as
contas neste exemplo sejam mantidas apenas por AccountHolders, a
implementação UpdatableAccount mutável de uso geral emprega sincronização.

a classe InsufficientFunds estende a exceção {}

interface Conta { saldo longo


(); }

interface UpdatableAccount estende Conta {


crédito nulo (quantia longa) lança InsufficientFunds; débito nulo (quantia
longa) lança InsufficientFunds; }

// Exemplo de implementação da classe de versão atualizável


UpdatableAccountImpl implements UpdatableAccount { private long currentBalance;

public UpdatableAccountImpl(long initialBalance) { currentBalance =


initialBalance; }

balanço longo sincronizado público() { return saldoatual; }

crédito nulo público sincronizado (quantia longa)


lança Fundos Insuficientes {
if (quantia >= 0 || Saldocorrente >= -quantia)
saldoatual += valor; senão lançar
novos
InsufficientFunds();
}

débito nulo sincronizado público (valor longo)


lança InsufficientFunds { credit(-amount);

}
}

classe final ImmutableAccount implementa Account { private Account


delegate;

public ImmutableAccount(long initialBalance) {


delegado = new UpdatableAccountImpl(initialBalance); }

ImmutableAccount(Conta conta) { delegado = conta; }


Machine Translated by Google

public long balance() { // encaminha o método imutável return delegate.balance(); }

Essas classes podem ser usadas, por exemplo, em:

class AccountRecorder { // Um recurso de log public void


recordBalance(Account a) { System.out.println(a.balance()); //
ou grava em arquivo } }

class AccountHolder { private


UpdatableAccount conta = new UpdatableAccountImpl(0); gravador de AccountRecorder
privado;

public AccountHolder(AccountRecorder r) { gravador = r; }

público sincronizado void aceitarMoney(quantia longa) {


tente
{ conta.crédito(valor);
recorder.recordBalance(new ImmutableAccount(conta));//(*)
}
Machine Translated by Google

catch (InsufficientFunds ex)


{ System.out.println("Não é possível aceitar valor negativo."); }

}
}

O uso de um wrapper somente leitura na linha (*) pode parecer uma precaução desnecessária.
Mas protege contra o que pode acontecer se alguém escrever a seguinte subclasse e usá-
la em conjunto com AccountHolder:

class EvilAccountRecorder extends AccountRecorder {


peculato longo privado; // ... public
void
recordBalance(Account a) {
super.recordBalance(a);

if (uma instância de UpdatableAccount) {


Conta atualizável u = (Conta atualizável)a; tente { u.débito(10);

peculato += 10;

} catch (InsufficientFunds quietignore) {}


}

}}

A estrutura java.util.Collection usa uma variante desse esquema. Em vez de declarar uma interface imutável
separada, a interface Collection principal permite que métodos mutativos lancem UnsupportedOperationExceptions.
As classes anônimas do adaptador somente leitura lançam essas exceções em todas as tentativas de operação
de atualização. Eles podem ser construídos através, por exemplo:

Lista l = new ArrayList(); // ...

untrustedObject.use(Collections.unmodifiableList(l));

2.4.4 Cópia na Gravação

Quando um conjunto de campos que compõem o estado de um objeto deve manter um conjunto de invariantes
inter-relacionados, você pode isolar esses campos em outro objeto que preserve as garantias semânticas pretendidas.
Machine Translated by Google

Uma boa maneira de fazer isso é contar com objetos de representação imutáveis que sempre mantêm
instantâneos consistentes de estados de objetos legais. Confiar na imutabilidade elimina a necessidade
de coordenar leituras separadas de atributos relacionados. Também normalmente elimina a
necessidade de ocultar essas representações dos clientes.

Por exemplo, no § 1.1.1.1, tivemos que tomar precauções especiais envolvendo a sincronização de
blocos para garantir que as coordenadas consistentes (x, y) das Partículas fossem sempre exibidas
corretamente. E as classes Shape descritas em § 2.4.2 nem mesmo fornecem um mecanismo para fazer
isso. Uma solução é empregar uma classe ImmutablePoint separada que mantém as informações de
localização sempre consistentes:

class ImmutablePoint { private


final int x; int final privado;

public ImmutablePoint(int initX, int initY) { x = initX; y = initY; }

public int x() { return x; } public int y()


{ return y; } }

ImmutablePoints pode ser usado na seguinte classe Dot que é semelhante à classe Particle em §
1.1.1.1. Esta aula ilustra as técnicas gerais relacionadas às atualizações de cópia na gravação, nas quais
as alterações de estado não atualizam campos diretamente, mas constroem e anexam novos objetos
de representação.

Observe que a sincronização de alguma forma é necessária aqui. Mesmo que os objetos de representação
de ponto sejam imutáveis, a referência loc é mutável. Embora a sincronização da localização
do método acessador possa ser afrouxada de acordo com as considerações em § 2.4.1, o método
shiftX deve ser sincronizado (ou talvez modificado de outra forma) para impedir várias execuções
simultâneas nas quais diferentes versões de loc são obtidas ao acessar loc.x() e loc.y().

class Dot { loc


ImmutablePoint protegido;

public Ponto(int x, int y) {


loc = new ImmutablePoint(x, y); }

public sincronizado ImmutablePoint location() { return loc; }

sincronizado void void updateLoc(ImmutablePoint newLoc) {

loc = novoLoc;
}
Machine Translated by Google

public void moveTo(int x, int y) {


updateLoc(new ImmutablePoint(x, y)); }

público sincronizado void shiftX(int delta) {


updateLoc(new ImmutablePoint(loc.x() + delta, loc.y()));

}}

2.4.4.1 Cópia em gravação interna

Quando as representações de estado são estritamente internas a um objeto, não há motivo convincente
para criar novas classes apenas para impor o acesso imutável. O copy-on-write pode ser aplicado sempre
que a necessidade de obter representações consistentes de forma rápida e sem esforço supera os custos de
construção. Requer no máximo uma operação sincronizada para acessar todo o estado mantido por um objeto de representação imutáv
Além disso, em alguns contextos, é conveniente obter um único instantâneo em vez de um que reflita quaisquer
modificações de estado feitas durante o uso desse instantâneo.

Por exemplo, objetos de coleção copy-on-write podem ser muito úteis para manter coleções de ouvintes
em estruturas de eventos e multicast (consulte § 4.1). Aqui, os objetos mantêm listas de ouvintes ou
manipuladores que devem receber notificações de mudanças de estado ou outros eventos de interesse.
Essas listas raramente mudam, mas podem ser percorridas com muita frequência. Além disso, quando
os objetos que recebem notificações fazem alterações na lista de notificados, elas quase sempre
têm a intenção de entrar em vigor na próxima vez que uma notificação for emitida, não na rodada atual.

Embora existam outras boas opções para a estrutura de dados subjacente (incluindo a estrutura baseada
em árvore de finalidade especial usada em java.awt.EventMulticaster e estruturas mais elaboradas que
mantêm registros de edição a partir de uma base comum), uma cópia baseada em array A classe de
coleção -write é adequada para a maioria dos aplicativos. A travessia via iteradores não é
apenas rápida, mas também evita ConcurrentModificationExceptions que podem ocorrer em algumas
outras abordagens de travessia (consulte § 2.2.3).

class CopyOnWriteArrayList { array // Incompleto


Object[] protegido = new Object[0];

Object sincronizado protegido[] getArray() { array de retorno; }

public sincronizado void add(Object element) { int len =


array.length; Object[] newArray
= new Object[len+1]; System.arraycopy(array, 0,
newArray, 0, len); novoArray[len] = elemento; matriz = novaArray; }

public Iterator iterator() { return new


Iterator() {
objeto final protegido[] instantâneo = getArray();
Machine Translated by Google

cursor int protegido = 0;

public boolean hasNext() {


return cursor < snapshot.length; }

public Object next() { try { return

snapshot[cursor++];

} catch (IndexOutOfBoundsException ex) { lançar novo


NoSuchElementException(); }

} };
}
}

(O pacote util.concurrent disponível no suplemento on-line contém uma versão dessa classe que está em conformidade
com a interface java.util.List .)

Essa classe seria terrivelmente ineficiente se usada em contextos envolvendo modificações frequentes de grandes
coleções, mas é adequada para a maioria das aplicações multicast, conforme ilustrado em § 3.5.2 e § 3.6.4.

2.4.4.2 Atualizações Otimistas

As atualizações otimistas empregam um protocolo mais fraco do que outras técnicas de cópia na gravação:
em vez de envolver bloqueios durante toda a duração dos métodos de atualização de estado, eles empregam
sincronização apenas no início e no final dos métodos de atualização. Normalmente, cada método assume a forma:

1. Obtenha uma cópia da representação do estado atual (enquanto mantém um bloqueio).


2. Construa uma nova representação de estado (sem manter nenhum bloqueio).
3. Comprometa-se com o novo estado apenas se o antigo estado não tiver mudado desde que o obteve.

As técnicas de atualização otimistas limitam a sincronização a intervalos muito breves, apenas o tempo
suficiente para acessar e atualizar posteriormente as representações de estado. Isso tende a fornecer um
desempenho muito bom em multiprocessadores, pelo menos sob condições de uso adequadas.

O principal requisito adicionado aqui sobre as técnicas convencionais de cópia na gravação é lidar com a
possibilidade de que a Etapa 3 falhe porque algum outro encadeamento atualizou independentemente a
representação do estado antes que o encadeamento atual tivesse a chance de fazê-lo. O potencial de falha apresenta
duas preocupações (discutidas com mais detalhes no § 3.1.1) que limitam o alcance da aplicabilidade das técnicas
de atualização otimista:

Protocolos de falha. As opções são repetir toda a sequência do método ou propagar a falha de volta para um
cliente, que pode então tomar uma ação evasiva. A escolha mais comum é tentar novamente.
No entanto, isso pode levar ao livelock, o análogo otimista do bloqueio indefinido, no qual os métodos giram
continuamente sem fazer nenhum progresso adicional. Embora a probabilidade de livelock seja normalmente muito
pequena, a ação pode nunca ser concluída e pode gastar muitos recursos da CPU repetidamente tentando fazê-lo.
Por esse motivo, as técnicas de atualização otimista são escolhas ruins para classes usadas em
Machine Translated by Google

contextos que podem encontrar contenção massiva de encadeamentos de duração ilimitada. No entanto,
alguns algoritmos otimistas sem espera especializados provaram ser bem-sucedidos após um número limitado de
tentativas, independentemente da contenção (consulte Leituras adicionais).

Efeitos colaterais. Como podem falhar, as ações executadas durante a construção de novas
representações de estado não podem incluir nenhum efeito colateral irrevogável. Por exemplo, eles não devem gravar
em arquivos, criar threads ou desenhar em GUIs, a menos que essas ações possam ser canceladas significativamente
em caso de falha (consulte § 3.1.2).

2.4.4.3 Compromisso atômico

O coração de qualquer técnica otimista é um método de compromisso atômico que é usado em vez de
instruções de atribuição. Ele deve trocar condicionalmente em uma nova representação de estado somente se a
representação de estado existente for a esperada pelo chamador. Há muitas maneiras de distinguir e rastrear
diferentes representações de estado, por exemplo, usando números de versão, identificadores de transação,
carimbos de data/hora e códigos de assinatura. Mas é de longe mais conveniente e mais comum simplesmente confiar
na identidade de referência do objeto de estado. Aqui está um exemplo genérico:

classe Otimista { // Esboço de código genérico

Estado Estado privado; // referência ao objeto de representação

Estado sincronizado privado getState() { return state; }

confirmação booleana sincronizada privada (estado assumido,


Estado seguinte) {
if (estado == assumido) { estado =
próximo; retornar
verdadeiro;

} outro
retorna falso;

}}

Existem várias pequenas variações comuns em como o método commit é definido. Por exemplo, a versão geralmente
denominada compareAndSwap retorna o valor atual, que pode ser o valor novo ou antigo, dependendo se a operação
foi confirmada com sucesso. A popularidade crescente de técnicas otimistas na programação simultânea em nível
de sistema é em parte devido (e em parte a causa) ao fato de que a maioria dos processadores modernos inclui uma instrução
compareAndSwap integrada eficiente ou uma de suas variantes. Embora não sejam diretamente
acessíveis a partir da linguagem de programação Java, em princípio é possível otimizar compiladores para mapear
construções para usar tais instruções. (Mesmo que não, as atualizações otimistas ainda são eficientes.)

Em uma classe puramente otimista, a maioria dos métodos de atualização assume uma forma padrão: obtendo
o estado inicial, construindo uma nova representação de estado e, em seguida, confirmando, se possível, senão
fazendo um loop ou lançando uma exceção. No entanto, os métodos que não dependem de nenhum estado
inicial específico podem ser escritos de maneira mais simples, trocando incondicionalmente o novo estado. Por
exemplo, aqui está uma versão otimista da classe Dot :
Machine Translated by Google

class OptimisticDot { loc


ImmutablePoint protegido;

public OptimisticDot(int x, int y) { loc = new


ImmutablePoint(x, y); }

public sincronizado ImmutablePoint location() { return loc; }

commit booleano sincronizado protegido (ImmutablePoint assumido,


ImutablePoint próximo) {
if (loc == assumido) { loc =
próximo;
retornar verdadeiro;

} outro
retorna falso;
}

public sincronizado void moveTo(int x, int y) {


// ignora o commit, pois a operação é incondicional loc = new ImmutablePoint(x,
y); }

public void shiftX(int delta) {


sucesso booleano = false; faça

{ ImmutablePoint old = location(); ImmutablePoint


next = new ImmutablePoint(old.x() + delta, old.y());

sucesso = commit(antigo, próximo); }


while (!sucesso); }

Se o potencial de interferência prolongada for uma preocupação, em vez de simplesmente girar, o loop em
shiftX pode usar um esquema de back-off exponencial conforme discutido em § 3.1.1.5.

2.4.5 Contêineres Abertos

As técnicas de bloqueio hierárquico ordenado podem ser aplicadas quando você tem um design de contenção em
camadas (§ 2.3.3) , mas não pode ou não deseja ocultar estritamente todos os objetos Parte de outros clientes.

Se as Partes estiverem visíveis para os clientes, elas devem empregar sincronização. Porém, quando essas
partes freqüentemente invocam métodos em outras partes, os designs resultantes podem estar propensos a um
impasse. Por exemplo, suponha que um thread mantenha o bloqueio em part1 que, por sua vez, faz uma chamada
para part2, enquanto outro thread está fazendo o oposto:
Machine Translated by Google

Você pode eliminar essa forma de impasse usando a estratégia vista em projetos de confinamento de
objetos estritos: faça com que os objetos Partes dependam do bloqueio do Host para seu controle de sincronização.
Se os clientes devem primeiro obter o bloqueio do host, esta forma de impasse não pode ocorrer:

Esta solução é suficiente para a maioria dos projetos de contenção envolvendo componentes visíveis (consulte
também § 2.5.1.3 para uma variante adicional). A obtenção de bloqueios externos de contêineres antes de operar em
peças representa uma abordagem estruturada para aplicar as técnicas de ordenação de recursos discutidas no
§ 2.2.6. No entanto, sem confinamento, não existe uma estratégia simples que imponha esta solução. As classes (e
seus autores) devem conhecer as regras e cumpri-las. A principal escolha política diz respeito a quem deve conhecer
essas regras, as partes internas ou os clientes externos. Nenhuma das opções é perfeita, mas uma deve ser adotada:

O bloqueio interno é difícil de adaptar a classes existentes e pode aumentar a dependência de uma classe
em seu contexto.
O bloqueio externo falha se algum cliente se esquecer de usar o protocolo.

2.4.5.1 Disciplinas internas

No bloqueio de contenção interna, cada parte usa o bloqueio de sincronização de seu contêiner para todos os
métodos que requerem controle de exclusão dinâmica. No caso mais eficiente, cada parte possui um campo final
que é inicializado na construção e usado para todos os bloqueios. Bloqueio não relacionado adicional dentro dos
métodos da Parte também pode ser aceitável (mas consulte o § 3.3.4).

Por exemplo:

parte da classe { // Esboço do código


Machine Translated by Google

bloqueio de objeto final protegido; // ...

parte pública (proprietário do objeto) { lock = proprietário; }

public Part() { lock = this; } // se não tiver proprietário, use self

public void anAction()


{ sincronizado(bloqueio)
{ outraPart.help(); // ... }

}
}

Por uma questão de política de design, você pode definir a maioria ou todas as classes dessa maneira para acomodar o uso
em várias estruturas baseadas em contêiner. No entanto, esses designs são mais difíceis de gerenciar quando a propriedade
de uma peça pode mudar dinamicamente. Nesse caso, você também deve sincronizar o acesso ao próprio campo de bloqueio
(normalmente usandosynched (this)) antes de usá-lo para controlar o acesso ao corpo de um método.

Uma estrutura mais simples está disponível quando você pode fazer com que cada classe Part seja declarada como uma classe
interna de seu Host. Nesse caso, você pode usar blocos sincronizados com Host.this como argumento:

classe Host { // ... // esboço do código

class Part { // ...


public
void anAction() {
sincronizado(Host.this)
{ outraPart.ajuda(); // ... } } } }

2.4.5.2 Disciplinas externas

Na versão mais extrema e não estruturada do bloqueio externo, cada chamador de cada método em cada parte deve saber de
alguma forma qual bloqueio adquirir antes de fazer a chamada:

sincronizado(someLock) { aPart.anAction(); }

Em sistemas finitos e fechados, os desenvolvedores podem até mesmo criar uma lista definindo quais bloqueios devem
ser associados a quais objetos e, em seguida, exigir que os autores do código estejam em conformidade com essas
regras. Essa tática pode ser defensável para pequenos sistemas embutidos não extensíveis que, de outra forma, poderiam ser propensos a impasses.
Machine Translated by Google

No entanto, esta solução obviamente não escala bem. Mesmo em contextos um pouco maiores, o código do cliente
deve ser capaz de determinar programaticamente qual bloqueio usar. Uma maneira de organizar isso é
construir tabelas que mantenham as associações necessárias entre objetos e bloqueios. Uma estratégia um
pouco mais estruturada é incluir em cada classe Part um método, digamos getLock, que retorne o bloqueio
a ser usado para controle de sincronização. Os clientes podem fazer chamadas usando:

sincronizado(aPart.getLock()) { aPart.anAction(); }

Essa abordagem é usada no pacote java.awt (pelo menos até a versão 1.2). Cada java.awt.Component
suporta o método getTreeLock que retorna o bloqueio a ser usado para controlar operações sincronizadas no
contêiner atual (por exemplo, um Frame). A escolha de como e quando usar esse bloqueio é deixada para o código
do cliente. Isso introduz oportunidades para extensibilidade ad hoc e pode resultar em pequenas
melhorias de desempenho em comparação com as disciplinas de bloqueio interno. Por exemplo, um
cliente não precisa readquirir o bloqueio se ele já estiver retido. Mas essa forma de bloqueio externo também
apresenta mais oportunidades de erro, bem como a necessidade de uma extensa documentação que os clientes
devem saber o suficiente sobre as operações para determinar se e como os bloqueios devem ser usados.

2.4.5.3 Contenção multinível

Ambas as abordagens para bloqueio de contenção hierárquica podem ser estendidas além de dois níveis. Cada
camada da hierarquia deve ter um bloqueio associado. O código deve passar pelos bloqueios em todas
as camadas, na ordem mais externa para a mais interna, antes de invocar os métodos de atualização. O
suporte para um nível arbitrário de bloqueios aninhados é extremamente difícil de configurar usando blocos ou
métodos sincronizados , mas pode ser acessível usando classes e gerenciadores utilitários de bloqueio (consulte § 2.5.1).

2.4.6 Leituras Adicionais


Uma descrição geral das classes de refatoração é:

Fowler, Martin. Refatoração, Addison-Wesley, 1999.

Algoritmos de atualização otimistas que podem ser comprovados como bem-sucedidos eventualmente incluem
a classe de algoritmos sem espera, nos quais nenhum thread é bloqueado em uma condição que pode ser
satisfeita apenas pela ação de algum outro thread. Em algoritmos sem espera, cada thread é bem-sucedida
após um número finito de tentativas, independentemente do que as outras threads fazem ou não. A maioria dos
algoritmos emprega a noção de ajuda: quando um thread não pode continuar, ele realiza alguma ação para
ajudar outro thread a concluir sua tarefa. A teoria dos algoritmos sem espera é descrita em:

Herlihy, Maurício. "Sincronização sem espera", ACM Transactions on Programming Languages and Systems, vol.
13, não. 1, 1991.

Algoritmos práticos de atualização sem espera são conhecidos apenas para um pequeno número de estruturas
de dados comuns, por exemplo, filas e listas. No entanto, esses algoritmos são cada vez mais usados no suporte
de tempo de execução subjacente em kernels do sistema operacional e implementações de JVM. O suplemento
online contém uma adaptação de uma classe de fila sem espera descrita no artigo a seguir, bem como links para
descrições de algoritmos sem espera implementados em outras linguagens.

Michael, Maged e Michael Scott. "Algoritmos de filas concorrentes simples, rápidos e práticos, sem bloqueio e com
bloqueio", Proceedings, 15th ACM Symposium on Principles of Distributed Computing, ACM, 1996.
Machine Translated by Google

2.5 Usando Utilitários de Bloqueio

Métodos e blocos sincronizados integrados são suficientes para muitos aplicativos baseados em bloqueio, mas eles
têm as seguintes limitações:

Não há como desistir de uma tentativa de adquirir um bloqueio se ele já estiver mantido, desistir depois de
esperar por um tempo especificado ou cancelar uma tentativa de bloqueio após uma interrupção. Isso pode
dificultar a recuperação de problemas de vitalidade.
Não há como alterar a semântica de um bloqueio, por exemplo, com relação à reentrância, proteção contra
leitura versus gravação ou imparcialidade.
Não há controle de acesso para sincronização. Qualquer método pode executarsynch
(obj) para qualquer objeto acessível, levando assim a possíveis problemas de negação de serviço
causados pela manutenção dos bloqueios necessários.
A sincronização dentro de métodos e blocos limita o uso a bloqueios estruturados de blocos rígidos. Por
exemplo, você não pode adquirir um bloqueio em um método e liberá-lo em outro.

Esses problemas podem ser superados usando classes utilitárias para controlar o bloqueio. Essas classes
podem ser construídas usando as técnicas descritas em § 3.7. Aqui, restringimos a atenção ao seu uso na
implementação de projetos baseados em bloqueio. Embora seja possível criar classes de bloqueio fornecendo
praticamente qualquer semântica e propriedades de uso desejadas, ilustramos apenas duas comuns, bloqueios de
exclusão mútua e bloqueios de leitura/gravação. Por uma questão de concretude, as apresentações contam com as
versões dessas classes no pacote util.concurrent disponível no suplemento online. No entanto, comentários
semelhantes são válidos para praticamente qualquer tipo de classe de utilitário de bloqueio que você possa construir.

Todos os projetos baseados em bloqueio discutidos anteriormente neste capítulo podem, se desejado, ser
reimplementados usando utilitários de bloqueio em vez de métodos e blocos sincronizados integrados. (Exemplos
adicionais podem ser encontrados na maioria dos textos de programação de sistemas concorrentes listados em §
1.2.5.) Esta seção se concentra em usos que, de outra forma, são difíceis de organizar.

As soluções fornecidas pelas classes utilitárias de bloqueio têm o preço de expressões de codificação mais estranhas
e menos imposição automática do uso correto. O uso de utilitários de bloqueio requer mais cuidado e disciplina do
que normalmente necessário ao usar métodos e blocos sincronizados . Essas construções também podem implicar
em maior sobrecarga, uma vez que são menos prontamente otimizadas do que os usos de sincronização
integrada.

2.5.1 Mutexes

Uma classe Mutex (abreviação de bloqueio de exclusão mútua) pode ser definida como (omitindo o código de implementação):

public class Mutex implementa Sync { public void


adquir() lança InterruptedException; public void release(); lances de
tentativa booleana pública (ms
longos)
InterruptedException; }

(Na versão util.concurrent , o Mutex implementa a interface Sync, uma interface padronizada para todas as
classes obedecendo aos protocolos de aquisição e liberação.)
Machine Translated by Google

Como você pode esperar, a aquisição é análoga às operações executadas na entrada em um bloco
sincronizado e a liberação é análoga às operações executadas na saída de um bloco. A operação de tentativa
retorna true somente se o bloqueio for adquirido dentro do tempo especificado (pelo menos com o melhor da
capacidade da implementação para medir esse tempo e reagir em tempo hábil, consulte § 3.2.5). Zero é um
argumento legal, o que significa que não espere nada se o bloqueio não estiver disponível.

Além disso, ao contrário da sincronização interna, os métodos de aquisição e tentativa lançam


InterruptedException se o thread atual foi interrompido durante a tentativa de obter o bloqueio. Isso dificulta o
uso, mas possibilita o desenvolvimento de código responsivo e robusto diante do cancelamento. A gama de
respostas razoáveis para InterruptedException é discutida com mais detalhes em § 3.1.2; aqui ilustramos
apenas as opções mais comuns.

Um Mutex pode ser usado da mesma forma que um bloqueio interno, substituindo blocos do formulário:

sincronizado(bloqueio) { /* corpo */ }

com o mais detalhado e desajeitado antes/depois da construção:

tente
{ mutex.acquire();
tente
{ /* corpo */ }

finalmente
{ mutex.release();
}

} catch (InterruptedException ou seja) {


/* resposta ao cancelamento da thread durante a aquisição */ }

Ao contrário dos blocos sincronizados , o bloqueio nas classes Mutex padrão não é reentrante. Se o bloqueio for
mantido, mesmo pelo thread que está realizando a aquisição, o thread será bloqueado. Embora também seja
possível definir e usar um ReentrantLock , uma classe Mutex simples é suficiente em muitos aplicativos de
bloqueio. Por exemplo, podemos usá-lo para reimplementar a classe Particle do § 1.1.1.1:

class ParticleUsingMutex { protected


int x; protegido int y;
final protegido Random
rng = new Random(); protegido final Mutex mutex = new
Mutex();

public ParticleUsingMutex(int inicialX, int inicialY) { x = inicialX; y = inicialY; }

public void move() { tente {


Machine Translated by Google

mutex.acquire(); tente
{ x +=
rng.nextInt(10) - 5; y += rng.nextInt(20)
- 10;

} finalmente { mutex.release(); }

} catch (InterruptedException ou seja) {


Thread.currentThread().interrupt(); }

public void draw(Gráficos g) { int lx, ly;

tente
{ mutex.acquire(); tente
{ lx =
x; ly = y;

} finalmente { mutex.release(); }

} catch (InterruptedException ou seja) {


Thread.currentThread().interrupt(); retornar;

g.drawRect(lx, ly, 10, 10); } }

As construções try/finally envolvendo os corpos da operação imitam o comportamento de blocos


sincronizados nos quais os bloqueios são liberados não importando como o corpo sai, mesmo que
por meio de uma exceção não capturada. Como regra de design, é uma boa ideia usar try/finally
mesmo se você acreditar que o corpo não pode lançar nenhuma exceção.

Os métodos move e draw retornam imediatamente sem executar nenhuma ação se o thread foi
interrompido durante a aquisição do bloqueio. Esta é uma resposta simples e adequada ao cancelamento.
No entanto, conforme discutido em § 3.1.2, as cláusulas catch também são obrigadas a propagar o
status de cancelamento via Thread.currentThread().interrupt().

A classe ParticleUsingMutex é mais resistente a ataques hostis de negação de serviço do que a original.
Como o bloqueio sincronizado embutido não é usado, não importa se alguém o segura.
(Observe, no entanto, que nenhum desses problemas poderia ocorrer no ParticleApplet de qualquer
maneira porque todas as referências estão confinadas ao applet.) Se fôssemos ainda mais paranóicos,
poderíamos declarar o mutex como privado. Mas, na maioria dos casos, isso impediria
desnecessariamente a extensibilidade. Como qualquer subclasse plausível também precisaria acessar o
bloqueio, declarar o mutex como privado é quase equivalente a declarar a própria classe como final.
Machine Translated by Google

2.5.1.1 Adaptadores de método

Uma melhor estrutura e disciplina em torno dos bloqueios podem ser organizadas por meio de qualquer um dos
padrões antes/depois discutidos em § 1.4. Por exemplo, o uso de adaptadores de método suporta a definição de
wrappers genéricos que podem executar qualquer código em qualquer bloqueio. Um wrapper pode ser definido
como método de uma classe usando bloqueio ou como uma classe de utilitário separada. Um exemplo deste último é:

classe ComMutex {
mutex mutex final privado; public
WithMutex(Mutex m) { mutex = m; }

public void perform(Runnable r) throws InterruptedException {


mutex.acquire(); tente
{ r.run(); } finalmente
{ mutex.release(); } } }

Isso pode ser usado por classes que separam ações simples como métodos internos, invocados dentro de
wrappers por métodos públicos, por exemplo:

class ParticleUsingWrapper { // ... protegido // Incompleto


final
WithMutex withMutex =
new ComMutex(new Mutex());

void protegido doMove() {


x += rng.nextInt(10) - 5; y +=
rng.nextInt(20) - 10; }

public void move() { try

{ withMutex.perform(new Runnable() { public void run()


{ doMove(); } });

} catch (InterruptedException ou seja) {


Thread.currentThread().interrupt(); } } // ... }

Esse design encontra uma sobrecarga um pouco maior, portanto, é aplicável principalmente em classes que
protegem ações relativamente demoradas. Além disso, a versão ilustrada aplica-se apenas a ações internas que podem
ser expressas como Runnables sem argumentos e sem resultados. Assim, por exemplo, não pode ser usado com o
método draw . No entanto, esse esquema pode ser estendido definindo adaptadores de método adicionais que
aceitem outros argumentos e/ou retornem resultados, conforme descrito em § 1.4.4.
Machine Translated by Google

2.5.1.2 Retrocessos

O método try é útil para recuperação de deadlocks e outros problemas de vivacidade envolvendo vários bloqueios.
Quando você não pode garantir que os bloqueios sejam impossíveis (como é o caso de pelo menos alguns
componentes na maioria dos sistemas abertos), você pode usar rotineiramente tentativa em vez de
aquisição, fornecendo um valor de tempo limite heurístico (por exemplo, alguns segundos) para indicar
possíveis bloqueios e, em seguida, tomar medidas evasivas em caso de falha (consulte § 3.2.5).

O método de tentativa também pode ser usado em construções mais especializadas para lidar com construções
propensas a impasses. Por exemplo, aqui está outra versão da classe Cell do § 2.2.5, que desiste e tenta
novamente ao descobrir um impasse potencial. Como uma heurística, inclui um pequeno atraso entre novas
tentativas. Como depende de novas tentativas, pode travar ao vivo. Isso pode ser aceitável se você conseguir se
convencer de que a probabilidade de livelock infinito é, digamos, menor que a probabilidade de uma falha aleatória
de hardware.

Observe que a verificação de alias é necessária aqui e em todas as construções semelhantes envolvendo bloqueios
não reentrantes para evitar bloqueios ao tentar readquirir um bloqueio que já foi mantido (consulte § 2.2.6).

class CellUsingBackoff { valor longo


privado; private final Mutex
mutex = new Mutex();

void swapValue(CellUsingBackoff other) { if (this == other)


return; // verificação de alias necessária aqui para (;;) { try { mutex.acquire(); tente { if

(other.mutex.attempt(0))
{ tente
{ longo t = valor; valor = outro.valor;

outro.valor = t; retornar; }
finalmente { outro.mutex.release();

}
}

} finalmente
{ mutex.release(); };

Thread.sleep(100);

} catch (InterruptedException ou seja)


{ Thread.currentThread().interrupt(); retornar;

}}
Machine Translated by Google

}
}

2.5.1.3 Reordenações

Técnicas de back-off podem ser usadas como salvaguardas em projetos que empregam técnicas de ordenação
de travamento (ver § 2.2.6 e § 2.4.5) em que há exceções relativamente raras a uma determinada hierarquia
de travamento. Nesses casos, o código que requer vários bloqueios pode tentar uma ordem e, se falhar,
libere todos os bloqueios e tente uma ordem diferente. Essa estratégia pode estender a gama de aplicabilidade
dos esquemas de bloqueio baseados em contenção. Você não precisa ter certeza absoluta de que todos os
bloqueios mantêm a ordem desejada se puder arranjar uma estratégia de fallback um pouco mais cara para
lidar com casos excepcionais. Isso pode ocorrer, por exemplo, em projetos de contenção hierárquica que
empregam callbacks ou passagens de coleção nas quais não é possível garantir a conformidade com um determinado conjunto de regra

Para ilustrar as técnicas básicas, aqui está uma classe Cell que emprega um embaralhamento para evitar
impasses para swapValue. Ao ficar preso, ele tenta travar os dois objetos na direção oposta:

classe CellUsingReorderedBackoff {
valor longo privado; private
final Mutex mutex = new Mutex();

private static booleano trySwap(CellUsingReorderedBackoff a,


CellUsingReorderedBackoff b)
lança InterruptedException { sucesso
booleano = false;

if (a.mutex.attempt(0)) { try { if

(b.mutex.attempt(0)) { try { long t = a.value;


a.valor
= b.valor; b.valor = t;
sucesso = verdadeiro;

} finalmente {
b.mutex.release(); }

} finalmente
{ a.mutex.release(); }

} return sucesso;
}

void swapValue(CellUsingReorderedBackoff outro) {


if (this == other) return; // verificação de alias necessária aqui try {
Machine Translated by Google

while (!trySwap(este, outro) && !trySwap(outro,


este))
Thread.sleep(100);

} catch (InterruptedException ex) {


Thread.currentThread().interrupt(); }

}}

2.5.1.4 Travamento não estruturado em bloco

Um Mutex pode ser usado em construções que não podem ser expressas usando blocos sincronizados
porque os pares de aquisição/liberação não ocorrem no mesmo método ou bloco de código.

Por exemplo, você pode usar um Mutex para bloqueio manual (também conhecido como acoplamento de
bloqueio) nos nós de uma lista encadeada durante a travessia. Aqui, o bloqueio para o próximo nó deve ser
obtido enquanto o bloqueio para o nó atual ainda está sendo mantido. Mas depois de adquirir o próximo
bloqueio, o bloqueio atual pode ser liberado.

A travessia mão a mão permite um bloqueio extremamente refinado e, portanto, aumenta a


simultaneidade potencial, mas ao custo de complexidade adicional e sobrecarga que normalmente valeria a
pena apenas em casos de contenção extrema.

class ListUsingMutex {

Nó de classe estática {
Item de objeto;
Nó seguinte;
Mutex lock = new Mutex(); // cada nó mantém seu próprio bloqueio
Nó(Objeto x, Nó n) { item = x; próximo = n; } }

cabeça de nó protegida; // ponteiro para o primeiro nodo da lista

//Use sincronização simples para proteger o campo da cabeça.


// (Poderíamos, em vez disso, usar um Mutex aqui também, mas // não há razão para fazê-lo.)

Nó sincronizado protegido getHead() { return head; }

public sincronizado void add(Object x) { // prefixo simples


Machine Translated by Google

// para simplificar aqui, não permita elementos nulos if (x == null) throw new
IllegalArgumentException();

// O uso de sincronizado aqui protege apenas o campo da cabeça.


// O método não precisa esperar outros atravessadores // que já passaram pelo nó
principal.

cabeça = novo Nó(x, cabeça); } busca

booleana(Objeto x) lança InterruptedException {


Nó p = getHead();

if (p == nulo || x == nulo) retorna falso;

p.lock.acquire(); // Prime loop adquirindo o primeiro bloqueio.

// Se a aquisição acima falhar devido à interrupção, o método lançará // InterruptedException


agora, portanto não há necessidade de // limpeza adicional.

para (;;) {
Nó nextp = nulo; booleano
encontrado;

tente
{ encontrado = x.equals(p.item); if (!
found) { nextp =
p.next; if (nextp != null)
{ tente {
// Adquire o próximo bloqueio //
enquanto ainda mantém o próximo
próximop.lock.acquire();

} catch (InterruptedException ou seja) {


jogar ou seja; // Observe que a cláusula finalmente // executará
antes do lançamento
}
}
}

} // libera o bloqueio antigo independentemente do resultado finalmente


{ p.lock.release(); }

se (encontrado)
retornar verdadeiro;
else if (nextp == null) return false;
outro
Machine Translated by Google

p = próximop;
}
}

// ... outros métodos similares de passagem e atualização ... }

Outra aplicação do Mutex que explora a falta de estruturação de bloco necessária é a construção de objetos
de variável de condição, discutidos em § 3.4.4.

2.5.1.5 Gerentes de Pedido de Bloqueio

Quando vários bloqueios devem ser obtidos em alguma ordem específica, por exemplo, nos esquemas
de contenção hierárquica discutidos em § 2.4.5 e nas técnicas gerais de ordenação de recursos discutidas em
§ 2.2.6, você pode ajudar a garantir a conformidade centralizando os métodos de ordenação em um classe de gerenciador de bloqueio.

Existem inúmeras técnicas para estruturar os tipos de bloqueios usados, definindo suas regras de ordenação
e estabelecendo as responsabilidades da classe gerenciadora. No entanto, quase todos os projetos
contêm métodos da seguinte forma, o que ilustra o cuidado necessário para garantir que os bloqueios sejam
liberados, não importa quais exceções ocorram:

class LockManager { // ... void // Esboço do código

sortLocks(Sync[] locks) { /* ... */ }

public void runWithinLocks (operação executável, bloqueios de sincronização [])


lança InterruptedException {

sortLocks(bloqueios);

// para obter ajuda na recuperação de exceções int lastlocked =


-1;
InterruptedException capturado = null;

try { for
(int i = 0; i < locks.length; ++i) { locks[i].acquire();
últimobloqueado = i; }

op.run();

catch (InterruptedException ou seja) {


pego = ou seja; }

finalmente {
for (int j = último bloqueado; j >= 0; --j)
Machine Translated by Google

locks[j].release();

if (pego != null) jogue pego;

}}

2.5.2 Bloqueios de Leitura-Gravação

ReadWriteLocks mantém um par de bloqueios associados. Uma forma de defini-los é:

interface ReadWriteLock {
Sincronizar readLock();
Sync writelock(); }

Os bloqueios retornados pelos dois métodos aqui obedecem à mesma interface Sync que o Mutex (consulte § 2.5.1), suportando
métodos de aquisição, liberação e tentativa.

Conforme discutido no § 3.3.3, existem várias maneiras de implementar essa interface, dependendo da seleção das
políticas desejadas em torno de seu uso. Para fins de ilustração, assumiremos a definição de uma classe de implementação
genérica RWLock.

A ideia por trás dos bloqueios de leitura e gravação é que o readLock pode ser mantido simultaneamente por vários threads de
leitura, desde que não haja escritores. O writeLock é exclusivo. Os bloqueios de leitura e gravação são geralmente preferíveis aos
bloqueios simples quando:

Os métodos em uma classe podem ser claramente separados entre aqueles que apenas acessam (leem) dados mantidos
internamente e aqueles que modificam (escrevem).
A leitura não é permitida enquanto os métodos de escrita estiverem em andamento. (Se as leituras forem permitidas
durante as gravações, você pode, em vez disso, confiar em métodos de leitura não sincronizados ou atualizações
de cópia na gravação, consulte § 2.4.)
Os aplicativos de destino geralmente têm mais leitores do que gravadores.
Machine Translated by Google

Os métodos são relativamente demorados, portanto, vale a pena introduzir um pouco mais de sobrecarga
associada aos bloqueios de leitura e gravação em comparação com técnicas mais simples para permitir a
simultaneidade entre os threads de leitura.

Os bloqueios de leitura e gravação são frequentemente usados em classes que fornecem acesso a grandes coleções de
dados, onde os métodos são estruturados como:

class Repositório de dados { // Esboço do código

ReadWriteLock final protegido rw = new RWLock();

public void access() lança InterruptedException { rw.readLock().acquire(); try


{ /* ler dados */

} finalmente
{ rw.readLock().release(); }

public void modify() lança InterruptedException { rw.writeLock().acquire(); try


{ /* gravar dados */

} finalmente
{ rw.writeLock().release(); }

Os bloqueios de leitura e gravação podem ser úteis em alguns aplicativos de classes de coleção comuns. O
pacote util.concurrent disponível no suplemento online contém um conjunto de classes adaptadoras que podem ser usadas
com classes java.util.Collection , colocando bloqueios de leitura em métodos puramente inspetivos (como contains) e
bloqueios de gravação em métodos de atualização (como como adicionar).

2.5.3 Leituras Adicionais


Um conjunto de padrões para usar diferentes estilos de fechaduras pode ser encontrado em:

MKENNEY, Paul. "Selecting Locking Primitives for Parallel Programming", Communications of the ACM, 39(10): 75-82, 1996.

Capítulo 3. Dependência do Estado


Dois tipos de condições de habilitação são geralmente necessários para executar qualquer ação:
Machine Translated by Google

Externo. Um objeto recebe uma mensagem solicitando que uma ação seja executada.

Interno. O objeto está em um estado apropriado para executar a ação.

Como um exemplo sem programação, suponha que você seja solicitado a escrever uma mensagem telefônica. Para fazer isso,
você precisa ter um lápis e papel (ou algum outro dispositivo de gravação).

As técnicas de exclusão preocupam-se principalmente com a manutenção de invariantes. O controle de simultaneidade dependente
do estado impõe preocupações adicionais sobre pré-condições e pós-condições. As ações podem ter pré-condições baseadas em
estado que nem sempre precisam ser mantidas quando os clientes invocam métodos no objeto host.
Por outro lado, as ações podem ter pós-condições inatingíveis quando o objeto host não está em um estado adequado, quando
as ações de outros objetos dos quais ele depende falham em atingir suas próprias pós-condições ou quando as ações de outras
threads alteram os estados de outros objetos sendo confiado.

A maioria dos problemas de design para classes com ações dependentes de estado gira em torno das considerações
necessárias para concluir um design para que você leve em consideração todas as combinações possíveis de mensagens e
estados, como em:

ter lápis atender não tem telefone de

telefone tocar telefone escrever atendimento a lápis?

receber mensagem mensagem

Conforme sugerido na tabela, os projetos geralmente precisam levar em consideração situações nas quais o objeto não está em um
estado que permita qualquer ação "normal". Em um sistema ideal, todos os métodos não teriam pré-condições baseadas em
estado e sempre cumpririam suas pós-condições. Quando possível, classes e métodos devem ser escritos dessa maneira,
evitando assim quase todos os problemas discutidos neste capítulo. Mas muitas atividades são intrinsecamente dependentes
do estado e simplesmente não podem ser programadas para atingir pós-condições em todos os estados.

Existem duas abordagens gerais para o projeto e implementação de qualquer ação dependente do estado, que decorrem de
perspectivas de projeto de vivacidade versus segurança:

Métodos otimistas de tentar e ver sempre podem ser tentados quando invocados, mas nem sempre são bem-sucedidos e,
portanto, podem ter que lidar com falhas.

Os métodos conservadores de verificar e agir se recusam a prosseguir, a menos que as pré-condições sejam válidas. Quando as pré-
condições são válidas, as ações sempre são bem-sucedidas.

Se os métodos não verificam nem suas pré-condições nem suas pós-condições, eles podem ser chamados apenas em
contextos nos quais as pré-condições são de alguma forma conhecidas. A confiança em tais práticas em sistemas
concorrentes é, na melhor das hipóteses, problemática.

Abordagens otimistas e conservadoras são igualmente predominantes, e formas apropriadas delas podem ser igualmente boas
ou ruins em relação a várias forças de design. Mas como suas formas gerais são regidas por questões que podem estar fora de
seu controle, os dois nem sempre são intercambiáveis.
As abordagens otimistas dependem da existência de exceções e mecanismos relacionados que indicam quando as pós-condições
não são válidas. As abordagens conservadoras contam com a disponibilidade de construções de guarda que indicam quando as
pré-condições são válidas e garantem que elas continuem válidas durante o curso de uma ação que depende delas. As misturas são
naturalmente possíveis e são de fato comuns. Em particular, muitos projetos conservadores contêm código que pode encontrar
exceções e, portanto, devem estar preparados para lidar com falhas.
Machine Translated by Google

As medidas de controle de simultaneidade que lidam com ações dependentes do estado podem exigir esforço e atenção
significativos na programação simultânea. Este capítulo divide a cobertura da seguinte forma:

§ 3.1 discute exceções e cancelamento. § 3.2


apresenta as construções de proteção usadas em projetos conservadores, juntamente com a
mecânica usada para implementá-las. §
3.3 apresenta padrões estruturais para classes que empregam controle de concorrência.
O § 3.4 mostra como as classes utilitárias podem reduzir a complexidade e, ao mesmo tempo,
melhorar a confiabilidade, o
desempenho e a flexibilidade. O § 3.5 estende problemas e soluções para lidar com ações conjuntas que
dependem dos estados de vários
participantes. § 3.6 fornece uma breve visão geral do controle de concorrência
transacional. O § 3.7 conclui com algumas técnicas vistas na construção de classes utilitárias de controle
de concorrência.

3.1 Lidando com o fracasso


Projetos de controle otimistas puros se originam de atualização otimista e protocolos de transação. Mas
abordagens otimistas de algum tipo são vistas em praticamente qualquer código que faz chamadas para métodos
que podem encontrar falhas. Projetos experimentais tentam ações sem primeiro garantir que serão bem-sucedidas,
muitas vezes porque as restrições que garantiriam o sucesso não podem ser verificadas. No entanto, os métodos
otimistas sempre verificam as pós-condições (geralmente capturando exceções de falha) e, se falharem, aplicam
uma política de falha escolhida.

A necessidade de abordagens de teste geralmente decorre da incapacidade ou falta de vontade de verificar


as pré-condições e restrições relacionadas. Isso pode surgir das seguintes formas:

Algumas condições não podem ser calculadas usando as construções disponíveis em uma determinada
linguagem ou contexto de execução. Por exemplo, não é possível verificar se um determinado bloqueio está
sendo mantido ou se uma determinada referência é única (ver § 2.3).
Em programas concorrentes, as pré-condições podem ter escopos temporais (neste caso, às vezes são
chamadas de restrições de ativação). Se uma restrição não estiver sob o controle do objeto hospedeiro,
então, mesmo que se saiba que ela é mantida momentaneamente, ela não precisa ser mantida durante o curso
de uma ação que depende dela. Por exemplo, seu lápis pode quebrar enquanto você escreve uma mensagem.
Um sistema de arquivos que é conhecido na entrada de um método como tendo espaço suficiente para
gravar um arquivo pode ficar sem espaço (devido às ações de outros programas independentes) antes
que o método termine de gravar o arquivo. Da mesma forma, o fato de uma determinada máquina remota
estar atualmente disponível não diz nada sobre se ela travará ou se tornará inacessível no decorrer de um
método que depende dela.
Algumas condições mudam devido às ações de sinalização de outras threads. O exemplo mais comum
é o status de cancelamento, que pode se tornar verdadeiro de forma assíncrona enquanto qualquer thread
estiver executando qualquer ação (consulte § 3.1.2).
Algumas restrições são computacionalmente muito caras para verificar, por exemplo, um requisito de que
uma matriz seja normalizada na forma triangular superior. Quando as ações são simples e fáceis de desfazer
ou as chances de falha são extremamente baixas, pode não valer a pena calcular nem mesmo pré-
condições simples, em vez disso, confiar em estratégias de fallback após a detecção posterior da falha.

Em todos esses casos, a falta de provisões que garantam o sucesso força métodos para detectar e lidar com
possíveis falhas para atingir as pós-condições.
Machine Translated by Google

3.1.1 Exceções

Acomodações para falha se infiltram no design de programas multithread. A simultaneidade introduz a possibilidade
de uma parte de um programa falhar enquanto outras continuam. Mas sem cuidado, uma ação com falha pode
deixar os objetos em estados de tal forma que outros encadeamentos não possam ser bem-sucedidos.

Os métodos podem lançar exceções (bem como definir indicadores de status ou emitir notificações) quando detectam
que seus efeitos pretendidos ou pós-condições não podem ser alcançados. Existem seis respostas gerais
para tais ações com falha: encerramento abrupto, continuação (ignorar falhas), reversão, avanço, nova tentativa e
delegação aos manipuladores. Rescisão abrupta e continuação são as duas respostas mais extremas. Rollback
e rollforward são opções intermediárias que garantem que os objetos mantenham estados consistentes.
Novas tentativas contêm pontos de falha localmente. A delegação permite respostas cooperativas a falhas em
objetos e atividades.

As escolhas entre essas opções devem ser acordadas e anunciadas. Às vezes, é possível oferecer suporte a várias
políticas e permitir que o código do cliente decida qual usar, por exemplo, por meio de caixas de diálogo que
perguntam aos usuários se devem tentar novamente a leitura de um disco. Exemplos adicionais dessas opções são
ilustrados ao longo deste livro.

3.1.1.1 Rescisão abrupta

Uma resposta extrema à falha é deixar um método morrer imediatamente, retornando (geralmente por meio de uma
exceção), independentemente do estado do objeto atual ou do status da atividade atual. Isso pode se aplicar se você
tiver certeza de que uma falha local força a falha de toda a atividade e que os objetos envolvidos na atividade nunca
serão usados novamente (por exemplo, se eles estiverem completamente confinados em uma sessão, consulte § 2.3.1) .
Por exemplo, esse pode ser o caso de um componente de conversão de arquivo que falha ao abrir o arquivo a ser
convertido.

A rescisão abrupta também é a estratégia padrão para casos não detectados (e não declarados)
RuntimeExceptions, como NullPointerException, que geralmente indicam erros de
programação. Quando uma falha normalmente recuperável não pode ser tratada, você
pode forçar respostas mais extremas escalando-a para um lançamento de RuntimeException ou Error.

Com exceção do encerramento completo do programa (via System.exit), as opções para recuperação adicional
de tais erros geralmente são muito limitadas. Quando os objetos são intrinsecamente compartilhados entre as
atividades e não há como restabelecer estados de objeto consistentes em caso de falha, e não há maneira possível
(ou prática) de reverter uma operação com falha, o único recurso é definir um valor quebrado ou sinalizador
corrompido no objeto que encontra a falha e, em seguida, termina abruptamente. Esse sinalizador deve fazer com
que todas as operações futuras falhem até que o objeto seja reparado de alguma forma, talvez por meio das ações
de um objeto manipulador de erros.

3.1.1.2 Continuação

Se uma invocação com falha não tiver relação com o estado do objeto chamador ou com os requisitos gerais de
funcionalidade da atividade atual, pode ser aceitável apenas ignorar a exceção e continuar em frente. Embora
normalmente seja muito irresponsável contemplar, esta opção pode ser aplicada em estruturas de eventos e
protocolos de mensagens unidirecionais (consulte § 4.1). Por exemplo, uma invocação com falha de um método de
notificação de alteração em um objeto ouvinte pode, na pior das hipóteses, fazer com que algumas partes de
uma sequência de animação sejam ignoradas, sem nenhuma outra consequência de longo prazo.
Machine Translated by Google

As políticas de continuação também são vistas em outros manipuladores de erro (e dentro da maioria das
cláusulas final ) que ignoram outras exceções incidentais que ocorrem enquanto tentam lidar com a falha que
as disparou, por exemplo, ignorando exceções ao fechar arquivos. Eles também podem ser usados em threads
que nunca devem ser encerradas e, assim, tentar o seu melhor para continuar diante de exceções.

3.1.1.3 Reversão

A semântica mais desejável em projetos otimistas são as garantias de falha limpa: ou a operação é totalmente
bem-sucedida ou falha de uma maneira que deixa o objeto exatamente no mesmo estado em que estava antes
da operação ser tentada. As técnicas de atualização otimista em § 2.4.4.2 demonstram uma forma dessa
abordagem na qual o critério de sucesso é a falta de interferência de outras threads tentando realizar
atualizações.

Existem dois estilos complementares para manutenção de representações de estado que podem ser
usados em rollbacks:

Ação provisória. Antes de tentar atualizações, construa uma nova representação que, após o sucesso, será
trocada pelo estado atual. Os métodos executam atualizações na nova versão provisória das representações de
estado, mas não se comprometem com a nova versão até que o sucesso seja garantido. Dessa forma, nada
precisa ser desfeito em caso de falha.

Ponto de verificação. Antes de tentar atualizações, registre o estado atual do objeto em uma variável de histórico,
talvez na forma de um Memento (consulte o livro Design Patterns). Os métodos realizam atualizações diretamente
na representação atual. Mas em caso de falha, os campos podem ser revertidos para os valores antigos.

A ação provisória geralmente é necessária quando as ações não são totalmente sincronizadas. A ação provisória
elimina a possibilidade de que outros encadeamentos vejam representações inconsistentes e
parcialmente atualizadas. Também é mais eficiente quando as leituras são muito mais comuns do que as gravações.
O ponto de verificação geralmente é mais simples de organizar e, portanto, é preferível em outras situações.
Em qualquer abordagem, nem sempre é necessário criar novos objetos de representação para registrar o
estado: muitas vezes, alguns campos extras no objeto ou variáveis locais dentro dos métodos são suficientes.

As técnicas de reversão específicas da situação são necessárias para ações diferentes das atualizações de
estado que devem ser desfeitas em caso de falha, incluindo ações resultantes do envio de outras mensagens.
Toda mensagem enviada dentro de tal método deve ter uma antimensagem inversa. Por exemplo, uma operação
de crédito pode ser desfeita via débito. Essa ideia pode ser estendida para manter listas de desfazer associadas a
sequências de ações, a fim de permitir a reversão a qualquer ponto.

Alguns tipos de operações não podem ser tentados provisoriamente nem desfeitos por meio de antimensagens
e, portanto, não podem empregar técnicas de reversão. Isso exclui métodos com efeitos visíveis externamente
que mudam irrevogavelmente o mundo real executando IO ou acionando dispositivos físicos, a menos que seja
possível desfazer as ações sem danos. No caso de IO, convenções podem ser adotadas para permitir
o equivalente conceitual de rollback. Por exemplo, se os métodos registram ações em um arquivo de log e o
arquivo de log suporta uma opção "desconsidere a entrada de log XYZ", isso pode ser invocado em caso de falha.

No entanto, conforme discutido mais adiante em § 3.1.2.2, a reversão da maioria dos objetos IO (como
InputStreams) em si normalmente não é possível. Não há métodos de controle para reverter os buffers
internos ou outros campos da maioria dos objetos IO de volta aos valores que eles mantiveram em algum ponto
arbitrário. Normalmente, o melhor que você pode fazer é fechar os objetos IO e construir novos vinculados aos
mesmos arquivos, dispositivos ou conexões de rede.
Machine Translated by Google

3.1.1.4 Roll-forward

Quando a reversão é impossível ou indesejável, mas a continuação completa também é impossível, você pode avançar da
maneira mais conservadora possível para restabelecer algum estado legal e consistente garantido que pode ser diferente
daquele existente na entrada do método. Roll-forward (às vezes conhecido simplesmente como recuperação) geralmente
é perfeitamente aceitável no que diz respeito a outros objetos, métodos e threads; em muitos casos, eles nem
conseguem distingui-lo da reversão.

Algumas dessas ações podem ser colocadas em cláusulasfinal que executam limpeza mínima (por exemplo, fechamento
de arquivos, cancelamento de outras atividades) necessária para alcançar pontos seguros de execução do programa. A
maioria das técnicas de rollforward assume formas semelhantes às técnicas de rollback. Mas, como não exigem
representações completas do estado salvo ou provisório, geralmente são um pouco mais fáceis de organizar.

Alguns métodos podem ser divididos em duas partes conceituais: uma parte preliminar que pode reverter facilmente (por
exemplo, retornando ou lançando novamente a exceção imediatamente) e a parte que ocorre após um ponto sem
retorno, no qual alguma ação irrecuperável já ocorreu. começou, que deve ser avançado para um ponto seguro
mesmo após a falha. Por exemplo, um método pode atingir um ponto em um protocolo no qual uma confirmação deve ser
enviada ou recebida (ver § 3.4.1.4).

3.1.1.5 Repetir

Você pode conter falha local no método atual, em vez de lançar exceções de volta aos clientes, se tiver motivos para acreditar
que a repetição de uma ação será bem-sucedida. Em geral, as novas tentativas só são possíveis quando as opções de
reversão local podem ser aplicadas, de modo que o estado do objeto e o status da atividade permaneçam os mesmos
no início de cada tentativa de repetição.

Táticas baseadas em novas tentativas podem ser usadas quando a falha é devida a outros objetos independentes que
podem ter estado temporariamente ruins ou em estados indesejados; por exemplo, ao lidar com dispositivos IO e
máquinas remotas. Conforme visto em § 2.4.4.2, os métodos de atualização de estado otimista também dependem
tipicamente de novas tentativas, uma vez que é extremamente improvável que os padrões de interferência persistam
indefinidamente. Novas tentativas também são comuns em projetos de votação, por exemplo, aqueles discutidos em §
4.1.5. Variantes de novas tentativas são vistas em algoritmos em cascata que primeiro tentam a mais desejável de
várias ações alternativas e, se isso falhar, tentam uma série de alternativas menos desejáveis até que uma seja bem-sucedida.

Sem cuidado, novas tentativas podem consumir quantidades ilimitadas de tempo de CPU (consulte § 3.2.6). Você pode
minimizar a probabilidade de falhas repetidas baseadas em contenção, bem como reduzir o desperdício de CPU,
inserindo atrasos heurísticos entre as tentativas. Uma estratégia popular (vista, por exemplo, em protocolos Ethernet) é
o backoff exponencial, no qual cada atraso é proporcionalmente maior que o anterior.

Por exemplo, você pode usar o método a seguir para se conectar a um servidor que às vezes recusa conexões
porque está sobrecarregado. O loop de repetição recua por mais tempo após cada falha.
No entanto, ele falha após a interrupção do thread (consulte o § 3.1.2), pois não há sentido em continuar se o thread
atual foi cancelado. (Conforme observado em § 3.1.2.2, em alguns lançamentos do JDK, pode ser necessário modificá-lo
para capturar InterruptedIOException e relançar InterrruptedException.)

class ClientUsingSocket { // ... // Esboço do código

Socket retryUntilConnected() throws InterruptedException { // primeiro atraso é escolhido


aleatoriamente entre 5 e 10 segundos long delayTime = 5000 + (long)(Math.random()
* 5000);
Machine Translated by Google

for (;;) { try


{ return
new Socket(servidor, número da porta);

} catch (IOException ex)


{ Thread.sleep(delayTime);
delayTime = delayTime * 3 / 2 + 1; // aumenta 50% } } }

3.1.1.6 Manipuladores

Chamadas, retornos de chamada ou notificações para objetos de tratamento de erros podem ser úteis
quando você precisa descarregar operações de processamento de erros para manipuladores centralizados
porque uma exceção em um thread ou em uma parte de um sistema requer ações de compensação em
outros threads ou outras partes de um sistema que caso contrário, não seria conhecido pelo método que está
capturando a exceção. Eles também podem ser usados para tornar o código mais extensível e resiliente quando
usados por clientes que não podem esperar saber como responder a falhas. No entanto, alguns cuidados são
necessários ao substituir exceções por callbacks, eventos e técnicas de notificação relacionadas. Quando
eles escapam das regras de fluxo de controle baseadas em pilha de exceções, seu uso pode tornar mais
difícil prever e gerenciar respostas a falhas em diferentes partes de um sistema.

Uma maneira de configurar um manipulador é criar uma classe antes/depois (consulte § 1.4) que lide com as
exceções como sua ação posterior. Por exemplo, suponha que você tenha uma interface que descreva
um serviço que pode lançar uma ServiceException e uma interface que descreva manipuladores para as exceções resultantes.
As implementações de ServiceExceptionHandler servem aqui como objetos Strategy, conforme discutido no
livro Design Patterns. Você pode criar um proxy para uso por clientes que não lidam com
ServiceException . Por exemplo:

interface ServerWithException { void service()


lança ServiceException; }

interface ServiceExceptionHandler { void


handle(ServiceException e); }

class HandledService implementa ServerWithException { final


ServerWithException server = new ServerImpl(); manipulador final
ServiceExceptionHandler = new HandlerImpl();

public void service() { // sem cláusula throw


tente
{ server.service(); } catch

(ServiceException e) { handler.handle(e); }
Machine Translated by Google

}
}

Observe que, embora seja legal declarar que HandledService implementa ServerWithException,
todos os usos que dependem de manipuladores precisariam ser digitados estaticamente para usar HandledService, não o
tipo genérico ServerWithException .

Um objeto manipulador pode executar qualquer ação que qualquer código em uma cláusula catch pode, incluindo encerrar
o processamento em um ou mais threads ou iniciar outros threads de limpeza. A chamada do manipulador também pode, de
alguma forma, comunicar o problema aos recursos de tratamento de erros que ocorrem em um thread diferente, envolver-se
em algum protocolo interativo, lançar novamente a exceção como um RuntimeException ou Error, envolvê-lo em um
InvocationTargetException para indicar falhas em cascata (consulte § 4.3.3.1) , e assim por diante.

Você pode configurar serviços nos quais os clientes sempre usam manipuladores fornecendo argumentos de retorno de
chamada para métodos de serviço. O tratamento baseado em retorno de chamada também pode ser aplicado quando o próprio
serviço nem mesmo sabe qual exceção deve lançar em caso de falha. Isso pode ser configurado através de:

interface ServerUsingCallback { void


anotherservice (ServiceFailureHandler handler); }

Aqui todos os chamadores devem fornecer um destino de retorno de chamada (que pode ser apenas eles mesmos)
para ser invocado em situações excepcionais. Mais detalhes, alternativas e variantes são discutidos em § 4.3.1.

Os manipuladores também podem ser usados ao converter um estilo de protocolo de mensagens para outro (ver § 4.1.1).
Por exemplo, ao usar estruturas baseadas em eventos, um serviço pode gerar e emitir um novo ExceptionEvent
que é processado por um ExceptionEventListener. A seguinte classe ServiceIssuingExceptionEvent mostra uma maneira
de configurar isso. Ele usa o CopyOnWriteArrayList do § 2.4.4 para gerenciar listas de manipuladores.
Alternativamente, os eventos podem ser emitidos de forma assíncrona (ver § 4.1).

class ExceptionEvent extends java.util.EventObject { public final Throwable


theException;

public ExceptionEvent(Object src, Throwable ex) { super(src);


Machine Translated by Google

theException = ex; } }

classe ExceptionEventListener { // Incompleto


public void exceptionOccured(ExceptionEvent ee) {
// ... responde a exceção... } }

classe ServiceIssuingExceptionEvent { // Incompleto


// ...
private final manipuladores CopyOnWriteArrayList = new
CopyOnWriteArrayList();

public void addHandler(ExceptionEventListener h) { handlers.add(h); }

public void service() { // ... if ( /*


falhou
*/ ) {
Throwable ex = new ServiceException();
ExceptionEvent ee = new ExceptionEvent(this, ex);

for (Iterator it = handlers.iterator(); it.hasNext();) { ExceptionEventListener l =


(ExceptionEventListener)(it.next());
l.exceptionOccured(ee);

}
}
}

Um estilo inverso de conversão, de eventos para exceções, é utilizado no pacote java.beans , conforme
descrito em § 3.6.4.

3.1.2 Cancelamento

Quando as atividades em uma thread falham ou mudam de curso, pode ser necessário ou desejável
cancelar atividades em outras threads, independentemente do que estejam fazendo. As solicitações
de cancelamento introduzem condições de falha inerentemente imprevisíveis para threads em
execução. A natureza assíncrona do cancelamento[1] leva a táticas de design que lembram aquelas em
sistemas distribuídos, onde falhas podem ocorrer a qualquer momento devido a travamentos e
desconexões. Programas concorrentes têm a obrigação adicional de garantir estados consistentes de objetos internos que participam

[1]
A grafia de dois l de cancelamento parece ser mais comum na programação concorrente.
Machine Translated by Google

O cancelamento é uma ocorrência natural na maioria dos programas multithread, visto em:

Praticamente qualquer atividade associada a um botão CANCELAR da GUI.


Apresentações de mídia (por exemplo, loops de animação) associadas a atividades normalmente
encerradas.
Threads que produzem resultados que não são mais necessários. Por exemplo, quando vários threads são
usados para pesquisar um banco de dados, uma vez que um thread retorna uma resposta, os outros podem ser cancelados.
Conjuntos de atividades que não podem continuar porque uma ou mais delas encontram erros ou
exceções inesperados.

3.1.2.1 Interrupção

As técnicas com melhor suporte para abordar o cancelamento dependem do status de interrupção por thread[2]
definido pelo método Thread.interrupt, inspecionado por Thread.isInterrupted, limpo (e inspecionado) por
Thread.interrupted e, às vezes, respondido lançando InterruptedException .

[2]
Os recursos de interrupção não eram suportados no JDK 1.0. Mudanças nas políticas e mecanismos entre os lançamentos
são responsáveis por algumas das irregularidades no suporte ao cancelamento.

Interrupções de thread servem como solicitações de cancelamento de atividades. Nada impede ninguém de usar
interrupções para outros propósitos, mas esta é a convenção pretendida. O cancelamento baseado em interrupção
depende de um protocolo entre canceladores e cancelados para garantir que os objetos que podem ser usados em
vários encadeamentos não sejam danificados quando os encadeamentos cancelados terminarem. A maioria
(idealmente todas) classes nos pacotes java.* estão de acordo com este protocolo.

Em quase todas as circunstâncias, o cancelamento da atividade associada a um encadeamento deve fazer com que o
encadeamento seja encerrado. Mas não há nada na interrupção que force o encerramento imediato. Isso dá a qualquer
encadeamento interrompido uma chance de limpar antes de morrer, mas também impõe obrigações para o código de
verificar o status da interrupção e tomar as medidas apropriadas em tempo hábil.

Essa capacidade de adiar ou mesmo ignorar solicitações de cancelamento fornece um mecanismo para escrever
código que é muito responsivo e muito robusto. A falta de interrupção pode ser usada como uma pré-condição
verificada em pontos seguros antes de fazer qualquer coisa que seria difícil ou impossível de desfazer mais tarde. A
gama de respostas disponíveis inclui a maioria das opções discutidas no § 3.1.1:

A continuação (ignorar ou eliminar interrupções) pode ser aplicada a threads que não devem terminar; por
exemplo, aqueles que executam serviços de gerenciamento de banco de dados essenciais para a
funcionalidade básica de um programa. Após a interrupção, a tarefa específica que está sendo executada
pelo thread pode ser abortada, permitindo que o thread continue processando outras tarefas. No entanto,
mesmo aqui, pode ser mais gerenciável substituir o encadeamento por um novo, começando em um estado
inicial bom conhecido.
A finalização abrupta (por exemplo, lançamento de erro) geralmente se aplica a threads que fornecem serviços
isolados que não requerem nenhuma limpeza além daquela fornecida em uma cláusula final na base de um
método run . No entanto, quando as threads estão executando serviços dependentes de outras threads
(consulte § 4.3), elas também devem alertá-las ou definir indicadores de status.
(As próprias exceções não são propagadas automaticamente pelos encadeamentos.)
Técnicas de rollback ou rollforward devem ser aplicadas em threads usando objetos que também
dependem de outros threads.
Machine Translated by Google

Você pode controlar a capacidade de resposta do seu código às interrupções, em parte, decidindo com que
frequência verificar o status por meio de Thread.currentThread().isInterrupted(). As verificações não precisam
ocorrer com frequência especial para serem eficazes. Por exemplo, se forem necessárias cerca de 10.000 instruções
para executar todas as ações associadas ao cancelamento e você verificar o cancelamento a cada 10.000 instruções,
então, em média, seriam necessárias 15.000 instruções no total, desde a solicitação de cancelamento até o desligamento.
Desde que não seja realmente perigoso continuar as atividades, essa ordem de grandeza é suficiente para a maioria
das aplicações. Normalmente, esse raciocínio leva você a colocar o código de detecção de interrupção apenas
naqueles pontos do programa em que é mais conveniente e mais importante verificar o cancelamento. Em aplicativos de
desempenho crítico, pode valer a pena construir modelos analíticos ou coletar medições empíricas para determinar
com mais precisão as melhores compensações entre capacidade de resposta e taxa de transferência (consulte também
o § 4.4.1.7 ).

As verificações de interrupção são executadas automaticamente em Object.wait Thread.join, Thread.sleep e seus


derivados. Esses métodos abortam após a interrupção lançando InterruptedException, permitindo que os
threads sejam ativados e apliquem o código de cancelamento.

Por convenção, o status de interrupção é limpo quando InterruptedException é lançado. Às vezes, isso é necessário para
apoiar os esforços de limpeza, mas também pode ser fonte de erro e confusão.
Quando você precisar propagar o status de interrupção após lidar com uma InterruptedException, deverá lançar
novamente a exceção ou redefinir o status por meio de
Thread.currentThread().interrupt(). Se o código em threads que você criar chamar outro código que não preserva
adequadamente o status de interrupção (por exemplo, ignorando InterruptedException
sem redefinir o status), você poderá contornar os problemas mantendo um campo que lembra o status de cancelamento,
definindo-o sempre que chamar a interrupção e verificar ao retornar dessas ligações problemáticas.

Existem duas situações em que os threads permanecem inativos sem poder verificar o status da interrupção ou
receber InterruptedException: bloqueio em bloqueios sincronizados e em IO. Os threads não respondem às interrupções
enquanto aguardam um bloqueio usado em um método ou bloco sincronizado .
No entanto, conforme discutido em § 2.5, as classes utilitárias de bloqueio podem ser usadas quando você precisa
reduzir drasticamente a possibilidade de ficar preso esperando por bloqueios durante o cancelamento. O
código que usa classes de bloqueio inativo bloqueia apenas para acessar os próprios objetos de bloqueio, mas não
o código que eles protegem. Esses bloqueios são intrinsecamente muito breves (embora os tempos não possam ser estritamente garantidos).

3.1.2.2 IO e revogação de recursos

Algumas classes de suporte de IO (principalmente java.net.Socket e classes relacionadas) fornecem meios


opcionais para expirar em leituras bloqueadas, caso em que você pode verificar a interrupção no tempo limite.

Uma abordagem alternativa é adotada em outras classes java.io , uma forma particular de revogação de recursos.
Se uma thread executar s.close() em um objeto IO (por exemplo, um InputStream) s, qualquer outra thread que tentar usar
s (por exemplo, s.read()) receberá uma IOException. A revogação afeta todos os encadeamentos que
usam os objetos IO fechados e faz com que os objetos IO sejam inutilizáveis. Se necessário, novos objetos IO podem ser
criados para substituí-los.

Isso combina bem com outros usos de revogação de recursos (por exemplo, para fins de segurança). A política
também protege os aplicativos de ter um objeto IO possivelmente compartilhado automaticamente inutilizado pelo
ato de cancelar apenas um dos encadeamentos que o utilizam. A maioria das classes em java.io não falha e não pode
falhar em exceções de E/S. Por exemplo, se uma exceção de E/S de baixo nível ocorrer no meio de uma operação
StreamTokenizer ou ObjectInputStream , não há
Machine Translated by Google

ação de recuperação que preservará as garantias pretendidas. Portanto, por uma questão de política, as JVMs
não interrompem automaticamente as operações de E/S.

Isso impõe uma obrigação adicional ao código que lida com o cancelamento. Se um thread pode estar
executando IO, qualquer tentativa de cancelá-lo no meio de operações IO deve estar ciente do objeto IO que está
sendo usado e deve estar disposto a fechar o objeto IO. Se isso for aceitável, você pode instigar o
cancelamento fechando o objeto IO e interrompendo o thread. Por exemplo:

class CancelávelLeitor { // Thread privado


incompleto readerThread; // apenas um de cada vez suportado private FileInputStream dataFile;

público sincronizado void startReaderThread()


lança IllegalStateException, FileNotFoundException { if (readerThread != null) lança
novo IllegalStateException(); dataFile = new
FileInputStream("dados"); leitorThread
= new Thread(new Runnable() { public void run()
{ doRead(); } }); leitorThread.start(); }

protegido sincronizado void closeFile() { // método utilitário


if (dataFile != null) {
tente { dataFile.close(); } catch
(IOException ignore) {} dataFile = null; }

protected void doRead() { try { while (!

Thread.interrupted()) {
tente
{ int c = dataFile.read(); se (c == -1)
quebrar; senão process(c); }
catch (IOException ex)

{ break; // talvez primeiro faça outra


limpeza } }

} finalmente
{ fecharArquivo();
sincronizado(este) { ReaderThread = null; } }

}
Machine Translated by Google

público sincronizado void cancelReaderThread() {


if (readerThread != nulo) readerThread.interrupt(); fecharArquivo();

}
}

A maioria dos outros casos[3] de E/S cancelada surge da necessidade de interromper threads que aguardam uma
entrada que, de alguma forma, você sabe que não chegará ou não chegará a tempo de fazer algo a respeito. Com a
maioria dos fluxos baseados em soquete, você pode gerenciar isso definindo parâmetros de tempo limite de
soquete. Com outros, você pode confiar em InputStream.available e criar manualmente seu próprio loop de polling
cronometrado para evitar o bloqueio em IO durante um tempo limite (consulte § 4.1.5). Essas construções podem
usar um protocolo de repetição de back-off temporizado semelhante ao descrito em § 3.1.1.5. Por exemplo:

[3] Alguns lançamentos do JDK também suportavam InterruptedIOException, mas foi


implementado apenas parcialmente e apenas em algumas plataformas. No momento em que
este livro foi escrito, as versões futuras são projetadas para descontinuar o suporte,
em parte devido à sua consequência indesejável de tornar os objetos IO inutilizáveis. Mas como
InterruptedIOException foi definido como uma subclasse de IOException, as construções
aqui funcionam aproximadamente conforme descrito em versões que incluem
suporte a InterruptedIOException , embora com uma incerteza adicional: uma interrupção pode
aparecer como InterruptedIOException ou InterruptedException. Uma solução parcial é capturar InterruptedIOException

class ReaderWithTimeout { // ... void // Esboço de código genérico

tryRead(InputStream stream, long timeout) throws... {

long startTime = System.currentTimeMillis(); tente (;;) {

if (stream.available() > 0) {
int c = stream.read(); if (c != -1)
process(c); senão quebrar; // eof }
else { try { Thread.sleep(100); //

tempo
de
back-off fixo arbitrário

} catch (InterruptedException ou seja) {


/* ... termina silenciosamente e retorna ... */ } long now =

System.currentTimeMillis(); if (agora - startTime >= tempo


limite) {
/* ... falhar ...*/
}
}

} } catch (IOException ex) { /* ... falha ... */ } }


Machine Translated by Google

3.1.2.3 Rescisão assíncrona

O método stop foi originalmente incluído na classe Thread, mas seu uso foi obsoleto desde então.
Thread.stop faz com que um thread lance abruptamente uma exceção ThreadDeath , independentemente
do que esteja fazendo. (Como a interrupção, stop não aborta esperas por bloqueios ou
IO. Mas, ao contrário da interrupção, não é estritamente garantido abortar wait, sleep ou join.)

Esta pode ser uma operação arbitrariamente perigosa. Como Thread.stop gera sinais assíncronos, as
atividades podem ser encerradas enquanto estão no meio de operações ou segmentos de código que devem
ser revertidos ou encaminhados para segurança do programa e consistência do objeto. Para um exemplo
genérico simples, considere:

classe C { // Fragmentos
private int v; // invariante: v >= 0

void sincronizado f() { v = -1 ; //


definido temporariamente como valor ilegal como sinalizador compute(); // possível
ponto de parada (*) // definido para valor legal v = 1; }

void sincronizado g() { while (v != 0)


{
--v;
algo(); }

}
}

Se acontecer de um Thread.stop causar o encerramento na linha (*), o objeto será interrompido: Após o
encerramento do thread, ele permanecerá em um estado inconsistente porque a variável v está definida com um valor ilegal.
Quaisquer chamadas ao objeto de outros encadeamentos podem fazer com que ele execute ações indesejadas
ou perigosas. Por exemplo, aqui o loop no método g irá girar 2*Integer.MAX_VALUE vezes enquanto v envolve
os negativos.

O uso de stop torna extremamente difícil aplicar técnicas de recuperação rollback ou rollforward.
À primeira vista, esse problema pode não parecer tão sério afinal, qualquer exceção não capturada lançada por
que a chamada para computar também corromperia o estado. No entanto, os efeitos de Thread.stop são mais
insidiosos, pois não há nada que você possa fazer nesses métodos para eliminar a exceção
ThreadDeath (lançada por Thread.stop) enquanto ainda propaga solicitações de cancelamento. Além disso,
a menos que você coloque um catch(ThreadDeath) após cada linha de código, não será possível reconstruir o
estado do objeto atual com precisão suficiente para recuperá-lo e, portanto, poderá ocorrer corrupção não
detectada. Por outro lado, geralmente você pode fazer um código à prova de balas para eliminar ou lidar com
outros tipos de exceções de tempo de execução sem tais esforços heróicos.

Em outras palavras, o motivo para desaprovar Thread.stop não foi corrigir sua lógica defeituosa, mas
corrigir erros de julgamento sobre sua utilidade. É humanamente impossível escrever todos os métodos de
forma a permitir que uma exceção de cancelamento ocorra em cada bytecode. (Este fato é bem conhecido dos desenvolvedores de
Machine Translated by Google

código de sistema operacional de baixo nível. Programar até mesmo aquelas poucas e muito curtas rotinas que devem ser
seguras para cancelamento de sincronização pode ser uma grande tarefa.)

Observe que qualquer método em execução tem permissão para capturar e ignorar a exceção ThreadDeath lançada por
stop. Assim, stop não é mais garantido para encerrar um thread do que interrupção, é apenas mais perigoso. Qualquer
uso de parar reflete implicitamente uma avaliação de que o dano potencial de tentar encerrar abruptamente uma atividade é
menor do que o dano potencial de não fazê-lo.

3.1.2.4 Controle de recursos

O cancelamento pode desempenhar um papel no projeto de qualquer sistema que carregue e execute código estrangeiro.
As tentativas de cancelar o código que não está em conformidade com os protocolos padrão enfrentam um problema difícil. O
código pode simplesmente ignorar todas as interrupções e até mesmo capturar e descartar exceções ThreadDeath , caso em que
invocações de Thread.interrupt e Thread.stop não terão efeito.

Você não pode controlar exatamente o que o código estrangeiro faz ou por quanto tempo ele faz. Mas você pode e deve
aplicar medidas de segurança padrão para limitar efeitos indesejáveis. Uma abordagem é criar e usar um SecurityManager e
classes relacionadas que negam todas as solicitações de recursos verificadas quando um thread é executado por muito tempo.
(Os detalhes vão além do escopo deste livro; consulte Leituras Adicionais.) Essa forma de negação de recurso, em conjunto com
as estratégias de revogação de recurso discutidas no § 3.1.2.2, pode impedir que o código estrangeiro tome quaisquer ações que
possam disputar recursos com outros tópicos que devem continuar. Como um subproduto, essas medidas geralmente causam
falhas nos threads devido a exceções.

Além disso, você pode minimizar a contenção de recursos da CPU invocando


setPriority(Thread.MIN_PRIORITY) para um encadeamento. Um SecurityManager pode ser usado para impedir que o thread
aumente novamente sua prioridade.

3.1.2.5 Cancelamento Multifásico

Às vezes, até mesmo um código comum deve ser cancelado com um prejuízo mais extremo do que você normalmente
gostaria. Para lidar com essas possibilidades, você pode configurar um recurso genérico de cancelamento multifásico
que tenta cancelar tarefas da maneira menos perturbadora possível e, se elas não terminarem logo, tenta uma técnica mais
disruptiva.

O cancelamento multifásico é um padrão observado no nível do processo na maioria dos sistemas operacionais. Por exemplo, é
usado em desligamentos do Unix, que primeiro tentam encerrar tarefas usando kill -1, seguido se necessário por kill -9. Uma
estratégia análoga é usada pelos gerenciadores de tarefas na maioria dos sistemas de janelas.

Aqui está um esboço da versão de amostra. (Mais detalhes sobre o uso de Thread.join vistos aqui podem ser encontrados em
§ 4.3.2.)

classe Terminator {

// Tente matar; retorna true se souber que está morto

static boolean Terminate(Thread t, long maxWaitToDie) {

if (!t.isAlive()) retorna verdadeiro; // já morto


Machine Translated by Google

// fase 1 -- cancelamento normal

t.interrupt(); tente
{ t.join(maxWaitToDie); }
catch(InterruptedException e){} // ignora

if (!t.isAlive()) retorna true; // sucesso

// fase 2 -- captura todas as verificações de segurança

theSecurityMgr.denyAllChecksFor(t); // um método inventado try


{ t.join(maxWaitToDie); }
catch(InterruptedException ex) {}

if (!t.isAlive()) retorna true;

// fase 3 -- minimizar o dano

t.setPriority(Thread.MIN_PRIORITY); retorna falso; }

Observe aqui que o próprio método de encerramento ignora as interrupções. Isso reflete a escolha da política de que as tentativas
de cancelamento devem continuar depois de iniciadas. Caso contrário, cancelar um cancelamento causa problemas ao lidar
com o código que já iniciou a limpeza relacionada ao encerramento.

Devido às variações no comportamento de Thread.isAlive em diferentes implementações de JVM (consulte § 1.1.2), é


possível que esse método retorne true antes que todos os vestígios do encadeamento morto tenham desaparecido.

3.1.3 Leituras Adicionais


Uma conta baseada em padrões de tratamento de exceções pode ser encontrada em:

RENZEL, Klaus. "Error Detection", em Frank Buschmann e Dirk Riehle (eds.) Proceedings of the 1997 European Pattern
Languages of Programming Conference, Irsee, Germany, Siemens Technical Report 120/SW1/FB, 1997.

Algumas técnicas de baixo nível para proteger o código de cancelamento ou interrupção assíncrona (por exemplo, mascarar
interrupções de hardware) não estão disponíveis ou não são apropriadas na linguagem de programação Java. Mas mesmo
muitos desenvolvedores de nível de sistema evitam o cancelamento assíncrono a todo custo. Veja, por exemplo, o livro
de Butenhof listado em § 1.2.5. Preocupações semelhantes são expressas sobre programas orientados a objetos
simultâneos em:

Fleiner, Claudio, Jerry Feldman e David Stoutamire. "Killing Threads Considered Dangerous", Proceedings of the POOMA
'96 Conference, 1996.
Machine Translated by Google

Detectar e responder ao término de um grupo de threads pode exigir protocolos mais complexos quando
aplicados em contextos menos estruturados do que os vistos na maioria dos programas simultâneos. Algoritmos
de detecção de terminação de propósito geral são discutidos em várias das fontes sobre programação concorrente
e distribuída listadas em § 1.2.5.

O gerenciamento de segurança é descrito em:

Gong, Li. Inside Java 2 Platform Security, Addison-Wesley, 1999.

Uma estrutura de controle de recursos é descrita em:

Czajkowski, Grzegorz e Thorsten von Eicken. "JRes: A Resource Accounting Interface for Java", Proceedings of
1998 ACM OOPSLA Conference, ACM, 1998.

3.2 Métodos Protegidos

Os métodos conservadores de verificar e agir se recusam a realizar ações, a menos que possam garantir que
essas ações serão bem-sucedidas, em parte verificando primeiro suas pré-condições. Os três tipos básicos refletem
as decisões políticas em torno das pré-condições que falharam:

Recuando. Lançar uma exceção se a pré-condição falhar. As exceções lançadas são conceitualmente diferentes
daquelas vistas em métodos otimistas: aqui, uma exceção indica recusa, não falha. Mas essas exceções geralmente
têm as mesmas consequências para os clientes.

Suspensão vigiada. Suspender a invocação do método atual (e seu encadeamento associado) até que a pré-
condição se torne verdadeira.

Tempo limite. O intervalo de casos entre impedimento e suspensão, em que um limite superior é colocado
sobre quanto tempo esperar para que a pré-condição se torne verdadeira.

Não existe uma escolha de política universalmente melhor entre essas opções. Conforme ilustrado no § 3.4.1,
muitas vezes é possível criar vários métodos que suportam várias políticas entre as quais os clientes podem escolher.

O balking é comum em programas sequenciais e simultâneos. A recusa é a única estratégia sensata quando não há
razão para acreditar que uma pré-condição se tornará verdadeira se já não for verdadeira.
Por exemplo, Thread.start lança IllegalThreadStateException se um thread já estiver iniciado (consulte § 1.1.2),
pois ele nunca mais poderá entrar em um estado não iniciado depois de iniciado. A recusa também é a melhor
escolha para quase todas as pré-condições baseadas em argumentos. Por exemplo, um método gráfico pode
lançar um IllegalArgumentException quando solicitado a desenhar algo com tamanho negativo. Balking também é útil
sempre que um método se destina a ter semântica agora ou nunca associada
Machine Translated by Google

com a disponibilidade de recursos. Quando a recusa não é considerada excepcional, um método de impedimento não
precisa lançar uma exceção. Isso é visto, por exemplo, no método ParticleApplet.stop em § 1.1.1.3, que silenciosamente
ignora as tentativas de parar o applet se ele não estiver em execução.

3.2.1 Suspensão Protegida

Suspensão vigiada e tempos limite não têm analogia em programas sequenciais, mas desempenham papéis centrais
em software concorrente. Isso se reflete na ampla gama de abordagens para conceituar guardas e nas muitas notações
e construções disponíveis para projetar software concorrente usando guardas. Antes de aprofundar as questões
de implementação, vale a pena voltar para considerar abordagens e construções de nível superior que ajudam a organizar
projetos que dependem da suspensão protegida.

Como forragem, considere o seguinte exemplo de Toy BoundedCounter , expresso por enquanto apenas como uma
interface. A ideia aqui é que as implementações de BoundedCounter são obrigadas a manter uma contagem entre MIN e
MAX:

interface BoundedCounter {
final estático longo MIN = 0; // valor mínimo permitido static final long MAX =
10; // valor máximo permitido

contagem longa(); // INV: MIN <= count() <= MAX


// INIT: contagem() == MIN

void inc(); // só permitido quando count() < MAX

void dez(); // somente permitido quando count() > MIN

3.2.1.1 Guardas

Em certo sentido, os métodos protegidos são extensões personalizáveis dos métodos sincronizados , fornecendo formas
estendidas de exclusão. A "guarda" para um método sincronizado simples é apenas que um objeto está no estado de
execução Pronto; ou seja, não está envolvido em nenhuma atividade. No nível de implementação, isso significa que o
thread atual possui o bloqueio de sincronização do objeto. Os métodos protegidos particionam ainda mais o estado
Pronto adicionando condições baseadas em estado (por exemplo, que count() < MAX) que são logicamente necessárias
para que uma ação prossiga.

Os guardas também podem ser considerados como formas especiais de condicionais. Em programas
sequenciais, uma instrução if pode verificar se uma condição é válida na entrada de um método. Quando a condição é
falsa, não adianta esperar que seja verdadeira; ela nunca pode se tornar verdadeira, pois nenhuma outra
atividade simultânea poderia fazer com que a condição mudasse. Mas em programas simultâneos, mudanças
de estado assíncronas podem acontecer o tempo todo.

Os métodos protegidos, portanto, apresentam problemas de vivacidade que os condicionais simples não
encontram. Qualquer guarda afirma implicitamente que, eventualmente, algum(ns) outro(s) encadeamento(s) fará com
que as mudanças de estado necessárias ocorram ou, se não o fizerem, seria melhor nunca prosseguir com a atividade
atual. Os tempos limite são uma forma de suavizar tais afirmações, usando uma política de impedimento como um backup se a espera continuar mu
Machine Translated by Google

Alguns métodos de design de alto nível expressam esperas condicionais usando uma construção semelhante a if
chamada WHEN (também conhecida como AWAIT) que pode ser útil no design de métodos protegidos. Por
exemplo, aqui está uma versão em pseudocódigo da classe contador usando WHEN:

pseudoclass BoundedCounterWithWhen { // Pseudo-código


contagem longa protegida = MIN;

public long count() { return count; }

public void inc() {


QUANDO (contagem < MAX) { +
+contagem; }

public void dez()


QUANDO (contagem > MIN) { --
contagem;
}

}}

As construções WHEN aqui expressam a ideia de que o BoundedCounter é obrigado a manter a contagem entre
MIN e MAX. Se uma mensagem dec for recebida, mas a contagem não puder ser diminuída porque já está em MIN,
o thread será bloqueado, retomando algum tempo depois se e quando a contagem se tornar maior que MIN por
meio de uma mensagem inc invocada por algum método em execução em algum outro thread.

3.2.1.2 Aceitação de mensagem baseada em estado

As ações em métodos protegidos são acionadas somente quando uma determinada mensagem é recebida e o objeto
está em um determinado estado. Como nem a mensagem nem o estado são necessariamente primários, você pode
criar versões abstratas de métodos com as duas partes invertidas. Esse estilo baseado em estado pode ser
mais fácil de usar ao projetar classes nas quais vários métodos diferentes são acionados nos mesmos estados,
por exemplo, quando o objeto está assumindo uma função específica. Essa forma também reflete mais
claramente as notações baseadas em estado usadas em vários métodos populares de projeto e análise OO de alto nível.

As construções de simultaneidade Ada podem ser usadas para definir métodos dessa maneira. Expresso em
pseudocódigo semelhante ao Ada, o BoundedCounter é:

pseudoclass BoundedCounterWithAccept { contagem longa // Pseudo-código


protegida = MIN;

WHEN (true) ACEITAR public long count() { return count; }

QUANDO (contagem < MAX) ACEITAR public void inc() {


++contar;
Machine Translated by Google

QUANDO (contagem > MIN) ACEITAR public void dec() {


--contar;

}}

Indo ao extremo, alguns projetos são mais fáceis de raciocinar se você pensar em ações como sendo
sempre solicitadas, mas acionadas apenas quando o objeto faz uma transição de estado específica.
Alguns métodos de loop assumem esta forma. Por exemplo, você pode criar um contador especial com um
mecanismo ativado continuamente que redefine a contagem para zero sempre que atinge um limite. Às
vezes, esse estilo é chamado de programação de restrição simultânea, em que as ações podem ser
acionadas apenas por mudanças de estado, pois não há mensagens.

3.2.1.3 Definindo o estado de controle lógico

Muitos objetos mantêm atributos que juntos constituem um espaço de estado muito grande (ou, para todos os efeitos
práticos, infinito), mas mantêm apenas um pequeno espaço de estado lógico para fins de proteção de ações. Por exemplo,
para fins de inc e dec, BoundedCounters tem apenas três estados lógicos, não um estado por valor de sua contagem:

Estado Doença Inc. dezembro

principal contagem == MAX não sim


meio MIN < contagem < MAX sim sim
fundo contagem == MIN sim não

Um pouco de cuidado é necessário na caracterização desses estados. Por exemplo, se MAX for igual a
MIN+1, não há um estado intermediário distinto. E se MIN for igual a MAX, não há como distinguir o topo
do fundo: nenhum dos métodos deve ser acionado.
Machine Translated by Google

Conforme visto na tabela, os estados lógicos são normalmente definidos em termos de predicados, expressões
booleanas que distinguem determinados intervalos, valores e/ou outras propriedades computáveis de campos.
Eles podem ser codificados como métodos booleanos internos autônomos ou simplesmente como condições
booleanas escritas dentro de métodos que dependem deles. Quando a análise de estado se torna muito grande e
difícil de manejar para tais técnicas, você pode projetar e codificar estados usando StateCharts, tabelas, árvores
de decisão, autômatos e ferramentas relacionadas ao comércio para lidar com máquinas de estado (consulte as Leituras Adicionais em § 1.3.5

Em vez de depender de expressões predicadas, você pode representar o estado lógico explicitamente em uma variável.
Cada estado distinto pode ser rotulado como um número inteiro ou qualquer outro tipo de dados discreto. O
campo que representa o estado deve ser reavaliado a cada atualização para que seja sempre preciso (ver § 3.3.1).
Não é estritamente necessário usar uma única variável, múltiplas variáveis podem ser usadas se o estado do
objeto puder ser particionado em várias dimensões independentes. Casos especiais incluem:

As variáveis de função controlam as respostas a todos os métodos relacionados (geralmente aqueles


declarados em uma única interface). Quando os objetos podem alternar entre papéis, uma única variável
é suficiente para direcionar o comportamento apropriado. Por exemplo, um objeto pode alternar entre ser
um produtor e um consumidor. Quando em uma função, pode ignorar ou atrasar as respostas às
mensagens associadas à outra.
Em vez de codificar o estado como um valor, você pode codificá-lo como uma referência a um objeto de
estado. Para cada estado, você pode escrever uma classe que descreva o comportamento do objeto
quando estiver naquele estado. A classe principal contém um campo de referência, digamos stateObject,
que está sempre vinculado ao delegado apropriado. Esta é uma aplicação do padrão States as
Objects do livro Design Patterns; uma variante é descrita no § 3.7.2.
Machine Translated by Google

3.2.2 Mecânica do Monitor

Existem pelo menos tantas maneiras de implementar métodos protegidos quanto maneiras
de projetá-los. Mas quase todas essas técnicas podem ser consideradas especializações
da seguinte estratégia que emprega os métodos Object.wait, Object.notify e Object.notifyAll:

Para cada condição que precisa ser aguardada, escreva um loop de espera protegida que faça com que o
thread atual seja bloqueado se a condição de proteção for falsa.
Certifique-se de que todos os métodos que causam alterações de estado que afetam o valor verdadeiro de qualquer
condição esperada notifiquem os encadeamentos que aguardam alterações de estado, fazendo com que eles acordem
e verifiquem novamente suas condições de guarda.

Como preliminar para discutir essas técnicas, aqui está um resumo das propriedades dos métodos de espera e
notificação.

Da mesma forma que todo Objeto tem um bloqueio (ver § 2.2.1), todo Objeto tem um wait set que é manipulado apenas
pelos métodos wait, notify, notifyAll e Thread.interrupt.
As entidades que possuem bloqueios e conjuntos de espera são geralmente chamadas de monitores (embora quase
todas as linguagens definam os detalhes de maneira um pouco diferente). Qualquer objeto pode servir como um monitor.

A espera definida para cada objeto é mantida internamente pela JVM. Cada conjunto contém encadeamentos bloqueados
por espera no objeto até que as notificações correspondentes sejam invocadas ou as esperas sejam liberadas.

Devido à maneira como os conjuntos de espera interagem com os bloqueios, os métodos wait, notify e notifyAll
podem ser invocados apenas quando o bloqueio de sincronização é mantido em seus destinos.
A conformidade geralmente não pode ser verificada em tempo de compilação. O não cumprimento faz com que essas
operações lancem uma IllegalMonitorStateException no tempo de execução.

As ações desses métodos são as seguintes:

Espere. Uma chamada de espera resulta nas seguintes ações:

Se o thread atual foi interrompido, o método sai imediatamente, lançando um InterruptedException. Caso
contrário, o thread atual é bloqueado.
A JVM coloca o encadeamento no conjunto de espera interno e inacessível associado ao objeto de destino.

O bloqueio de sincronização para o objeto de destino é liberado, mas todos os outros bloqueios mantidos
pelo thread são retidos. Uma liberação total é obtida mesmo se o bloqueio for mantido reentrante devido a
chamadas sincronizadas aninhadas no objeto de destino. Após a retomada posterior, o status de
bloqueio é totalmente restaurado.

Notificar. Uma invocação de notificação resulta nas seguintes ações:

Se existir, um encadeamento escolhido arbitrariamente, digamos T, é removido pela JVM do conjunto de


espera interno associado ao objeto de destino. Não há garantia sobre qual segmento de espera será
selecionado quando o conjunto de espera contém mais de um segmento, consulte § 3.4.1.5.
T deve obter novamente o bloqueio de sincronização para o objeto de destino, o que sempre fará com que ele
seja bloqueado pelo menos até que o thread que chama a notificação libere o bloqueio. Ele continuará
bloqueando se algum outro thread obtiver o bloqueio primeiro.

T é então retomado do ponto de sua espera.


Machine Translated by Google

NotifyAll. Um notifyAll funciona da mesma forma que o notify , exceto que as etapas ocorrem (na verdade,
simultaneamente) para todos os encadeamentos no conjunto de espera para o objeto. No entanto, como eles devem
adquirir o bloqueio, os encadeamentos continuam um de cada vez.

Interromper. Se Thread.interrupt for invocado para um encadeamento suspenso em espera, a mesma mecânica
de notificação se aplica, exceto que após readquirir o bloqueio, o método lança uma InterruptedException
e o status de interrupção do encadeamento é definido como falso. Se uma interrupção e uma notificação
ocorrerem ao mesmo tempo, não há garantia sobre qual ação tem precedência, então qualquer um dos resultados é
possível. (Futuras revisões do JLS podem introduzir garantias determinísticas sobre esses resultados.)

Esperas cronometradas. As versões temporizadas do método wait , wait(long msecs) e wait(long


msecs, int nanossecs), usam argumentos que especificam o tempo máximo desejado para permanecer
no conjunto de espera. Eles operam da mesma forma que a versão sem tempo, exceto que, se
uma espera não for notificada antes de seu limite de tempo, ela será liberada automaticamente. Não
há indicação de status que diferencie as esperas que retornam por meio de notificações versus
tempos limite. Contra-intuitivamente, wait(0) e wait(0, 0) têm o significado especial de serem equivalentes a um wait() sem tem

Uma espera cronometrada pode retomar uma quantidade de tempo arbitrária após o limite solicitado devido
à contenção de encadeamento, políticas de agendamento e granularidades do cronômetro. (Não há garantia sobre
granularidade. A maioria das implementações de JVM observou tempos de resposta na faixa de 1 a 20 ms para
argumentos menores que 1 ms.)

O método Thread.sleep(long msecs) usa uma espera cronometrada, mas não vincula o bloqueio de sincronização do
objeto atual. Ele age como se fosse definido como:

se (msegs != 0) {
Objeto s = new Objeto();
sincronizado(s) { s.wait(msecs); }
}

É claro que um sistema não precisa implementar o sono exatamente dessa maneira. Observe que sleep(0) pausa por
pelo menos nenhum tempo, o que quer que isso signifique.

Para ilustrar algumas das mecânicas subjacentes, considere a seguinte classe inútil que emite cegamente wait e
notifyAll:

classe X {
sincronizado void w() lança InterruptedException { before(); espere(); depois(); }
void sincronizado n() { notifyAll(); } void

antes() {} void after() {} }

Aqui está um resultado possível de três threads invocando métodos em um x comum. Observe que, embora T1
tenha começado a esperar antes de T2, T2 recomeçou antes de T1. Poderia ter sido diferente; não há garantias.
Machine Translated by Google

3.2.3 Esperas Protegidas

O idioma de codificação padrão para expressar métodos protegidos é um loop while simples chamando wait.
Por exemplo, o método inc de uma implementação BoundedCounter pode começar como:

sincronizado void inc() lança InterruptedException {


while (contagem >= MAX) espera(); +
+contar; // ...

Para garantir que os guardas sejam implementados corretamente, às vezes é útil encapsular cada guarda
em seu próprio método. Para um exemplo genérico:

classe ClasseGuardada { // Esboço de código genérico


booleano protegido cond = false;

// PRE: bloqueio mantido


protegido void awaitCond() throws InterruptedException {
while (!cond) espera(); }
Machine Translated by Google

público sincronizado void guardedAction() {


tente
{awaitCond(); }

catch (InterruptedException ie) { // falha

// ações }

As verificações de condição devem ser colocadas em loops while . Quando uma ação é retomada, a tarefa
em espera não sabe se a condição que está esperando é realmente verdadeira; ele apenas sabe que foi
acordado. Portanto, para manter as propriedades de segurança, ele deve verificar novamente.

Por uma questão de prática de programação, esse estilo deve ser usado mesmo que a classe contenha apenas
uma única instância de espera que espera por uma única condição. Nunca é aceitável escrever código que assume
que um objeto está em algum estado específico quando ele é retomado após uma determinada espera. Um
motivo é que esse código pode falhar apenas porque algum outro objeto não relacionado invocou notify ou
notifyAll no objeto de destino por engano. (Estes são métodos públicos definidos em todos os objetos.) Além
disso, é aconselhável evitar quebras no caso de ativações espúrias nas quais esperas são liberadas pelo sistema
sem qualquer chamada explícita a um método de notificação[4]. No entanto, uma consideração mais importante
é que, sem reavaliação, esse código começará a falhar de maneiras peculiares se as pessoas definirem métodos
adicionais (talvez em subclasses de sua classe) que também usam esperas e notificações para outros fins.

[4]
No momento em que este livro foi escrito, o JLS não reconhece especificamente que ativações espúrias podem ocorrer.
No entanto, muitas implementações de JVM são construídas usando rotinas do sistema (por exemplo, bibliotecas de
encadeamento POSIX) nas quais ativações espúrias são permitidas e são conhecidas por ocorrer.

Objetos com esperas protegidas podem ser mais difíceis de pensar do que objetos simples totalmente
sincronizados (§ 2.2.2). Métodos com esperas protegidas não são completamente atômicos. Um método em
espera é suspenso sem reter seu bloqueio de sincronização, permitindo assim que qualquer outro
thread comece a executar qualquer método sincronizado naquele objeto. (E, como sempre, outros métodos
não sincronizados ainda podem ser executados a qualquer momento.)

Os métodos protegidos, portanto, precisam ser escritos para que os objetos estejam em estados consistentes ao entrarem em espera.
A melhor estratégia para garantir isso decorre da ideia geral de políticas de checar e agir. Se você
colocar uma espera protegida como a primeira instrução em um método e não alterar o estado no
processo de verificação, não poderá ter alterado o estado de maneira inconsistente ao inserir a espera.

3.2.3.1 Esperas interrompidas

Uma espera será interrompida (ou não será iniciada) se o thread em espera for interrompido. Isso permite que
threads bloqueados respondam ao cancelamento de thread. Nesse sentido, os métodos protegidos são
semelhantes aos métodos try-and-see que tentam passar as pré-condições podem falhar e as políticas e
implementações de falha descritas no § 3.1 se aplicam.

De longe, a política mais comum aplicada em métodos protegidos é apenas relançar


InterruptedException para indicar falha ao cliente, que precisará lidar com isso.
Machine Translated by Google

Supondo que as esperas protegidas apareçam no início dos métodos, nenhuma limpeza local adicional é
necessária.

A prática rotineira de relançar InterruptedException (ou, no caso usual, simplesmente não capturá-la) e, portanto, incluir
throws InterruptedException em assinaturas de método também serve como uma declaração declarativa simples de que
um método emprega uma espera protegida ou derivada. Esta pode ser uma informação vital para potenciais usuários de
uma classe (ver especialmente § 3.3.4).

3.2.4 Notificações

As construções baseadas em espera constituem a maior parte do lado da segurança da implementação da guarda.
Para garantir a vivacidade, as classes também devem conter código para ativar os threads em espera quando as
condições pelas quais eles estão esperando mudam. Toda vez que o valor de qualquer campo mencionado em uma
guarda muda de forma que possa afetar o valor verdadeiro da condição, as tarefas em espera devem ser ativadas
para que possam verificar novamente as condições de guarda.

A maneira mais simples de fazer com que as threads bloqueadas verifiquem novamente as condições é
inserir notifyAll nos métodos que causam alterações de estado relevantes. Por sua vez, a maneira mais simples de fazer
isso é definir métodos utilitários que encapsulam a atribuição, emitindo uma notificação a cada mudança de valor.
Isso pode levar a sinais inúteis e baixo desempenho (devido à sobrecarga de troca de contexto) em classes que executam
muitas tarefas. No entanto, como prática de design, ocasionalmente é uma boa ideia começar usando notificações gerais
nos métodos de atribuição e, em seguida, minimizá-los e reorganizá-los, conforme discutido nas seções posteriores deste
capítulo. Por exemplo, aqui está uma primeira passagem em BoundedCounter:

class SimpleBoundedCounter { contagem


longa protegida = MIN;

contagem longa sincronizada pública() { return contagem; }

público sincronizado void inc () lança InterruptedException {


awaitUnderMax();
setCont(conta + 1);
}

público void dec() sincronizado lança InterruptedException { awaitOverMin();


setCount(contagem -
1); }

protected void setCount(long newValue) { // PRE: lock hold


contagem = novoValor;
notificarTodos(); // ativa qualquer thread dependendo do novo valor }

void protegido awaitUnderMax() lança InterruptedException { while (count == MAX) wait(); }

void protegido awaitOverMin() lança InterruptedException {


Machine Translated by Google

while (contagem == MIN) espera(); } }

3.2.4.1 Condições escorregadias e sinais perdidos

Em SimpleBoundedCounter, as chamadas para awaitUnderMax e setCount no método inc são executadas


no mesmo escopo de bloqueio sincronizado. Não seria suficiente sincronizar separadamente
awaitUnderMax e setCount , mas não inc. Isso pode encontrar uma violação de segurança. Expandindo-
os:

void badInc() throws InterruptedException { // Não use


synced(this) { while (count >= MAX) wait(); } // (*)synced(this) { ++count;

notificarTodos(); }
}

Esta versão pode encontrar uma condição escorregadia na qual a condição muda devido às ações de algum
outro encadeamento em execução no ponto (*) entre o momento em que o bloqueio é liberado após a espera
e, em seguida, readquirido antes de incrementar a contagem. Isso pode resultar na execução da ação mesmo
se a guarda agora for falsa, possivelmente quebrando o objeto fazendo com que a invariante necessária se torne falsa.

Além disso, uma falha de vivacidade pode ocorrer se setCount for escrito de maneira não atômica, em
particular como:

void badSetCount(long newValue) { // Não use


sincronizado(este) { notifyAll(); } // (**)syncd(this)
{ count =
newValue; }
}

Aqui, o método primeiro adquire o bloqueio para executar notifyAll, depois o libera e o recupera para
alterar a contagem. Isso pode resultar em um sinal perdido: um thread em execução no ponto (**) pode
começar a esperar depois que o sinal destinado a ativá-lo for emitido, mas antes que a condição seja
alterada. Este segmento irá esperar para sempre, ou pelo menos até que a próxima notificação seja produzida de alguma forma.

Observe que dentro dos métodos sincronizados , a ordem em que um notifyAll é colocado não importa.
Nenhum encadeamento ativado poderá continuar até que o bloqueio de sincronização seja liberado. Por uma
questão de estilo, a maioria das pessoas coloca as notificações por último nos corpos dos métodos.

Os erros que levam a sinais perdidos e condições escorregadias ilustrados aqui podem parecer inverossímeis.
Mas eles podem ser fontes comuns de erro em projetos que fazem uso mais extensivo de técnicas de
espera e notificação (ver, por exemplo, § 3.7.2).

3.2.4.2 Notificações únicas

A classe SimpleBoundedCounter usa notifyAll porque os encadeamentos podem estar esperando que a
contagem seja maior que MIN ou menor que MAX. Não seria suficiente aqui usar o notify, que ativa
apenas um thread (se houver). A JVM pode escolher um encadeamento esperando por uma condição que
não é mantida sem escolher os possíveis muitos que poderiam continuar. Isso pode acontecer, por
Machine Translated by Google

por exemplo, se houver vários threads, todos tentando incrementar e vários tentando decrementar.
(Considere, por exemplo, o caso em que MAX == MIN+1.)

No entanto, em algumas outras classes, você pode reduzir a sobrecarga de alternância de contexto associada às
notificações usando uma única notificação em vez de notifyAll. Notificações únicas podem ser usadas para melhorar o
desempenho quando você tiver certeza de que no máximo um thread precisa ser ativado. Isso se aplica quando:

Todos os threads de espera possíveis estão necessariamente esperando por condições que dependem
das mesmas notificações, geralmente exatamente a mesma condição.
Cada notificação intrinsecamente permite que no máximo um único thread continue. Assim, seria inútil acordar
os outros.
Você pode acomodar incertezas em torno da possibilidade de que uma interrupção e uma notificação ocorram
aproximadamente ao mesmo tempo. Nesse caso, o thread que foi notificado está prestes a ser encerrado. Você
pode querer que outro encadeamento receba notificação, mas isso não é organizado automaticamente. (O
problema não surge com notifyAll , pois todos os encadeamentos são ativados.)

Para ilustrar a relação entre notify e notifyAll, a classe GuardedClassUsingNotify a seguir usa a
notificação para aproximar os efeitos de notifyAll adicionando instrumentação a métodos auxiliares que
encapsulam guardas. Aqui, adicionar uma variável de estado de execução para rastrear o número de encadeamentos em
espera permite a construção de um loop que transmite uma notificação para todos os encadeamentos em espera,
simulando assim notifyAll (embora apenas aproximadamente notifyAll seja uma operação interna primitiva).

A cláusula catch de aparência estranha vista aqui garante que, se um thread cancelado receber uma notificação, ele
retransmitirá essa notificação para algum outro thread em espera (se houver). Essa proteção não é realmente necessária
aqui, pois todos os threads em espera estão sendo ativados de qualquer maneira, mas a técnica deve ser empregada em
qualquer código que use a notificação , na qual as interrupções não encerram necessariamente um programa inteiro.

Observe que a chamada extra para notificar dentro da cláusula catch pode fazer com que a contagem de encadeamentos em
espera superestime o número de notificações necessárias para ativar todos os encadeamentos. Isso, por sua vez, pode
fazer com que mais do que o número mínimo de chamadas a serem notificadas sejam emitidas. Esse fato ressalta a
necessidade de colocar esperas em loops de guarda, mesmo ao usar o notify.

classe GuardedClassUsingNotify {
booleano protegido cond = false; protegido
int nEsperando = 0; // conta threads em espera

void sincronizado protegido awaitCond() lança


InterruptedException {
while (!cond) { +
+nEsperando; // registra o fato de que uma thread está esperando
tente
{espera();

} catch (InterruptedException ou seja) { notify();


jogar ou seja; // retransmite para thread não cancelada
Machine Translated by Google

} finalmente
{ --nAguardando; // não espera mais }

}}

void sincronizado protegido signalCond() {


if (cond) { for // simula o notifyAll
(int i = nWaiting; i > 0; --i) notify();

}}

Em designs abertos e extensíveis (ver § 1.3.4), as condições sob as quais notificar se aplicam são bastante
especiais e frágeis. O uso de notificação e otimizações de construções protegidas em geral são fontes
comuns de erro. Como uma tática geral de projeto, é uma ideia melhor isolar os usos de notificação para
classes de utilitários de controle de concorrência (consulte § 3.4) que podem ser fortemente otimizadas
e cuidadosamente revisadas e testadas. Adotamos essa abordagem ao longo do restante deste capítulo.

As condições para usar a notificação ocorrem com muito mais frequência em projetos fechados, onde você
tem controle total de todos os encadeamentos participantes. Por exemplo, o esboço a seguir de um jogo
de dois jogadores em sistema fechado usa esperas para a tomada de turnos. Uma única notificação é
suficiente para despertar o único thread que pode estar aguardando sua vez. Por outro lado, como
há apenas um encadeamento esperando de qualquer maneira, as diferenças de desempenho entre esta
versão e uma que usa notifyAll são provavelmente muito pequenas para medir a sobrecarga principal
associada a notifyAll é a troca de contexto, não a chamada para notifyAll em si.

Observe que giveTurn é invocado como uma chamada aberta (consulte §


2.4.1.3) no método GamePlayer.releaseTurn. É uma boa prática liberar o máximo de sincronização possível
ao realizar notificações (consulte o § 3.7.2).

class GamePlayer implements Runnable { protegido // Incompleto


GamePlayer outro; booleano protegido
myturn = false;

void sincronizado protegido setOther(Jogador p) {


outro = p; }

sincronizado void giveTurn() { // chamado por outro jogador myturn = true; notificar(); }

// desbloqueia o tópico

void liberarTurn() {
GamePlayer p;
sincronizado(este) { myturn =
false;
Machine Translated by Google

p = outro; }

p.giveTurn(); // chamada aberta }

void sincronizado awaitTurn() lança InterruptedException {


while (!myturn) wait(); }

void move() { /*... executa um movimento ... */ }

public void run() { try { for


(;;)
{ awaitTurn();
mover();

releaseTurn(); } }

catch (InterruptedException ou seja) {} // morrer }

public static void main(String[] args) { GamePlayer um =


new GamePlayer(); GamePlayer dois = new
GamePlayer(); um.setOutro(dois);
dois.setOutro(um);
one.giveTurn(); novo
Thread(um).start();
novo Thread(dois).start(); }

3.2.5 Esperas Temporárias

Em vez de esperar para sempre que uma condição se torne verdadeira em um método protegido, os designs de
tempo limite estabelecem um limite de quanto tempo qualquer espera deve permanecer suspensa.

As respostas aos intervalos dependem, obviamente, da situação. Quando os tempos limite são usados
heuristicamente, o fato de um predicado não ser válido pode ser apenas de valor informativo. Em outros
casos, os tempos limite forçam o cancelamento das tentativas de ação, caso em que
geralmente é apropriado declarar uma TimeoutException como uma subclasse de InterruptedException.

Time-outs são tipicamente mais úteis do que outras técnicas que detectam problemas de atividade imprevistos (como
deadlock[5]) porque eles fazem menos suposições sobre contextos qualquer parada que cause bloqueios
inaceitavelmente longos pode ser detectada por um time-out que aciona respostas de falha (ver § 3.1.1). Como a
maioria das respostas a atrasos de qualquer tipo é a mesma, todas podem ser acionadas por exceções de tempo
limite ou técnicas de notificação relacionadas.
Machine Translated by Google

[5]
Algoritmos de detecção de deadlock são discutidos, por exemplo, nos textos de Andrews e Bernstein e
Lewis listados nas Leituras Adicionais em § 1.2.5. A implementação requer o uso de classes de bloqueio especiais.
No entanto, alguns sistemas de tempo de execução e depuradores contêm recursos que permitem a detecção de
impasses envolvendo sincronização interna.

Os parâmetros que controlam o tempo de espera e a reavaliação da condição às vezes são completamente
arbitrários e geralmente requerem algumas tentativas e erros. Normalmente, não é muito difícil fornecer
valores que detectarão problemas reais de vivacidade sem alarmes falsos em esperas que são lentas.
Como muitas dessas falhas exigirão, em algum momento, intervenção humana, as políticas podem ser
apoiadas por meio de mecanismos que consultam os usuários sobre ações corretivas.

Os tempos limite são um pouco difíceis de expressar usando wait(msec). Na classe


TimeoutBoundedCounter a seguir , a espera é colocada em um loop para lidar com o fato de que podem
ocorrer notificações não relacionadas. Esse loop é um pouco confuso, mas tem a mesma lógica das
versões sem tempo limite. A condição em espera é sempre verificada primeiro depois de acordar da
espera, antes de verificar o tempo limite. Isso ajuda a evitar erros de programação decorrentes de casos em
que uma espera é liberada por um tempo limite, mas outros encadeamentos concorrentes são executados
antes que o encadeamento com tempo limite tenha a chance de continuar. Uma dessas outras threads
poderia ter alterado a condição, caso em que não seria necessário ou apropriado retornar uma indicação de
falha. Se a condição não for mantida, o valor do tempo limite é verificado e ajustado para uso na próxima iteração.

Você pode alterar esse código para tomar a decisão oposta sobre solicitar a verificação de condição e a
verificação de tempo se os tempos limites forem sempre considerados como denotando falha, mesmo se a
condição for válida após a retomada do tempo limite.

class TimeoutException extends InterruptedException { ... }

classe TimeOutBoundedCounter { contagem


longa protegida = 0;

tempo limite protegido = 5000; // para ilustração

// ...
sincronizado void inc() throws InterruptedException {

if (contagem >= MAX) {


início longo = System.currentTimeMillis(); long waitTime =
TIMEOUT;

for (;;) { if
(waitTime <= 0) lançar novo
TimeoutException(); else { tente

{ wait(waitTime);

} catch (InterruptedException ie) { throw ie; //


codificado desta forma apenas para dar ênfase } if (count < MAX) break;
Machine Translated by Google

else
{ longo agora = System.currentTimeMillis(); waitTime =
TIMEOUT - (agora - início); }

}}

++contar;
notificarTodos(); }

sincronizado void dec() throws InterruptedException { // ... similar ... }

3.2.6 Esperas ocupadas

A implementação de guardas por meio de métodos de espera e notificação é quase sempre superior ao uso de um
"spinloop" de espera ocupada no estilo de repetição otimista do formulário:

protegido void ocupadoEsperarUntilCond() {


enquanto (!cond)
Thread.yield();
}

As esperas ocupadas têm desvantagens que as tornam escolhas ruins para implementar a maioria das ações cautelosas. Os
contrastes entre as duas técnicas ajudam a explicar por que os métodos de espera e notificação baseados em
suspensão são definidos como são.

3.2.6.1 Eficiência

Esperas ocupadas podem desperdiçar uma quantidade ilimitada de tempo de CPU girando inutilmente. A versão
baseada em espera verifica novamente as condições somente quando algum outro thread fornece uma notificação de que o
estado do objeto foi alterado, possivelmente afetando a condição de guarda. Mesmo que as notificações às vezes sejam
enviadas quando as condições de ativação não são válidas, é provável que as condições sejam verificadas novamente
de forma improdutiva com muito menos frequência do que em um spinloop que verifica continuamente e cegamente.

As principais exceções são aqueles casos em que você de alguma forma sabe que a condição deve se tornar verdadeira
dentro de um período de tempo muito curto e limitado. Nesses casos, o tempo perdido girando pode ser menor do que o
tempo necessário para suspender e retomar os encadeamentos. Isso pode se aplicar a operações associadas ao controle do
dispositivo. Os giros limitados, seguidos por suspensões, também são às vezes usados dentro de sistemas de tempo de
execução como uma otimização para bloqueios "adaptativos" que geralmente são mantidos apenas brevemente.

3.2.6.2 Agendamento

O rendimento na versão spinloop é apenas uma dica (consulte § 1.1.2) e não é garantido para permitir que outras
threads executem para que possam alterar a condição. Assim, a utilidade das esperas ocupadas é
Machine Translated by Google

mais dependente das políticas de uma determinada JVM e pode interagir com outros aspectos do agendamento.
Por exemplo, se a tarefa giratória estiver sendo executada em alta prioridade, mas os threads que alteram a condição forem
executados em baixa prioridade, a tarefa giratória ainda poderá expulsar as outras. Na versão baseada em espera, a tarefa
em espera não é executada e, portanto, não pode encontrar tais problemas de agendamento (embora outros problemas
de agendamento possam surgir).

Algumas das situações mais plausíveis para usar spinloops são aquelas em que alguma outra ação pode ser executada
dentro do loop se a condição não for verdadeira, em vez de apenas ceder. Isso ajuda a limitar o desperdício de CPU
e interage melhor com políticas de agendamento comuns. Se a rotação for necessária por algum motivo e nenhuma
outra alternativa se aplicar, você pode reduzir os custos de CPU e as incertezas de agendamento usando as técnicas de
back-off cronometradas descritas em § 3.1.1.5.

3.2.6.3 Acionamento

Ao contrário das construções baseadas em espera, os métodos com spinloops não precisam ser emparelhados com
métodos que fornecem notificações para acionar verificações. Às vezes, os spinloops são usados em desespero quando
nenhum método de sinalização existe ou pode ser escrito. Mas as esperas ocupadas podem perder oportunidades de disparar
se elas não estiverem programadas para serem executadas durante os momentos em que a condição é momentaneamente verdadeira.

Observe, no entanto, que fenômenos semelhantes também ocorrem em construções baseadas em espera: uma condição
sinalizada por um notify ou notifyAll pode mudar posteriormente devido à ação de algum outro encadeamento ocorrendo
antes que o encadeamento notificado tenha a chance de continuar. Esta é uma razão para proteger todas as formas de espera.

Além disso, nenhuma das técnicas sozinhas garante automaticamente a imparcialidade de que cada encadeamento
potencialmente habilitado eventualmente prossiga. Mesmo em construções baseadas em espera, pode acontecer
que uma thread de loop em particular que encontra repetidamente o guarda seja sempre aquela que prossegue, eliminando
todas as outras (consulte § 3.4.1.5).

3.2.6.4 Ações de sincronização

Pode ser difícil sincronizar spinloops da maneira desejada. Por exemplo, nem sempre funcionaria declarar o método
busyWaitUntilCond como sincronizado, pois isso não permite que nenhum outro método sincronizado altere a condição.
No mínimo, cond precisa ser declarado como volátil e/ou acessado e configurado em seus próprios métodos sincronizados .
No entanto, sem a sincronização de toda uma sequência de ação de verificação, você não pode, em geral, garantir que um
objeto permaneça no mesmo estado entre o teste de condição e a ação associada.

Conforme descrito em § 3.4.2.1, o uso confiável de esperas ocupadas não sincronizadas em métodos protegidos é
geralmente restrito a predicados de travamento que, de alguma forma, garantem que permanecerão verdadeiros para
sempre, uma vez definidos. Em contraste, a versão baseada em espera abandona automaticamente o bloqueio de
sincronização (somente para o objeto host) após a espera e obtém novamente o bloqueio ao acordar. Contanto que tanto
a guarda quanto a ação estejam dentro de um bloqueio comum, e o guarda faça referência apenas a variáveis protegidas
por esse bloqueio, não há perigo de condições escorregadias. Esse é um dos motivos pelos quais as instruções de espera
podem ser usadas apenas em sincronização. No entanto, o fato de as tarefas em espera conterem qualquer bloqueio
pode ser a fonte de dificuldades logísticas, incluindo o problema do monitor aninhado discutido em § 3.3.4.

3.2.6.5 Implementações

Nos raros casos em que você não tem alternativa para esperas ocupadas, você pode usar uma classe como a seguinte
SpinLock. Nunca há qualquer razão para usar esta classe para bloqueio (ver § 2.5.1), mas é um veículo simples para ilustrar
construções que podem ser aplicadas em outros contextos.
Machine Translated by Google

O método de liberação aqui é sincronizado como uma garantia de que uma sincronização de memória ocorre após
a liberação do bloqueio, como seria necessário em praticamente qualquer uso desta classe (consulte § 2.2.7). As
regras de escalonamento para verificações com falha são desconfortavelmente dependentes de configurações
que são intrinsecamente específicas da plataforma e do aplicativo (por exemplo, a fase de rotação
sem rendimento puro é plausível apenas em multiprocessadores) e pode ser difícil de ajustar efetivamente mesmo com feedback empírico.

classe SpinLock { // Evite a necessidade de usar isso

private volátil booleano ocupado = false;

liberação void sincronizada() { ocupado = false; }

voidadquire() lança InterruptedException { int spinsBeforeYield =


100; // 100 é arbitrário // 200 é arbitrário int spinsBeforeSleep = 200; tempo
int arbitrário spins = 0; for (;;) { if (! de sono longo = 1; // 1ms é
ocupado) { sincronizado(este) { if (!ocupado) {

// teste-e-teste-e-conjunto

ocupado =
verdadeiro; retornar;
}

}}

if (roda <spinsBeforeYield) { ++spins; } else if // fase de rotação


(spins <

spinsBeforeSleep) { // rende fase ++spins; Thread.yield(); } else

{ Thread.sleep(sleepTime);

// fase de retorno
sleepTime = 3 * sleepTime / 2 + 1; //
50% é arbitrário } }

}
}

3.3 Estruturação e Refatoração de Classes


As técnicas básicas de espera e notificação descritas em § 3.2 podem ser combinadas com outras estratégias de
design para melhorar a reutilização e/ou desempenho, bem como para obter um controle mais refinado sobre as
ações. Esta seção examina alguns padrões, técnicas e problemas comuns observados ao rastrear o estado
lógico e o estado de execução, envolvendo o controle em métodos substituíveis e criando projetos baseados em
confinamento.
Machine Translated by Google

3.3.1 Estado de Rastreamento

A estratégia mais conservadora para escrever métodos protegidos é chamar notifyAll toda vez que você alterar
o valor de qualquer variável de instância. Essa estratégia é altamente extensível. Se todas as alterações em
todas as variáveis de instância gerarem notifyAll, então qualquer método na classe e todas as suas
subclasses possíveis podem definir uma cláusula de guarda que espera por qualquer estado específico. Por outro
lado, esta prática pode ser ineficiente quando gera notificações que não podem afetar as condições de guarda
de qualquer thread em espera. Frequentemente, algumas ou todas essas notificações inúteis podem ser eliminadas
por meio da análise de estado lógico. Em vez de emitir notificações sobre todas as alterações nas variáveis de
instância, você pode emitir notificações somente após transições fora dos estados lógicos nos quais os
threads podem esperar. Os exemplos a seguir ilustram as técnicas.

3.3.1.1 Canais e buffers limitados

As abstrações de canal desempenham papéis centrais em vários estilos de design de software concorrente
(consulte § 1.2.4 e § 4.1). Uma interface de canal pode ser definida como:

interface Channel { void


put(Object x) throws InterruptedException;
O objeto take() lança InterruptedException;
}

Os métodos take e put podem ser vistos como análogos de transporte de dados de operações de
aquisição e liberação de sincronização (consulte § 2.5.1), versões não baseadas em IO de operações de leitura e
gravação de fluxo , formas encapsuladas de operações de transferência (consulte § 2.3.4 ) e quando os
elementos do canal representam mensagens, operações de recebimento e envio de mensagens (ver § 4.1.1).

Um buffer limitado pode ser usado como um canal (consulte § 3.4.1 para algumas outras alternativas). Os buffers
limitados têm a mesma estrutura geral dos contadores limitados. Além de um tamanho (contagem), um buffer
mantém uma matriz fixa de elementos. Em vez de inc, suporta put e, em vez de dec, suporta take.
Além disso, o MIN é simplesmente zero e o MAX é a capacidade (declarada como int para simplificar o uso na
indexação de array).

interface BoundedBuffer extends Canal { int capacidade(); //


INV: 0 < capacidade
tamanho(); } // INV: 0 <= tamanho <= capacidade int

Conforme descrito em praticamente qualquer livro de estruturas de dados, as implementações de


BoundedBuffers podem empregar uma matriz de tamanho fixo junto com dois índices que percorrem circularmente
a matriz, acompanhando as próximas posições a serem colocadas e tomadas, respectivamente. Os estados
lógicos e transições definidos para um BoundedBuffer são semelhantes aos de um BoundedCounter:

Estado Doença colocar pegar


completo
tamanho == capacidade não sim
0 < tamanho < capacidade sim sim

parcialmente vazio tamanho == 0 sim não


Machine Translated by Google

Observe que as únicas transições que podem afetar os threads em espera são aquelas que se
afastam dos estados vazio e cheio; ou seja, incrementar o tamanho a partir de zero ou diminuí-lo a partir da capacidade.

Essas observações levam à seguinte implementação de BoundedBuffer , na qual as


notificações são emitidas somente quando as transições são feitas fora dos estados vazio e cheio.
(Parte da concisão do código se deve à conveniência dos idiomas de codificação pós-
incremento e pós-decremento.)

Esta versão pode gerar muito menos notificações do que uma versão em que cada mudança
de tamanho resulta em uma notificação, ativando threads inutilmente. Nos casos em que avaliar
os guardas é computacionalmente caro, minimizar as verificações dessa maneira resulta em
melhorias de eficiência ainda maiores.

class BoundedBufferWithStateTracking { array Object[]


final protegido; // os elementos protegidos int putPtr = 0; // índices
circulares protegidos int takePtr = 0; protegido int usedSlots = 0;

// a conta

public BoundedBufferWithStateTracking(capacidade int) throws


IllegalArgumentException {
if (capacity <= 0) lançar novo IllegalArgumentException(); array = new
Object[capacidade];
}
Machine Translated by Google

público sincronizado int size() { return usedSlots; }

public int capacidade() { return array.length; }

put nulo sincronizado público (objeto x)


lança InterruptedException {

while (usedSlots == array.length) // espera até não ficar cheio wait();

array[putPtr] = x; putPtr =
(putPtr + 1) % matriz.comprimento; // inc ciclicamente

if (usedSlots++ == 0) notifyAll(); // sinaliza se estava vazio

Objeto público sincronizado take() lança


InterruptedException{

while (usedSlots == 0) wait(); // espera até não estar vazio

Objeto x = array[takePtr]; array[takePtr]


= null; takePtr = (takePtr + 1) %
array.length;

if (usedSlots-- == array.length) // sinaliza se estava cheio notifyAll(); retornar x;

3.3.1.2 Variáveis de estado

Às vezes, o rastreamento de estado pode ser simplificado e melhor encapsulado usando variáveis de
estado que representam todo o estado lógico de um objeto em um único campo (consulte § 3.2.1.3).
Normalmente, uma variável de estado assume valores de um tipo enumerado. A variável é reavaliada após qualquer atualização nos campo
Essa reavaliação pode então ser isolada em um único método, digamos updateState, chamado após cada
método de atualização. Depois de reavaliar o estado, updateState emite notificações associadas à mudança
de estado. Por exemplo, usar uma variável de estado em um BoundedCounter (um BoundedBuffer funcionaria
de forma semelhante) leva a:

classe BoundedCounterWithStateVariable {
static final int BOTTOM = 0, MIDDLE = 1, TOP = 2; estado int protegido =
INFERIOR; // a variável de estado protegida long count = MIN;
Machine Translated by Google

protected void updateState() {//PRE: bloqueio de sincronização mantido


int estadoantigo = estado; if
(count == MIN) estado = BOTTOM;
else if (count == MAX) estado = TOP; else if (state !=
estado = MEIO;
oldState && oldState != MIDDLE) notifyAll();
// notifica na transição
}

contagem longa sincronizada pública() { return contagem; }

público void inc() sincronizado lança InterruptedException { while (state == TOP) wait(); ++contar;
atualizarEstado(); }

público sincronizado void dec () lança InterruptedException {


while (estado == BOTTOM) wait(); --contar;

atualizarEstado(); }

Em vez de usar updateState para reavaliar o estado, você pode fazer com que cada método que executa
atualizações também determine o próximo estado correto e o envie como um argumento para updateState, que
ainda pode executar notificações após a alteração. (No entanto, isso pode agravar os problemas de fragilidade
discutidos em § 3.3.3.3.) À medida que o número de estados aumenta, você pode empregar máquinas mais
pesadas, como máquinas de estado finito ou tabelas de decisão (consulte Leituras adicionais).

3.3.2 Conjuntos de Conflitos

As classes que rastreiam o estado de execução das operações subjacentes podem usar essas informações
para decidir o que fazer com as novas solicitações recebidas. Uma das principais aplicações é a
construção de políticas de exclusão personalizadas que fornecem um controle de exclusão mais refinado do que o visto no Capítulo 2.

Para ilustrar, considere uma classe Inventory com métodos para armazenar e recuperar objetos, cada um
com uma descrição exclusiva. Suponha que essas operações sejam um pouco demoradas, mas são
implementadas de uma forma que não requer necessariamente sincronização de baixo nível. Nesse caso,
podemos permitir que as operações que não conflitam semanticamente entre si sejam executadas ao mesmo
tempo, permitindo assim mais simultaneidade do que seria possível em uma versão totalmente sincronizada da classe.

No contexto clássico dessa forma de controle de política, a funcionalidade básica é organizada por
meio de transações de banco de dados, mas ilustraremos usando java.util.Hashtable. Mesmo que a
classe Hashtable totalmente sincronizada permita que uma classe Inventory seja definida sem se preocupar
com alguns detalhes de sincronização de baixo nível, ainda queremos colocar algumas restrições semânticas
nas operações de armazenamento e recuperação . Uma escolha política é:
Machine Translated by Google

Uma operação de recuperação não deve ser executada simultaneamente com uma operação de
armazenamento , pois o armazenamento pode estar no processo de adicionar exatamente o item solicitado;
nesse caso, você não deseja retornar uma indicação de falha.

Duas ou mais operações de recuperação não devem ser executadas ao mesmo tempo, pois uma pode estar
removendo o item solicitado pelas outras.

Poderíamos ter tomado outras decisões aqui, por exemplo, até permitir que todas as operações operem
simultaneamente, permitindo assim falhas. Além disso, poderíamos ter baseado as políticas nos detalhes
de implementação interna das operações. Por exemplo, as opções acima também seriam válidas aqui se o método de
recuperação fosse programado de uma forma que exigisse exclusão, mas o armazenamento não.

Várias notações formais e semiformais foram criadas para ajudar a representar esse tipo de informação.
O método mais amplamente utilizado, que é suficiente para a maioria dos problemas de controle de concorrência desse
tipo, é baseado em conjuntos de pares de ações que não podem ocorrer simultaneamente. Por exemplo, aqui o
conjunto de conflitos é meramente:

{ (armazenar, recuperar), (recuperar, recuperar) }.

Essas informações podem servir como documentação da semântica da classe e como um guia para implementar essa
semântica por meio do rastreamento do estado de execução.

3.3.2.1 Implementação

Classes baseadas em conjuntos de conflito podem empregar designs antes/depois (consulte § 1.4) nos quais as ações
de solo são cercadas por código que mantém as relações de exclusão pretendidas. A seguinte mecânica pode ser
implementada através de qualquer padrão antes/depois:

Para cada método, declare um campo contador representando se o método está ou não em andamento.

Isole cada ação de solo em um método não público.


Escreva versões públicas dos métodos que cercam a ação de solo com controle antes/depois: o Cada
ação
anterior sincronizada primeiro espera até que todos os métodos não conflitantes tenham terminado,
conforme indicado pelos contadores. Em seguida, incrementa o contador associado ao método.

o Cada ação posterior sincronizada diminui o contador do método e executa notificações para
ativar outros métodos em espera.

Aplicar essas etapas diretamente aos métodos de uma classe de inventário leva a:

classe Inventário {

itens hashtable finais protegidos = new Hashtable(); Fornecedores


Hashtable finais protegidos = new Hashtable();

// variáveis de rastreamento do estado de execução:

armazenamento int protegido = 0; // número de armazenamentos em andamento


protegidos int recuperando = 0; // número de recuperações
Machine Translated by Google

// ações de solo:

protected void doStore(String descrição, Objeto item, String fornecedor)


{ items.put(descrição, item);
fornecedores.put(fornecedor, descrição); }

objeto protegido doRetrieve(descrição da string) {


Objeto x = itens.get(descrição); se (x != nulo)

itens.remove(descrição); retornar x; }
public void

store(Descrição da string, item do objeto, fornecedor da


string) throws
InterruptedException {

sincronizard(this) { while // antes da ação


(recuperando != 0) // não se sobrepõe com recuperações
espere(); +
+armazenamento; } // grava o estado do exec

tente
{ doStore(descrição, item, fornecedor); // ação de solo
}

finalmente { // pós-ação // sinal recupera sincronizado(this) { if (--


apenas quando atingir zero storing == 0) // necessário
notifyAll();

}
}
}

recuperação de objeto público (descrição da string)


lança InterruptedException {

sincronizada(this) { // espera // ação anterior


até que não haja nenhum armazenamento ou recuperação
while (armazenando != 0 || recuperando != 0)
espere(); +
+recuperando; }

tente
{ return doRetrieve(descrição); // ação de solo
Machine Translated by Google

finalmente
{ sincronizado(este) { // ação posterior
if (--recuperando == 0) notifyAll();

}
}

}}

(Devido à natureza do conflito definido aqui, o notifyAll no método de recuperação sempre está ativado. No entanto,
de maneira mais geral, as notificações devem assumir a forma condicional mostrada.)

3.3.2.2 Variantes e extensões

As ideias vistas no exemplo de Inventário acima também se aplicam a métodos otimistas, caso em que os
conflitos são freqüentemente chamados de relações de invalidação. Elas são implementadas abortando
operações conflitantes antes da confirmação, em vez de esperar até que seja seguro realizá-las (consulte § 3.6).

Uma notação mais extensa pode ser usada para representar o conflito em um nível de detalhe arbitrariamente fino,
abrangendo casos como aqueles em que, digamos, algum método A entra em conflito com o método B apenas
se ocorrer após o método C. Da mesma forma, na classe Inventory , podemos usar uma notação mais precisa
para afirmar que uma operação de armazenamento pode começar se uma recuperação estiver em andamento,
mas não vice-versa. Uma variedade de notações foi desenvolvida para tais propósitos (veja as Leituras Adicionais
em § 1.2.5 e § 3.3.5), permitindo uma representação mais detalhada de conflitos enquanto ainda permite a
implementação semi-automática por meio de variáveis de rastreamento de estado de execução. No entanto, em
casos extremos, pode ser que nada menos que um histórico completo seja suficiente para implementar uma determinada política.

As técnicas descritas em § 3.4 e § 3.7 podem ser usadas para reduzir o número de notificações e trocas de
contexto na maioria das classes que dependem de conjuntos de conflitos.

Implementações baseadas em rastreamento de estado de execução e conjuntos de conflitos podem sofrer


problemas de fragilidade e não extensibilidade. Como os conjuntos de conflitos são baseados nos métodos
realmente definidos em uma classe, e não em representações lógicas de sua semântica ou invariantes de
estado subjacentes, eles são difíceis de estender ao alterar ou adicionar métodos em subclasses. Por exemplo, se
um método de classificação for introduzido para reordenar os itens de alguma forma, ou um método de
pesquisa para verificar se um item existe, eles podem entrar em conflito de maneiras diferentes daquelas atualmente tratadas, exigindo retra

O padrão Readers e Writers e as construções relacionadas descritas em § 3.3.3 aliviam alguns desses problemas
classificando as operações em categorias extensíveis. O padrão Readers and Writers também aborda questões
de precedência e programação que não são cobertas por notações de conflito. Por exemplo, em Inventory,
podemos querer adicionar uma disposição de que, se houver vários encadeamentos em espera, os
encadeamentos aguardando para realizar operações de recuperação são preferidos aos que aguardam para
realizar operações de armazenamento , ou vice-versa.
Machine Translated by Google

3.3.3 Subclasse
A subclassificação pode ser usada para colocar em camadas diferentes políticas de controle sobre o mecanismo existente ou
até mesmo vice-versa. Esta prática estende as aplicações de subclasse vistas no § 2.3.3.2 que bloqueiam a camada sobre a
funcionalidade básica.

3.3.3.1 Leitores e Escritores

O padrão Readers and Writers é uma família de projetos de controle de simultaneidade com uma base comum, mas diferindo em
questões de política que rege o controle de threads que invocam acessadores ("Readers") versus aqueles que invocam operações
mutativas de mudança de estado ("Writers").

No § 2.5.2, vimos uma versão desse padrão encapsulado como uma classe utilitária. Aqui mostramos uma versão
subclassável antes/depois usando o padrão de método de modelo (consulte § 1.4.3). Além de sua utilidade intrínseca, esse projeto é
um bom modelo para qualquer tipo de política que pode ser implementada misturando o controle de simultaneidade antes/depois
baseado em subclasse e contadores que registram mensagens e atividades. Por exemplo, técnicas muito semelhantes se aplicam
a classes que exigem que certas categorias de mensagens ocorram em pares ordenados (como na imposição, digamos, ler, escrever,
ler, escrever e assim por diante). Eles também se aplicam a esquemas estendidos que suportam bloqueios de intenção que reservam
a opção de adquirir posteriormente (ou atualizar para) bloqueios de leitura ou gravação para qualquer um de um conjunto de
objetos acessíveis a partir de um determinado contêiner (consulte § 2.4.5) .

Antes de implementar mecanismos de controle, você deve primeiro estabelecer um conjunto de políticas que regem seu uso. As
políticas de leitores e gravadores são generalizações dos tipos de políticas de controle de concorrência vistas, por exemplo, na
classe Inventory em § 3.3.2. Mas ao invés de lidar com métodos particulares, eles lidam com todos os métodos que têm a
semântica de ler versus escrever. No entanto, os detalhes ainda dependem da situação. As considerações incluem:

Se já houver um ou mais Leitores ativos (em execução), um Leitor recém-chegado pode se juntar a eles
imediatamente, mesmo que também haja um Escritor em espera? Nesse caso, um fluxo contínuo de entrada de leitores
fará com que os escritores morram de fome. Caso contrário, a taxa de transferência dos Leitores diminui.
Se alguns leitores e alguns gravadores estiverem esperando que um gravador ativo termine, você deve influenciar a
política para permitir leitores? um escritor? O mais antigo primeiro? Aleatório? Alternar?
Opções semelhantes estão disponíveis após o término do Readers.
Você precisa de uma maneira de permitir que os escritores façam o downgrade do acesso para serem leitores sem ter
que abrir mão de bloqueios?

Embora não haja respostas corretas para essas questões políticas, existem algumas soluções padrão e implementações
correspondentes. Ilustraremos com um conjunto comum de opções: Leitores são bloqueados se houver Gravadores em espera,
Gravadores em espera são escolhidos arbitrariamente (dependendo apenas da ordem em que o planejador da JVM subjacente retoma
os encadeamentos desbloqueados) e não há mecanismos de downgrade .

A implementação dessa política de controle de simultaneidade requer rastreamento do estado de execução. Como a maioria das
políticas, ela pode ser estabelecida mantendo contagens de threads que estão ativamente envolvidos nas operações de leitura e
gravação, além daqueles que estão esperando para fazê-lo. O rastreamento de threads em espera é a principal extensão aqui das
técnicas vistas em implementações típicas de conjuntos de conflitos.

Para estruturar as implementações correspondentes, o código de controle pode ser fatorado em pares de métodos que cercam o
código real de leitura e gravação, que deve ser definido em subclasses. Esse projeto antes/depois (consulte § 1.4.3) permite a
construção de qualquer número de métodos públicos de estilo de leitura e de gravação, em que cada método público invoca o
método não público dentro dos pares.
Machine Translated by Google

A versão a seguir foi escrita de maneira genérica, de modo que as variantes secundárias sejam
simples de implementar nas subclasses. Em particular, a contagem de leitores em espera não é
realmente necessária nesta versão, pois nenhuma política depende de seu valor. No entanto, sua
presença permite que você ajuste as políticas alterando os predicados nos métodos allowReader e
allowWriter para confiar neles de alguma forma. Por exemplo, você pode alterar as condicionais para dar preferência à contage

classe abstrata ReadWrite { protected


int activeReaders = 0; // threads executando leitura protegida int activeWriters = 0; //
sempre zero ou um

protegido int esperandoLeitores = 0; // threads ainda não protegidos contra leitura int
waitWriters = 0; // o mesmo para escrever

abstrato protegido void doRead(); // implementa em subclasses protegidas abstract


void doWrite();

public void read() lança InterruptedException {


antes de ler();
tente { doRead(); }
finalmente { afterRead(); } }

public void write() lança InterruptedException {


antesEscrever();
{ doWrite(); } tente
finalmente { afterWrite(); } } protected

boolean allowReader() { return waitWriters == 0


&& activeWriters == 0; }

protected boolean allowWriter() { return


activeReaders == 0 && activeWriters == 0; }

void sincronizado protegido antes de Ler() lança


InterruptedException { ++waitingReaders;
while (!allowReader())
{ tente { wait(); } catch
(InterruptedException
ou seja) {
--esperandoLeitores; // reverte o lançamento do
estado ie;
}

} --waitingReaders; +
+Leitores ativos; }
Machine Translated by Google

void sincronizado protegido afterRead() {


--activeReaders;
notificarTodos(); }

void sincronizado protegido beforeWrite() lança


InterruptedException { ++waitingWriters;
while (!allowWriter())
{ tente { wait(); } catch
(InterruptedException
ou seja) {
--waitingWriters; jogar ou
seja; } } --

waitingWriters; +
+escritores ativos;
}

void sincronizado protegido afterWrite() {


--activeWriters;
notificarTodos(); }

Essa classe ou suas subclasses também podem ser reempacotadas para suportar a interface
ReadWriteLock discutida em § 2.5.2. Isso pode ser feito usando classes internas. (Uma
estratégia semelhante é usada pelas versões util.concurrent de ReadWriteLock, que também
incluem algumas otimizações discutidas no § 3.7 para minimizar notificações desnecessárias.) Por exemplo:

classe RWLock estende ReadWrite implementa ReadWriteLock {


class RLock implementa Sync { public void
adquir() lança InterruptedException {
antes de ler();
}

liberação public void() {


apósLer(); }

public boolean try(long msecs) throws


InterruptedException{ return
beforeRead(msecs);
}
}

class WLock implementa Sync { public void


adquir() lança InterruptedException { beforeWrite();
Machine Translated by Google

liberação public void() {


apósEscrever(); }

public boolean try(long msecs) throws


InterruptedException{ return
beforeWrite(msecs);
}
}

RLock final protegido rlock = new RLock(); WLock final protegido


wlock = new WLock();

public Sync readLock() { return rlock; } public Sync writeLock()


{ return wlock; }

public boolean beforeRead(long msecs) throws


InterruptedException { // ... versão de tempo
limite de beforeRead ...
}

public boolean beforeWrite(long msecs)


throws InterruptedException { // ... versão de
tempo limite de beforeWrite ...
}
}

3.3.3.2 Guardas de camadas

Os guardas podem ser adicionados às classes básicas de estrutura de dados que foram originalmente escritas em
forma de balking. Por exemplo, considere uma pilha simples:

class StackEmptyException extends Exception { }

classe Pilha { //
fragmentos

público sincronizado booleano isEmpty() { /* ... */ }

public sincronizado void push(Object x) { /* ... */ }

público sincronizado Object pop () lança StackEmptyException


{
if (isEmpty()) lançar
novo StackEmptyException(); // outro ...

}
Machine Translated by Google

Recusar tentativas de remover um elemento de uma pilha vazia é atraente, pois torna a classe
utilizável em configurações sequenciais onde seria inútil esperar por um pop: se nenhuma outra thread
puder adicionar um elemento, o programa simplesmente travará para sempre. Por outro lado,
alguns clientes de uma Pilha em contextos concorrentes podem querer parar e esperar que um elemento
apareça. Uma abordagem ineficiente é tentar executar pop e, se uma StackEmptyException for
capturada, tentar novamente. Esta é uma forma disfarçada de espera ocupada.

Uma versão que suporta diretamente o uso protegido pode ser criada usando um design simples
baseado em subclasse que introduz métodos que fornecem coordenação adicional. No entanto, não
é uma ideia particularmente boa substituir o método pop aqui. Entre outras considerações, as
diferentes políticas de impedimento versus espera são refletidas nas diferentes assinaturas dos
métodos: A forma de impedimento de pop pode lançar StackEmptyException, mas uma versão em
espera nunca pode; inversamente, uma versão em espera pode gerar InterruptedException, mas
uma versão em atraso nunca pode. Embora eles possam ser mesclados em alguma interface mais
branda, é mais gerenciável defini-los como métodos separados.

Mesmo assim, é possível adicionar o método waitPop na subclasse sem a necessidade de reescrever
todas as partes internas do pop. Observe, no entanto, que isso também requer a
substituição de push para fornecer notificações para threads bloqueados em waitPop. (O
notifyAll aqui pode ser ainda mais otimizado.)

class WaitingStack extends Stack {

push nulo sincronizado público (objeto x) {


super.push(x);
notificarTodos(); }

Objeto público sincronizado esperandoPop()


lança InterruptedException {
Machine Translated by Google

while (isEmpty()) { wait(); }

tente
{ return super.pop();

} catch (StackEmptyException não pode acontecer) {


// só é possível se pop contiver um erro de programação throw new
Error("Erro de implementação interna"); } }

3.3.3.3 Anomalias de herança

Algumas linguagens de programação OO simultâneas (consulte Leituras Adicionais) requerem sintaticamente


separação entre métodos não públicos que definem funcionalidade e métodos públicos que definem políticas de
controle de simultaneidade; ou seja, eles determinam o tipo de separação visto na versão do método de modelo
da classe ReadWrite. Mesmo quando a separação não é estritamente necessária, é uma opção atraente:

Ele permite que o código de ação ou o código de controle de simultaneidade sejam variados
independentemente em subclasses, evitando construções que tornariam impossível para a subclasse
obter a funcionalidade desejada sem reescrever quase todos os métodos.
Evita a necessidade de misturar variáveis usadas apenas para fins de sincronização com variáveis de
estado lógicas necessárias para a funcionalidade básica. Em vez disso, essas variáveis podem ser
introduzidas em subclasses.
Evita problemas envolvendo controle exclusivo, acesso a variáveis e métodos internos, identidade de
objeto, monitores aninhados (§ 3.3.4) e adaptação de interface encontrada com outras técnicas de
camadas. A criação de subclasses estende os objetos em vez de compô-los. Por exemplo, nenhuma
consideração especial é necessária para garantir a propriedade exclusiva da "parte" da superclasse de um
objeto.

Contanto que todas as variáveis e métodos relevantes sejam declarados como protegidos, uma subclasse geralmente
pode realizar as modificações necessárias no código de nível básico para dar suporte a uma política desejada.
Apesar das melhores intenções dos autores da classe, cirurgias extensas no código do método em uma subclasse às
vezes são a única maneira de salvar uma classe para que ela obedeça a uma determinada política. Embora o acesso
protegido tenha algumas desvantagens claras como uma convenção de design, em configurações simultâneas, a
capacidade resultante das subclasses de alterar o controle de política pode superar as preocupações sobre o abuso de representações de superc

Para que isso funcione, as invariantes necessárias devem ser bem documentadas. As superclasses que
dependem de campos e métodos protegidos e restrições documentadas têm maior probabilidade de serem
estendidas corretamente do que aquelas que expõem publicamente todos os campos (mesmo por meio de métodos
get/set), esperando que os clientes externos possam descobrir como preservar a consistência, invariantes semânticos
e atomicidade requisitos para novas ações ou novas políticas.

Mas essa forma de subclasse tem suas limitações. Quando as pessoas começaram a usar linguagens OO
concorrentes experimentais, vários pesquisadores perceberam que pode ser difícil ou mesmo impossível definir
subclasses que adicionem ou estendam funcionalidades ou políticas comumente desejadas para superclasses.
Preocupações semelhantes foram expressas em relatos de análise OO de alto nível e métodos de projeto.
Machine Translated by Google

Algumas construções em classes puramente sequenciais também são difíceis de estender, por exemplo,
aquelas que declaram métodos como finais sem um bom motivo. Mas obstáculos adicionais suficientes são
encontrados na programação OO simultânea para que esse estado de coisas tenha sido rotulado como anomalia de herança.
As questões e problemas abrangidos por este termo são apenas vagamente relacionados. Exemplos incluem:

Se uma subclasse inclui esperas protegidas em condições sobre as quais os métodos da superclasse
não fornecem notificações, esses métodos devem ser recodificados. Isso é visto na classe
WaitingStack (§ 3.3.3.2), onde push é substituído apenas para fornecer notificações para o novo
método waitPop.
Da mesma forma, se uma superclasse usa notify em vez de notifyAll, e uma subclasse adiciona
recursos que fazem com que as condições para o uso de notify não sejam mais mantidas, todos os
métodos que executam notificações devem ser recodificados.
Se uma superclasse não representa e rastreia explicitamente aspectos de seu estado lógico ou de
execução dos quais os métodos da subclasse dependem, todos os métodos que precisam rastrear e
verificar esse estado devem ser recodificados.
O uso de variáveis de estado (§ 3.3.1.2) restringe as subclasses àquelas em que a sincronização
depende apenas dos estados lógicos ou subdivisões desses estados definidos na superclasse.
Assim, as subclasses devem estar de acordo com as mesmas especificações abstratas com relação
ao estado lógico. Essa prática é recomendada em várias contas de análise e projeto OO de alto nível, mas
pode impedir os esforços de criação de subclasses. Por exemplo, suponha que você queira
estender a classe Bounded CounterWithStateVariable para adicionar um método disable que faz com que
inc e dec sejam bloqueados e um método enable que permite que eles continuem. O suporte para esses
métodos adicionais apresenta uma nova dimensão ao estado lógico que altera tanto a guarda quanto as
condições de notificação para os métodos básicos.

Juntos, esses tipos de problemas servem como um aviso de que, sem mais cuidado do que o normalmente
necessário em configurações sequenciais, é provável que você escreva classes concorrentes que os programadores
(incluindo você) não serão capazes de estender de forma fácil ou útil. Embora não tenham um nome
chamativo, obstáculos semelhantes podem ser encontrados ao tentar agregar, compor e delegar a objetos.

Uma abordagem que evita alguns dos problemas de extensibilidade mais comuns é encapsular guardas e
notificações em métodos substituíveis e, em seguida, estruturar ações públicas como:

public sincronizado void anAction()


{ awaitGuardsForThisAction();
doAction();
notifyOtherGuardsAffectedByThisAction(); }

No entanto, assim como na programação OO sequencial, não há regras universalmente válidas para criar
classes que possam servir como superclasses úteis para todas as extensões possíveis ou que possam ser
usadas sem modificação em todos os contextos possíveis. A maioria das diretrizes para escrever classes que
evitam obstáculos se resumem a duas regras de design bem conhecidas:

1. Evite otimização prematura.


2. Encapsule as decisões de design.

Ambas as regras podem ser surpreendentemente difíceis de seguir. Na maioria das vezes, evitar a otimização
requer mais abstração e andaimes do que otimizar para situações conhecidas. Da mesma forma, você não pode
encapsular uma decisão de design a menos que esteja ciente de que uma decisão foi tomada. Isso requer a
contemplação de alternativas que podem não ocorrer a você ao escrever uma aula pela primeira vez.
Machine Translated by Google

Regras como essas talvez sejam mais comumente aplicadas retrospectivamente, durante a limpeza do
código existente em esforços para torná-lo mais reutilizável. Em um mundo ideal, você pode antecipar todas as
maneiras pelas quais uma classe supostamente reutilizável deve ser aberta para torná-la mais extensível. O
mundo quase nunca é esse ideal. Refatorações retrospectivas e retrabalhos iterativos são aspectos honrosos
e rotineiros do desenvolvimento de software.

3.3.4 Confinamento e monitores aninhados

Conforme discutido em § 2.3.3 e § 2.4.5, geralmente é aceitável confinar objetos Part sincronizados dentro
de objetos Host sincronizados. Na pior das hipóteses, você pode encontrar alguma sobrecarga de bloqueio supérflua.
No entanto, essa história se torna significativamente mais complicada para as Partes que empregam
métodos de espera e notificação. Os problemas associados geralmente são descritos como o problema do
monitor aninhado. Para ilustrar o potencial de bloqueio, considere as seguintes classes mínimas:

classe PartWithGuard {
booleano protegido cond = false;

sincronizado void await() throws InterruptedException { while (!cond) wait(); //


qualquer outro
código }

sinal vazio sincronizado (booleano c) { cond = c;

notificarTodos(); }

classe Host {
parte PartWithGuard final protegida = new ParteComGuard();

sincronizado void confia() lança InterruptedException { part.await(); }

void sincronizado set(boolean c) { part.signal(c); }

A suspensão protegida faz sentido quando você acredita que outros encadeamentos podem
eventualmente desbloquear uma espera. Mas aqui, a classe Host impede estruturalmente que outros threads
executem código que poderia fazê-lo. Os problemas aqui decorrem do fato de que qualquer thread
aguardando em um conjunto de espera retém todos os seus bloqueios, exceto o do objeto em cujo conjunto de
espera foi colocado. Por exemplo, suponha que no thread T uma chamada seja feita para host.rely causando
um bloqueio dentro da parte. O bloqueio para o host é retido enquanto T é bloqueado: nenhum outro thread terá a chance de desbloqueá

Essas restrições de aninhamento podem levar a bloqueios inesperados quando métodos


sincronizados de aparência comum invocam outros métodos sincronizados de aparência comum que empregam
Machine Translated by Google

espere. Como acontece com todas as políticas para lidar com o comportamento dependente do estado,
você precisa documentar e anunciar as políticas de espera empregadas em uma classe para que as pessoas
que tentam usá-las tenham a chance de resolver possíveis problemas. Simplesmente adicionar
InterruptedException às assinaturas de métodos protegidos é um bom começo.

Existem duas abordagens gerais para evitar bloqueios de monitores aninhados. A primeira e mais simples (na
verdade, apenas uma aplicação de nossas regras padrão em § 1.1.1.1) é não empregar a sincronização do Host nos
métodos do Host que retransmitem para os métodos da Parte. Isso se aplica sempre que a chamada é stateless em
relação ao Host (consulte o § 2.4.1).

Em outros casos, onde os métodos da parte devem acessar o estado do host bloqueado, você pode redefinir as
classes da parte para usar uma forma estendida de bloqueio de contenção hierárquica (consulte § 2.4.5 ) empregando o host como monitor.
Por exemplo:

class OwnedPartWithGuard { // Esboço do código


booleano protegido cond = false; bloqueio de
objeto final;
OwnedPartWithGuard(Proprietário do objeto) { lock = proprietário; }

void await() lança InterruptedException { sincronizado(lock)


{ while (!cond) lock.wait(); // ... }

void sinal(booleano c)
{ sincronizado(bloqueio) { cond
= c;
lock.notifyAll(); }

}}
Machine Translated by Google

3.3.5 Leituras Adicionais


Discussões mais completas e outros exemplos de anomalias de herança podem ser encontrados na
coleção editada por Agha, Wegner e Yonezawa listada no § 1.2.5, bem como em artigos apresentados em
conferências OO recentes, como ECOOP, a tese de David Holmes listada no § 1.4.5, e em:

McHale, Ciaran. Synchronization in Concurrent Object-Oriented Languages, tese de doutorado, Trinity College,
Irlanda, 1994.

O sistema Composition-Filters é um exemplo de uma estrutura de desenvolvimento OO que requer a


separação da funcionalidade do controle de sincronização. Ele também inclui uma notação mais extensa do
que conjuntos de conflitos para representar restrições de controle de simultaneidade. Veja, por exemplo,
artigos de Mehmet Aksit e outros na coleção editada por Guerraoui, Nierstrasz e Riveill listados no § 1.2.5.

Técnicas para representar estados e transições (por exemplo, usando máquinas de estado finito) são
descritas na maioria das contas de OO e design de software concorrente listados em § 1.3.5. Padrões adicionais
são discutidos em:

Dyson, Paul e Bruce Anderson. "State Patterns", em Robert Martin, Dirk Riehle e Frank Buschmann (eds.),
Pattern Languages of Program Design, Volume 3, Addison-Wesley, 1998.

As classes internas usadas em RWLock ilustram uma versão simples e não consultável do padrão Extension
Object. Ver:

Gama, Erich. "Extension Object", em Robert Martin, Dirk Riehle e Frank Buschmann (eds.), Pattern Languages
of Program Design, Volume 3, Addison-Wesley, 1998.

3.4 Usando Utilitários de Controle de Simultaneidade

Os métodos integrados de espera e notificação fornecem mecanismos suficientes para implementar qualquer tipo
de esquema de coordenação dependente do estado. Mas eles apresentam três obstáculos relacionados:

Os requisitos e as propriedades dos métodos de espera e notificação geralmente se intrometem


em aspectos aparentemente não relacionados do design da classe, levando a uma sobrecarga conceitual
desnecessária e à complexidade do código. Por exemplo, embora a versão do método de modelo de
Readers and Writers em § 3.3.3 seja sólida e flexível, usá-la requer mais compreensão do design
subjacente do que a versão que suporta a interface ReadWriteLock em § 2.5.2.
Embora as aplicações simples de métodos de monitoramento sejam realmente simples, as chances
de erro (por exemplo, condições escorregadias) podem aumentar drasticamente quando fatores
adicionais são abordados, especialmente desempenho e robustez diante do cancelamento de
encadeamento. Quando as soluções são encapsuladas como classes utilitárias, o trabalho árduo
de reuni-las precisa ser feito apenas uma vez. Isso pode valer a pena mesmo quando as
classes resultantes impõem obrigações de programação adicionais a seus usuários, desde que a
reutilização de classes não seja mais difícil e propensa a erros do que reinventá-las. Para melhorar a
qualidade do software, as classes utilitárias (de qualquer tipo) devem encapsular implementações
não tão simples de ideias simples e impor obstáculos mínimos em torno de seu uso.
Embora um número ilimitado de designs possa, em princípio, ser implementado por meio de
métodos protegidos, uma grande fração dos usados na prática se enquadra em um pequeno
número de categorias gerais. Grande parte do código para eles pode ser reutilizado, em vez de reescrito do zero dentro
Machine Translated by Google

cada classe usando-os. Isso também fornece uma separação mais clara entre a escolha de uma
política de controle de concorrência específica e sua implementação.

Esta seção discute quatro exemplos representativos de utilitários e suas aplicações, incluindo a construção de
utilitários maiores a partir dos mais básicos. Alguns outros são apresentados mais adiante neste livro.
Para ser mais concreto, as descrições se concentram nas versões disponíveis no pacote util.concurrent , mas quase
todas as discussões se aplicam a quaisquer outras que você possa construir. A maioria dos detalhes de
implementação envolvendo essas classes foi adiada para § 3.7 (o que provavelmente é de interesse apenas para
desenvolvedores que criam suas próprias versões personalizadas de tais utilitários).

3.4.1 Semáforos
Semáforos (especificamente, semáforos de contagem) são construções clássicas de controle de concorrência.
Como muitos outros utilitários, eles estão em conformidade com um protocolo de liberação de aquisição e,
portanto, suportam a mesma interface Sync da classe Mutex em § 2.5.

Conceitualmente, um semáforo mantém um conjunto de permissões inicializadas em um construtor. Cada adquire


blocos, se necessário, até que uma licença esteja disponível e, em seguida, pegue-a. A tentativa do método é a mesma,
exceto que falha após o tempo limite. Cada versão adiciona uma permissão. No entanto, nenhum objeto de
permissão real é usado; o semáforo apenas acompanha o número disponível e age de acordo.

Existem outras maneiras de descrever os semáforos também, incluindo aquelas baseadas em sua metáfora
motivadora original: as bandeiras de sinalização usadas para evitar colisões ferroviárias.

3.4.1.1 Bloqueios de exclusão mútua

Os semáforos podem ser usados para implementar bloqueios de exclusão mútua simplesmente inicializando o
número de permissões para 1. Por exemplo, uma classe Mutex pode ser definida como:

classe Mutex implementa Sincronização {


private Semaphore s = new Semaphore(1);

public voidadquire() lança InterruptedException { s.acquire(); }

public void release(); {


s.release(); }

public boolean try(long ms) throws InterruptedException {


return s.tentativa(ms); } }

Esse tipo de bloqueio também é conhecido como semáforo binário, pois o contador deve assumir apenas os
valores zero e um. Um detalhe menor que não é (mas poderia ser) abordado aqui é que, pela convenção mais
comum, liberar um Mutex que não é mantido não tem efeito. (Uma convenção alternativa menos comum é lançar
uma exceção.) Caso contrário, não há nenhuma necessidade estrita de definir uma classe Mutex .
Machine Translated by Google

Um semáforo inicializado em 1 pode ser usado diretamente como um bloqueio, caso em que liberações "extras" são
lembradas e, portanto, permitem aquisições extras. Embora essa propriedade não seja desejável aqui, em contextos não
relacionados ao bloqueio, ela pode ser explorada como uma cura para sinais perdidos (consulte § 3.2.4.1).

Como os semáforos podem ser usados como bloqueios, bem como outras formas de controle de simultaneidade, eles são
suficientes como uma única construção primitiva de controle de simultaneidade. Por exemplo, é possível implementar
os equivalentes das operações de locks de método sincronizado , wait, notification e notifyAll a partir de semáforos, em vez
de vice-versa. (Para detalhes, veja, por exemplo, o livro de Andrews listado nas Leituras Adicionais em § 1.2.5.)

Vários sistemas e linguagens têm, de fato, oferecido semáforos como sua única construção de controle de
simultaneidade. No entanto, a dependência excessiva de semáforos simples para fins de exclusão mútua tende a ser mais
complexa e propensa a erros do que o bloqueio estruturado em bloco, conforme reforçado por métodos e blocos
sincronizados e auxiliado por construções antes/depois que cercam o uso de Mutex. Os semáforos são muito mais valiosos
em contextos que exploram suas capacidades de contagem e sinalização, em vez de seu uso como bloqueios.

3.4.1.2 Conjuntos de recursos

Semáforos são contadores especializados, assim como escolhas naturais para controle de concorrência em muitas
classes que envolvem contagens. Por exemplo, classes de pool de vários tipos normalmente mantêm contagens de itens de
recursos (por exemplo, descritores de arquivo, impressoras, buffers, grandes objetos gráficos) que os clientes podem
retirar e depois retornar.

A classe Pool a seguir ilustra a estrutura básica da maioria dos pools de recursos. Essa classe contém apenas uma das
várias proteções comuns e úteis, garantindo que os itens devolvidos ao pool tenham sido realmente retirados. Outros podem
ser adicionados, por exemplo, verificações para garantir que os chamadores sejam elegíveis para obter itens.

Para auxiliar a conformidade com este protocolo de check-out/check-in, os usuários de piscinas devem normalmente
empregar antes/depois das construções, como em:

tente
{ Object r = pool.getItem(); tente
{ use(r); } finalmente
{ pool.returnItem(r); }

} catch (InterruptedException ou seja) {


// lida com a interrupção ao tentar obter o item }

A classe Pool exibe uma estrutura em camadas característica de quase todas as classes que usam utilitários de controle
de simultaneidade: métodos de controle não sincronizados públicos cercam métodos auxiliares sincronizados internos. A
exclusão é necessária nos métodos doGet e doReturn porque vários clientes podem passar por available.acquire. Sem
bloqueio, vários threads podem operar simultaneamente nas listas subjacentes. Por outro lado, seria um erro declarar
os métodos getItem e returnItem como sincronizados. Isso não apenas não faria sentido, mas também
poderia causar uma forma de bloqueio de monitor aninhado (consulte § 3.3.4) quando um thread aguardando na aquisição
mantém o bloqueio necessário para qualquer thread que possa executar uma liberação.
Machine Translated by Google

pool de classe { // Itens


java.util.ArrayList protegidos incompletos = new ArrayList(); java.util.HashSet
protegido ocupado = new HashSet();

Semáforo final protegido disponível;

public Pool(int n) { disponível


= new Semaphore(n); inicializarItems(n); }

public Object getItem() lança InterruptedException {


disponível.acquire(); return
doGet(); }

public void returnItem(Object x) { if (doReturn(x))


available.release();

Objeto sincronizado protegido doGet() {


Objeto x = items.remove(items.size()-1); ocupado.add(x); //
colocar em conjunto para verificar retornos return x;

protegido sincronizado booleano doReturn(Object x) {


if (ocupado.remove(x))
{ items.add(x); // coloca de volta na lista de itens disponíveis return true;

} senão retorna falso; }

protected void initializeItems(int n) { // De alguma forma, crie


os objetos de recurso // e coloque-os na lista de itens. } }

Observe que o uso de HashSet aqui requer que as classes que definem os itens de recursos não substituam o
método equals de uma maneira que interrompa as comparações baseadas em identidade (consulte o § 2.1.1)
necessárias para a manutenção do pool.

3.4.1.3 Buffers limitados

Os semáforos são ferramentas úteis sempre que você pode conceituar um projeto em termos de licenças.
Por exemplo, podemos projetar um BoundedBuffer com base na ideia de que:
Machine Translated by Google

Inicialmente, para um buffer de tamanho n, existem n put-permits e 0 take-permits.


Uma operação take deve adquirir um take-permit e então liberar um put-permit.
Uma operação de venda deve adquirir uma autorização de venda e, em seguida, liberar uma autorização de aceitação.

Para explorar isso, é conveniente isolar as operações de matriz subjacentes em uma


classe auxiliar BufferArray simples. (Na verdade, conforme ilustrado em § 4.3.4, uma estrutura de dados
subjacente completamente diferente, como uma lista encadeada, pode ser usada sem alterar a lógica
desse projeto.) A classe BufferArray usa métodos sincronizados , mantendo a exclusão quando vários
clientes recebem permissões e poderia inserir ou extrair elementos simultaneamente.

class BufferArray { array


Object[] final protegido; // os elementos // índices circulares protegidos int
takePtr = 0; putPtr = 0; protegido int

BufferArray(int n) { array = new Object[n]; }

sincronizado void insert(Object x) { // coloca mecânica array[putPtr] = x; putPtr =


(putPtr + 1) %
matriz.comprimento; }

sincronizado Object extract() { Object x = // pega mecânica


array[takePtr]; array[takePtr] = null;
takePtr = (takePtr + 1) %
array.length; retornar x; } }

A classe BoundedBufferWithSemaphores correspondente envolve operações de buffer com operações de


semáforo para implementar colocar e receber. Mesmo que cada método comece com uma aquisição
e termine com uma liberação, eles seguem um padrão de uso diferente do visto com bloqueios em § 2.5. A
liberação está em um semáforo diferente da aquisição e é realizada somente após o elemento ser inserido
ou extraído com sucesso. Portanto, entre outras consequências, esses lançamentos não são colocados
em cláusulasfinalmente : Se houvesse alguma chance de falhas nas operações do buffer , algumas
ações de recuperação seriam necessárias, mas essas instruções de lançamento à direita não estão entre elas.

class BoundedBufferWithSemaphores {buffer de


BufferArray final protegido; Semaphore final
protegido putPermits; protegido final Semáforo
takePermits;

public BoundedBufferWithSemaphores(int capacidade) {


if (capacity <= 0) lançar novo IllegalArgumentException(); buff = new
BufferArray(capacidade); putPermits = new
Semaphore(capacidade); takePermits = new Semaphore(0); }
Machine Translated by Google

public void put(Object x) throws InterruptedException {


putPermits.acquire();
buff.insert(x);
takePermits.release(); }

public Object take() lança InterruptedException { takePermits.acquire();


Objeto x = buff.extract();
putPermits.release(); retornar x; }

public Object poll(long msecs) throws InterruptedException { if (!


takePermits.attempt(msecs)) return null; Objeto x = buff.extract();
putPermits.release(); retornar x;

oferta pública booleana (objeto x, ms longos)


lança InterruptedException { if (!
putPermits.attempt(msecs)) return false; buff.insert(x);
takePermits.release();
retornar verdadeiro; } }

Essa classe também inclui variantes de colocar e receber, chamadas de oferta e pesquisa, que oferecem
suporte a políticas de bloqueio (quando msegs é 0) ou de tempo limite. Esses métodos
são implementados usando Semaphore.attempt, que lida com as confusas construções baseadas em tempo descritas em § 3.2.5.
Os métodos de oferta e pesquisa permitem que os clientes escolham a política de guarda mais adequada às
suas necessidades. No entanto, os clientes ainda devem escolher políticas compatíveis. Por exemplo, se um
produtor dependesse exclusivamente de offer(x, 0) e seu único consumidor usasse poll(0), os itens raramente seriam transferidos.

A classe BoundedBufferWithSemaphores provavelmente será executada com mais eficiência do que


a classe BoundedBufferWithStateTracking em § 3.3.1 quando houver muitos encadeamentos, todos
usando o buffer. BoundedBufferWithSemaphores depende de dois conjuntos de espera subjacentes
diferentes. A classe BoundedBufferWithStateTracking sobrevive com apenas um, portanto, qualquer
transição de estado vazio para parcial ou completo para parcial faz com que todos os threads em
espera sejam ativados, incluindo aqueles que aguardam a outra condição lógica e aqueles que
aguardarão novamente imediatamente porque algum outro thread demorou o único item ou preencheu o único espaço disponível.

A classe BoundedBufferWithSemaphores isola os monitores para essas duas condições.


Isso pode ser explorado pela implementação do Semaphore subjacente (consulte o § 3.7.1) para eliminar
a troca de contexto desnecessária usando notify em vez de notifyAll. Isso reduz o número de ativações do
pior caso de uma função quadrática do número de invocações para linear.
Machine Translated by Google

Geralmente, sempre que você pode isolar uma condição usando um semáforo, geralmente pode
melhorar o desempenho em comparação com soluções baseadas em notifyAll .

3.4.1.4 Canais síncronos

Conforme mencionado no § 3.3.1, a interface para BoundedBuffer pode ser ampliada para descrever
qualquer tipo de Canal que suporte uma operação de venda e recebimento :

canal de interface { // Repetido


void put(Object x) lança InterruptedException;
O objeto take() lança InterruptedException; }

(A versão util.concurrent dessa interface também inclui os métodos offer e poll que oferecem suporte a
tempos limite e declara que ele estende as interfaces Puttable e Takable para permitir a imposição de
tipos de usos unilaterais.)

Existem muitas semânticas possíveis que você pode anexar a um canal. Por exemplo, a classe de
fila em § 2.4.2 tem capacidade ilimitada (pelo menos conceitualmente falhando apenas quando um
sistema fica sem memória), enquanto buffers limitados têm capacidade predeterminada finita. Um caso
limite é a ideia de um canal síncrono sem capacidade interna. Com canais síncronos, cada
encadeamento que tenta colocar deve esperar por um encadeamento que tenta tomar e vice-versa. Isso
permite o controle preciso sobre a interação do thread necessário em vários dos frameworks e padrões
de design discutidos em § 4.1.4 e § 4.5.1.

Semáforos podem ser usados para implementar canais síncronos. Aqui, podemos usar a mesma
abordagem dos buffers limitados, adicionando outro semáforo que permite que um put continue somente
após o item oferecido ter sido obtido. No entanto, isso introduz um novo problema. Até agora,
usamos apenas construções de bloqueio que podem lançar InterruptedExceptions como as primeiras
linhas de métodos, permitindo uma saída simples e limpa após a interrupção. Mas aqui, precisamos
fazer uma segunda aquisição no final do método put . Abortar neste ponto sem retorno quebraria o
protocolo. Embora seja possível definir uma versão dessa classe que execute rollback completo, a
solução mais simples aqui é rolar para frente (consulte § 3.1.1.4), ignorando qualquer interrupção até que a segunda aquisição s

class SynchronousChannel implementa Canal {

item de objeto protegido = null; // para manter durante o trânsito

protegido final Semáforo putPermit; protegido final


Semáforo takePermit; Semaphore final protegido obtido;

public SynchronousChannel() {
putPermit = new Semaphore(1); takePermit
= new Semaphore(0); tomado = novo
Semaphore(0); }

public void put(Object x) throws InterruptedException {


Machine Translated by Google

putPermit.acquire(); artigo =
x;
takePermit.release();

// Deve esperar até ser sinalizado pelo tomador


InterruptedException catch = null; for (;;) { try
{ take.acquire();
quebrar;

} catch(InterruptedException ie) { capturado = ie; } }

if (pego != null) jogue pego; // agora pode relançar }

public Object take() lança InterruptedException {


takePermit.acquire(); Objeto
x = item; artigo = nulo;
putPermit.release();
take.release(); retornar x; }

3.4.1.5 Justiça e agendamento

Os métodos integrados de espera e notificação não fornecem nenhuma garantia de imparcialidade. Eles
não fazem promessas sobre quais threads em um conjunto de espera serão escolhidos em uma operação
de notificação , ou qual thread obterá o bloqueio primeiro e poderá prosseguir (excluindo outras) em
uma operação notifyAll .

Essa flexibilidade em implementações de JVM permitida pelo JLS torna praticamente impossível provar
propriedades de vivacidade específicas de um sistema. Mas esta não é uma preocupação prática na
maioria dos contextos. Por exemplo, na maioria dos aplicativos de buffer, não importa qual das várias
threads que tentam obter um item realmente o faz. Por outro lado, em uma classe de gerenciamento de pool de
recursos, é prudente garantir que os encadeamentos que aguardam itens de recursos necessários não
sejam continuamente deixados de lado por outros devido à injustiça na forma como as operações de notificação subjacentes escolhem
Preocupações semelhantes surgem em muitas aplicações de canais síncronos.

Não é possível alterar a semântica da notificação, mas é possível implementar operações de aquisição de
classe Semaphore (sub) para fornecer propriedades de equidade mais fortes. Uma variedade de políticas
pode ser suportada, variando exatamente como a justiça é definida.

A política mais conhecida é a First-In-First-Out (FIFO), na qual a thread que está esperando há mais tempo
é sempre selecionada. Isso é intuitivamente desejável, mas pode ser desnecessariamente exigente e até um
tanto arbitrário em multiprocessadores, onde diferentes threads em diferentes processadores começam a esperar em
Machine Translated by Google

(aproximadamente) ao mesmo tempo. No entanto, estão disponíveis vários enfraquecimentos e aproximações do FIFO
que fornecem justiça suficiente para aplicativos que precisam evitar adiamento indefinido.

Existem, no entanto, algumas limitações intrínsecas a essas garantias: Não há como garantir que um sistema subjacente
realmente executará um determinado processo ou encadeamento executável, a menos que o sistema forneça garantias
que vão além dos requisitos mínimos declarados no JLS. No entanto, é improvável que isso seja uma questão
pragmática significativa. A maioria, se não todas as implementações de JVM, se esforçam para fornecer políticas de
agendamento sensatas que se estendem muito além dos requisitos mínimos. Eles exibem algum tipo de propriedades de
imparcialidade fracas, restritas ou probabilísticas em relação à execução de threads executáveis.
No entanto, é difícil para uma especificação de linguagem estabelecer todas as maneiras razoáveis pelas quais isso pode
ocorrer. O assunto é deixado como uma questão de qualidade de implementação no JLS.

Classes de utilidade, como semáforos, são veículos convenientes para estabelecer diferentes políticas de imparcialidade,
modulo essas ressalvas de agendamento. Por exemplo, § 3.7.3 descreve a implementação de uma classe
FIFOSemaphore que mantém a ordem de notificação FIFO. Aplicativos como a classe Pool podem usar esta ou outras
implementações de semáforos que fornecem quaisquer propriedades de imparcialidade com suporte, com o custo
potencial de sobrecarga adicional.

3.4.1.6 Prioridades

Além de abordar a imparcialidade, as classes de implementação de semáforos podem prestar atenção às prioridades
do encadeamento. Não é garantido que o método notify faça isso, mas é claro que é permitido, e o faz em algumas
implementações da JVM.

As configurações de prioridade (consulte o § 1.1.2.3) tendem a ter valor apenas quando pode haver muito mais threads
executáveis do que CPUs, e as tarefas executadas nesses threads intrinsecamente têm diferentes urgências ou
importâncias. Isso ocorre mais comumente em sistemas de tempo real embutidos (suaves), onde um único processador
pequeno deve realizar muitas tarefas que interagem com seu ambiente.

A dependência de configurações de prioridade pode complicar as políticas de notificação. Mesmo que as notificações
desbloqueiem (e executem) threads na ordem de prioridade mais alta, os sistemas ainda podem encontrar inversões de
prioridade. Uma inversão de prioridade ocorre quando um thread de alta prioridade fica bloqueado aguardando a
conclusão de um thread de baixa prioridade e, em seguida, libera um bloqueio ou altera uma condição necessária para o thread
de alta prioridade. Em um sistema que usa agendamento de prioridade estrita, isso pode fazer com que o encadeamento
de alta prioridade morra de fome se o encadeamento de baixa prioridade não tiver a chance de ser executado.

Uma solução é usar classes de semáforos especiais ou classes de bloqueio construídas por meio desses semáforos.
Aqui, os próprios objetos de controle de concorrência manipulam as prioridades. Quando um thread de alta prioridade fica
bloqueado, o objeto de controle de simultaneidade pode aumentar temporariamente a prioridade de um thread de baixa
prioridade que poderia desbloqueá-lo. Isso reflete o fato de que prosseguir para um ponto de liberação é uma ação de alta
prioridade (consulte Leituras adicionais em § 1.2.5). Para que isso funcione, todas as sincronizações e bloqueios relevantes
devem contar com essas classes de utilitários de ajuste de prioridade.

Além disso, essa tática garante a manutenção das propriedades pretendidas apenas em implementações de JVM
específicas que usam agendamento de prioridade estrito. Na prática, qualquer implementação de JVM utilizável que
suporte programação de prioridade estrita certamente aplicará ajuste de prioridade para operações integradas de bloqueio e
monitoramento. Fazer o contrário anularia a maior parte da justificativa para a adoção de agendamento de prioridade estrito
em primeiro lugar.

A principal consequência prática é que os programas que dependem absolutamente de escalonamento prioritário estrito
sacrificam a portabilidade. Eles precisam de garantias específicas de implementação de JVM adicionais que podem ser
reforçadas por meio da construção e uso de utilitários de controle de simultaneidade adicionais. Em outros mais portáteis
Machine Translated by Google

programas, classes de semáforos e utilitários relacionados que preferem threads de prioridade mais alta ainda podem ser
usados ocasionalmente como dispositivos para melhorar heuristicamente a capacidade de resposta.

3.4.2 Travas
Uma variável ou condição de travamento é aquela que eventualmente recebe um valor do qual nunca mais muda. Uma
variável ou condição de trava binária (normalmente chamada apenas de trava, também conhecida como one-shot) pode
alterar o valor apenas uma vez, de seu estado inicial para seu estado final.

Técnicas de controle de simultaneidade envolvendo latches podem ser encapsuladas usando uma classe Latch simples que
novamente obedece à interface usual de aquisição-liberação, mas com a semântica de que uma única versão permite que
todas as operações de aquisição anteriores e futuras continuem.

Latches ajudam a estruturar soluções para problemas de inicialização (consulte § 2.4.1) onde você não deseja que um conjunto
de atividades prossiga até que todos os objetos e threads tenham sido completamente construídos. Por exemplo, um
aplicativo de jogo mais ambicioso do que o mostrado em § 3.2.4 pode precisar garantir que todos os jogadores esperem até
que o jogo comece oficialmente. Isso pode ser organizado usando código como:

class Player implementa Runnable { // Esboço do código


// ...
protegido final Latch startSignal;

Player(Trava l) { startSignal = l; }

public void run() { try

{ startSignal.acquire(); jogar();

} catch(InterruptedException ie) { return; } } // ... }

class Game { // ...


void
begin(int nplayers) { Latch startSignal
= new Latch();

for (int i = 0; i < nplayers; ++i) new Thread(new


Player(startSignal)).start();

startSignal.release(); } }

As formas estendidas de travas incluem contagens regressivas, que permitem que a aquisição prossiga quando ocorrer
um número fixo de liberações , não apenas uma. Travas, contagens regressivas e outros utilitários simples construídos
sobre eles podem ser usados para coordenar respostas a condições envolvendo:
Machine Translated by Google

Indicadores de conclusão. Por exemplo, para forçar um conjunto de encadeamentos a aguardar até que alguma outra
atividade seja concluída.

Limites de tempo. Por exemplo, para acionar um conjunto de threads em uma determinada data.

Indicações de eventos. Por exemplo, para acionar o processamento que não pode ocorrer até que um determinado pacote
seja recebido ou o botão seja clicado.

Indicações de erro. Por exemplo, para acionar um conjunto de encadeamentos para prosseguir com tarefas de desligamento global.

3.4.2.1 Variáveis e predicados de travamento

Embora as classes utilitárias sejam convenientes para a maioria dos aplicativos de disparo único, os campos de travamento
(também conhecidos como variáveis permanentes) e os predicados podem melhorar a confiabilidade, simplificar o uso e
melhorar a eficiência em outros contextos também.

Entre suas outras propriedades, predicados de travamento (incluindo o caso especial comum de indicadores de limite) estão
entre as poucas condições para as quais loops de espera ocupada não sincronizados (consulte § 3.2.6) podem ser uma opção de
implementação possível (embora raramente adotada) para proteção métodos. Se um predicado é conhecido por travar, não há
risco de escorregar (consulte § 3.2.4.1). Seu valor não pode mudar entre a verificação para ver se é verdadeiro e uma ação
subsequente que exige que ele permaneça verdadeiro. Por exemplo:

class LatchingThermometer { private // Raramente útil


volátil booleano pronto; // travando a temperatura do flutuador
volátil privado;

public double getReading() { while (!


ready)
Thread.yield();
temperatura de retorno; }

void sense(float t) { // chamado do sensor


temperatura = t; pronto
= verdadeiro; } }

Observe que esse tipo de construção é confinado a classes nas quais todas as variáveis relevantes são declaradas como

voláteis ou são lidas e escritas apenas sob sincronização (consulte § 2.2.7).

3.4.3 Permutadores

Um trocador atua como um canal síncrono (ver § 3.4.1.4) , exceto que em vez de suportar dois métodos, put e take, ele
suporta apenas um método, rendezvous (às vezes chamado apenas de troca) que combina seus efeitos (ver § 2.3.4) . Essa
operação recebe um argumento que representa um Objeto oferecido por uma thread para outra e retorna o Objeto oferecido pela
outra thread.

Os trocadores podem ser generalizados para mais de duas partes e podem ser ainda mais generalizados para aplicar
funções arbitrárias em argumentos, em vez de simplesmente trocá-los. Esses recursos são suportados
Machine Translated by Google

pela classe Rendezvous em util.concurrent. Mas a maioria dos aplicativos é restrita à troca de
objetos de recursos entre dois encadeamentos (conforme organizado abaixo, usando apenas o
construtor padrão de duas partes para Rendezvous).

Os protocolos baseados em troca estendem aqueles descritos no § 2.3.4 para servir como alternativas
aos pools de recursos (consulte o § 3.4.1.2). Eles podem ser usados quando duas ou mais tarefas em
execução em diferentes encadeamentos sempre mantêm um recurso. Quando um thread termina com
um recurso e precisa de outro, ele troca com outro thread. A aplicação mais comum desse
protocolo é a troca de buffer. Aqui, um thread preenche um buffer (por exemplo, lendo dados). Quando
o buffer está cheio, ele o troca com uma thread que processa o buffer, esvaziando-o. Dessa forma,
apenas dois buffers são usados, nenhuma cópia é necessária e um pool de gerenciamento de recursos torna-se desnecessár

A classe FillAndEmpty a seguir dá uma ideia das obrigações adicionais de tratamento de


exceções exigidas pelos trocadores. Como o protocolo é simétrico, o cancelamento ou o tempo limite
de uma parte no meio de uma tentativa de troca deve levar a uma exceção (aqui,
BrokenBarrierException) na outra parte. No exemplo abaixo, isso é tratado simplesmente retornando
do método run. Uma versão mais realista envolveria limpeza adicional, incluindo ajustes adicionais
para lidar com buffers incompletamente preenchidos ou esvaziados após o término, bem como para
lidar com exceções de E/S e condições de fim de arquivo em torno do método readByte .

class FillAndEmpty { static // Incompleto


final int SIZE = 1024; // tamanho do buffer, para trocador Rendezvous protegido
por demonstração = new Rendezvous(2);

byte protegido readByte() { /* ... */; } protected void


useByte(byte b) { /* ... */ }

public void start() { new


Thread(new FillingLoop()).start(); new Thread(new
EmptyingLoop()).start(); } class FillingLoop implements

Runnable { // classe interna public void run() { byte[] buffer = new byte[SIZE];
posição int = 0;

tente
(;;) {

if (posição == TAMANHO) {
buffer = (byte[])(exchanger.rendezvous(buffer)); posição = 0;

buffer[posição++] = readByte();
}

} catch (BrokenBarrierException ex) {} // die catch


(InterruptedException ie) {} // die }
Machine Translated by Google

class EmptyingLoop implementa Runnable { // classe interna


public void run() { byte[]
buffer = new byte[SIZE]; int posição =
TAMANHO; // força a troca na primeira vez através

tente
(;;) {

if (posição == TAMANHO) { buffer


= (byte[])(exchanger.rendezvous(buffer)); posição = 0; }

useByte(buffer[posição++]); }

} catch (BrokenBarrierException ex) {} // die catch


(InterruptedException ex) {} // die } }

O uso de trocadores aqui ilustra uma das vantagens de design de classes utilitárias que substituem as
preocupações que envolvem os campos de objetos por aquelas que envolvem a passagem de mensagens. Isso
pode ser muito mais fácil de lidar à medida que os esquemas de coordenação aumentam (consulte o Capítulo 4).

3.4.4 Variáveis de condição

As operações do monitor na linguagem de programação Java mantêm um único conjunto de espera para cada objeto.
Algumas outras linguagens e bibliotecas de encadeamento (em particular POSIX pthreads) incluem suporte para
vários conjuntos de espera associados a várias variáveis de condição gerenciadas em um objeto ou bloqueio comum.

Embora qualquer projeto que exija vários conjuntos de espera possa ser implementado usando outras
construções, como semáforos, é possível criar utilitários que imitem as variáveis de condição encontradas em outros sistemas.
Na verdade, o suporte para condvars no estilo pthreads leva a padrões de uso que são quase idênticos àqueles em
programas C e C++ simultâneos.

Uma classe CondVar pode ser usada para representar uma variável de condição que é gerenciada em conjunto com
um determinado Mutex, onde este Mutex também é (inexigivelmente) usado para todos os bloqueios de
exclusão na(s) classe(s) associada(s). Assim, as classes que usam CondVar também devem contar com
as técnicas de travamento "manual" discutidas em § 2.5.1. Mais de um CondVar pode usar o mesmo Mutex[6].

[6]
A recíproca de que mais de um Mutex atende à mesma variável de condição é logicamente possível,
mas geralmente reflete um erro de programação e não é suportada por esta classe.

A classe suporta análogos dos métodos padrão de espera e notificação, aqui dados nomes baseados naqueles em
pthreads:
Machine Translated by Google

CondVar { protected // Implementação omitida class


final Sync mutex; public CondVar(bloqueio
de sincronização) { mutex = bloqueio; }

public void await() gera InterruptedException; public boolean


timedwait(long ms) lançamentos
InterruptedException; public
void sinal(); transmissão public // analógico de notify //
void(); } analógico de notifyAll

(Na versão util.concurrent , as nuances dessas operações também refletem aquelas em pthreads.
Por exemplo, ao contrário de notificar, o sinal não requer que o bloqueio seja mantido.)

As principais aplicações dessa classe não estão nos esforços de design original, mas na adaptação de
código originalmente escrito usando outras linguagens e sistemas. Em outros aspectos, um CondVar
pode ser empregado nos mesmos padrões de projeto, encontrando os mesmos problemas de projeto,
conforme discutido em § 3.3. Por exemplo, aqui está outra classe de buffer limitada. Exceto pelo tratamento
estruturado de exceções, esta versão quase parece ter saído de um livro de programação pthreads (consulte Leituras Adicionais em

class PThreadsStyleBuffer {
private final Mutex mutex = new Mutex(); private final CondVar
notFull = new CondVar(mutex); private final CondVar notEmpty = new
CondVar(mutex); contagem int privada = 0; private int takePtr = 0; private int
putPtr = 0; array Object[] final
privado;

public PThreadsStyleBuffer(int capacidade) {


array = new Object[capacidade]; }

public void put(Object x) throws InterruptedException { mutex.acquire(); try { while


(count == array.length)

notFull.await();

array[putPtr] = x; putPtr =
(putPtr + 1) % matriz.comprimento; ++contar;

notEmpty.signal(); }

finalmente
{ mutex.release(); }

}
Machine Translated by Google

public Object take() lança InterruptedException { Object x = null;


mutex.acquire(); tente
{ while (count == 0)

notEmpty.await();

x = array[takePtr];
array[takePtr] = null; takePtr =
(takePtr + 1) % array.length; --contar; notFull.signal(); }
finalmente
{ mutex.release(); } retorna

x; } }

3.4.5 Leituras Adicionais


Discussões adicionais e exemplos de semáforos e variáveis de condição podem ser encontrados em quase todos os
livros sobre programação concorrente (consulte § 1.2.5).

Os pools de recursos podem ser estendidos para classes de Gerenciador de objetos mais gerais. Ver:

Sommerlad, Peter. "Manager", em Robert Martin, Dirk Riehle e Frank Buschmann (eds.), Pattern Languages of Program
Design, Volume 3, Addison-Wesley, 1998.

Os trocadores são descritos com mais detalhes em:

Sane, Aamod e Roy Campbell. "Resource Exchanger", em John Vlissides, James Coplien e Norman Kerth (eds.),
Pattern Languages of Program Design, Volume 2, Addison-Wesley, 1996.

A imparcialidade aproximada de algumas políticas de agendamento comumente usadas é discutida em:

Epema, Dick HJ "Escalonamento de Decaimento de Uso em Multiprocessadores", ACM Transactions on Computer


Systems, vol. 16, 367-415, 1998.

3.5 Ações Conjuntas

Até agora, este capítulo limitou-se principalmente a discussões sobre ações protegidas que dependem do estado de um
único objeto. As estruturas de ação conjunta fornecem um cenário mais geral para atacar problemas de design mais
gerais. De uma perspectiva de design de alto nível, as ações conjuntas são métodos protegidos atômicos que envolvem
condições e ações entre vários objetos participantes independentes. Eles podem ser descritos abstratamente
como métodos atômicos envolvendo dois ou mais objetos:

void jointAction(A a, B b) { // Pseudo-código


Machine Translated by Google

WHEN (canPerformAction(a, b))


performAction(a, b);
}

Problemas que assumem essa forma geral e irrestrita são encontrados no desenvolvimento de protocolos
distribuídos, bancos de dados e programação de restrições simultâneas. Como visto em § 3.5.2, até mesmo
alguns padrões de projeto de aparência comum que dependem de delegação requerem esse tipo de
tratamento quando ações independentes em objetos independentes devem ser coordenadas.

A menos que você tenha uma solução de propósito especial, a primeira ordem do negócio ao lidar com ações
conjuntas é traduzir intenções vagas ou especificações declarativas em algo que você possa realmente programar.
As considerações incluem:

Atribuição de responsabilidade. Qual objeto tem a responsabilidade de executar a ação? Um dos


participantes? Todos eles? Um coordenador separado?

Detecção de condições. Como você pode saber quando os participantes estão no estado certo para realizar
a ação? Você pergunta a eles chamando acessores? Eles lhe dizem sempre que estão no estado certo?
Eles dizem a você sempre que podem estar no estado certo?

Ações de programação. Como as ações em vários objetos são organizadas? Eles precisam ser atômicos?
E se um ou mais deles falhar?

Vinculando condições a ações. Como você garante que as ações ocorram apenas nas condições certas?
Os falsos alarmes são aceitáveis? Você precisa impedir que um ou mais participantes mudem de estado
entre o teste da condição e a execução da ação? As ações precisam ser executadas quando os participantes
entram nos estados apropriados, ou apenas quando as condições são mantidas? Você precisa impedir que
vários objetos tentem executar a ação ao mesmo tempo?

3.5.1 Soluções Gerais

Nenhum pequeno conjunto de soluções aborda todos os problemas em todos os contextos. Mas a
abordagem geral mais amplamente aplicável é criar designs nos quais os participantes dizem uns aos outros
quando estão (ou podem estar) em estados apropriados para uma ação conjunta, ao mesmo tempo em que se
impedem de mudar de estado novamente até que a ação seja executada.

Esses projetos fornecem soluções eficientes para problemas de ação conjunta. No entanto, eles podem ser
frágeis e não extensíveis, podendo levar a um alto acoplamento dos participantes. Eles são potencialmente
aplicáveis quando você pode construir subclasses especiais ou versões de cada uma das classes
participantes para adicionar notificações e ações específicas e quando você pode prevenir ou recuperar de
impasses que são intrínsecos em muitos projetos de ação conjunta.

O objetivo principal é definir notificações e ações dentro do código sincronizado que se aninha
corretamente nas chamadas incorporadas, em um estilo que lembra o despacho duplo e o padrão Visitor
(consulte o livro Design Patterns). Muitas vezes, boas soluções dependem da exploração de
propriedades especiais dos participantes e suas interações. A combinação de acoplamento direto e a
necessidade de explorar quaisquer restrições disponíveis para evitar falhas de vivacidade é responsável
pela alta dependência de contexto de muitos projetos de ação conjunta. Isso, por sua vez, pode levar a
classes com tanto código de finalidade especial que elas devem ser marcadas como finais.
Machine Translated by Google

3.5.1.1 Estrutura

Para concretude, as descrições a seguir são específicas para o caso de duas partes (para as classes A e B), mas
podem ser generalizadas para mais de dois. Aqui, mudanças de estado em qualquer um dos participantes
podem levar a notificações para o outro. Essas notificações podem, por sua vez, levar a ações coordenadas em um
ou em ambos os participantes.

Os projetos podem assumir uma das duas formas características. Versões planas acoplam objetos participantes diretamente:

Versões explicitamente coordenadas encaminham algumas ou todas as mensagens e notificações por meio de um
terceiro objeto (uma forma de Mediador, consulte o livro Design Patterns) que também pode desempenhar algum
papel nas ações associadas. A coordenação por terceiros raramente é uma necessidade absoluta, mas pode
adicionar flexibilidade e pode ser usada para inicializar objetos e conexões:

3.5.1.2 Classes e métodos

As seguintes etapas genéricas podem ser aplicadas ao construir as classes e métodos correspondentes:

Defina versões (geralmente subclasses) de A e B que mantêm referências umas às outras, juntamente
com quaisquer outros valores e referências necessárias para verificar suas partes em condições de acionamento
Machine Translated by Google

e/ou executar as ações associadas. Como alternativa, conecte os participantes indiretamente com a ajuda
de uma classe coordenadora.
Escreva um ou mais métodos que executam as ações principais. Isso pode ser feito escolhendo uma das
classes para abrigar o método de ação principal, que por sua vez chama os métodos auxiliares secundários
na outra. Alternativamente, a ação principal pode ser definida na classe coordenadora, por sua vez
chamando métodos auxiliares em A e B.
Em ambas as classes, escreva métodos sincronizados projetados para serem chamados quando o outro
objeto mudar de estado. Por exemplo, na classe A, escreva o método Bchanged e na classe B, escreva
Achanged. Em cada um, escreva o código para verificar se o objeto host também está no estado correto.
Se as ações resultantes envolverem ambos os participantes, elas deverão ser executadas sem perder nenhum
dos bloqueios de sincronização.
Em ambas as classes, providencie para que o método alterado do outro seja chamado em qualquer alteração
que possa desencadear a ação. Quando necessário, certifique-se de que o código de mudança de estado
que leva à notificação esteja devidamente sincronizado, garantindo que toda a sequência check-and-
act seja executada antes de quebrar os bloqueios mantidos em ambos os participantes no início da mudança.

Certifique-se de que as conexões e os estados sejam inicializados antes que as instâncias de A e B possam

receber mensagens que resultem em interações. Isso pode ser organizado mais facilmente por
meio de uma classe de coordenador.

Essas etapas são quase sempre de alguma forma simplificadas ou combinadas, explorando as restrições
dependentes da situação disponível. Por exemplo, várias subetapas desaparecem quando as notificações e/ou ações
são sempre baseadas em apenas um dos participantes. Da mesma forma, se as condições alteradas envolverem
predicados de travamento simples (consulte § 3.4.2), normalmente não há necessidade de sincronização para
vincular notificações e ações. E se for permitido estabelecer um bloqueio comum na classe do coordenador e usá-lo
para todos os métodos nas classes A e B (consulte § 2.4.5), você pode remover todas as outras sincronizações e tratá-
las como uma forma disfarçada de um problema de controle de concorrência de objeto único, usando
técnicas de § 3.2-§ 3.4.

3.5.1.3 Vivacidade

Quando todas as notificações e ações são simétricas entre os participantes, as etapas acima normalmente geram
designs com potencial para impasse. Uma sequência começando com uma ação que emite Achanged pode travar
contra uma emissão de Bchanged. Embora não haja uma solução universal, as estratégias de resolução de
conflitos para lidar com problemas de impasse incluem as seguintes abordagens. Alguns desses remédios exigem
retrabalho extensivo e refinamento iterativo.

Forçando a direcionalidade. Por exemplo, exigir que todas as alterações ocorram por meio de um dos participantes.
Isso só é possível se você tiver permissão para alterar as interfaces dos participantes.

Precedência. Por exemplo, usando a ordenação de recursos (ver § 2.2.6) para evitar sequências conflitantes.

Retrocessos. Por exemplo, ignorar uma obrigação de atualização se uma já estiver em andamento. Conforme ilustrado
no exemplo abaixo, a contenção de atualização geralmente pode ser simplesmente detectada e ignorada com segurança.
Em outros casos, a detecção pode exigir o uso de classes de utilitários que suportam tempos limite e a semântica pode
exigir que um participante repita a atualização em caso de falha.

Passagem de ficha. Por exemplo, permitir a ação apenas de um participante que detém um determinado recurso,
controlado por meio de protocolos de transferência de propriedade (ver § 2.3.4).
Machine Translated by Google

Enfraquecimento da semântica. Por exemplo, afrouxar as garantias de atomicidade quando elas não afetam a
funcionalidade mais ampla (consulte § 3.5.2).

Agendamento explícito. Por exemplo, representar e gerenciar atividades como tarefas, conforme descrito
em § 4.3.4.

3.5.1.4 Exemplo

Para ilustrar algumas técnicas comuns, considere um serviço que transfere automaticamente dinheiro de uma
conta poupança para uma conta corrente sempre que o saldo corrente cai abaixo de um determinado limite, mas
somente se a conta poupança não estiver no vermelho. Esta operação pode ser expressa como uma ação
conjunta de pseudocódigo:

void autoTransfer (verificação de conta bancária, // Pseudo-código


Economia de conta bancária,
limite longo,
transferência máxima longa) {
WHEN (checking.balance() < limite && poupança.saldo() >=
0) { quantia longa = poupança.saldo();
if (valor > maxTransfer) valor = maxTransfer;
poupança.retirada(valor); cheque.depósito(valor); }

Vamos basear uma solução em uma classe BankAccount simples :

class BankAccount { saldo


longo protegido = 0;

saldo longo sincronizado público() { return saldo; }

depósito nulo público sincronizado (quantia longa)


throwsInsufficientFunds { if (saldo +
valor < 0)
lança novos InsufficientFunds(); senão saldo
+=
valor;
}

public void saque (quantia longa) throws InsufficientFunds {


valor do depósito); } }

Aqui estão algumas observações que levam a uma solução:


Machine Translated by Google

Não há nenhuma razão convincente para adicionar uma classe de coordenador explícita. As interações
necessárias podem ser definidas em subclasses especiais de BankAccount.
A ação pode ser executada se o saldo corrente diminuir ou o saldo da poupança aumentar. A
única operação que faz com que qualquer um mude é o depósito (uma vez que a retirada é
definida aqui para chamar o depósito), portanto, as versões desse método em cada classe iniciam
todas as transferências.
Apenas uma conta corrente precisa saber sobre o limite e apenas uma conta poupança precisa saber
sobre o valor maxTransfer . (Outras fatorações razoáveis levariam a implementações ligeiramente
diferentes.)
No lado da economia, a verificação de condição e o código de ação podem ser agrupados definindo
o método único transferOut para retornar zero se não houver nada para transferir e, caso
contrário, para deduzir e retornar o valor.
No lado da verificação, um único método tryTransfer pode ser usado para lidar com alterações
iniciadas por verificação e economia.

Sem maiores cuidados, o código resultante seria propenso a impasses. Este problema é intrínseco em
ações conjuntas simétricas nas quais mudanças em qualquer um dos objetos podem levar a uma ação.
Aqui, tanto uma conta poupança quanto uma conta corrente podem iniciar suas sequências de depósitos ao
mesmo tempo. Precisamos de uma maneira de quebrar o ciclo que pode levar ao bloqueio de ambos ao
tentar invocar os métodos um do outro. (Observe que o impasse nunca ocorreria se exigissemos apenas que
a ação ocorresse ao verificar a diminuição dos saldos. Isso, por sua vez, levaria a uma solução mais simples.)

Para ilustração, o impasse potencial é abordado aqui de uma maneira comum (embora, é claro, não seja
universalmente aplicável), por meio de um protocolo de back-off não cronometrado simples. O método
tryTransfer usa uma classe de utilitário booleano que oferece suporte a um método testAndSet que define
atomicamente seu valor como true e relata seu valor anterior. (Como alternativa, o método try de um Mutex pode ser usado aqui.)

class TSBoolean { valor


booleano privado = false;

// definido como verdadeiro; retorna o valor antigo


public sincronizado booleano testAndSet() {
boolean oldValue = valor; valor =
verdadeiro; return
valorantigo; }

público sincronizado void clear() { valor = false; } }

Uma instância dessa classe é usada para controlar a entrada na parte sincronizada do método tryTransfer
do lado da verificação principal, que é o ponto de impasse potencial nesse design. Se outra transferência
for tentada por uma conta poupança enquanto outra estiver em execução (sempre, neste caso, iniciada pela
conta corrente), ela será simplesmente ignorada sem travamento. Isso é aceitável aqui, pois as
operações tryTransfer e transferOut em execução são baseadas no saldo de poupança atualizado mais
recentemente.
Machine Translated by Google

Tudo isso leva às seguintes subclasses muito especiais de BankAccount, ajustadas para funcionar apenas em seu
contexto específico. Ambas as classes dependem de um processo de inicialização (não mostrado) para estabelecer interconexões.

A decisão de marcar as classes como finais é uma decisão difícil. No entanto, há latitude suficiente para
pequenas variações nos métodos e protocolos, não impedindo que autores de subclasses experientes
modifiquem, digamos, as condições de transferência em shouldTry ou a quantia a ser transferida em
transferOut.

class ATCheckingAccount extends BankAccount {


poupança ATSavingsAccount protegida; limite longo
protegido; TSBoolean protegido
transferInProgress = new TSBoolean();

public ATCheckingAccount(long t) { limite = t; }

// chamado apenas na inicialização sincronizado


void initSavings(ATSavingsAccount s) { poupança = s; }

protected boolean shouldTry() { return balance < threshold; }

void tryTransfer() { // chamado internamente ou da poupança


if (!transferInProgress.testAndSet()) { // se não estiver ocupado ...
tente
{synced(this) { if (shouldTry())
saldo += poupança.transferOut(); }

} finalmente { transferInProgress.clear(); } }

depósito nulo público sincronizado (quantia longa)


throws InsufficientFunds { if (saldo +
valor < 0) throw new InsufficientFunds();

else
{ saldo += valor;
tenteTransfer();
}

} } class ATSavingsAccount extends BankAccount {

verificação ATCheckingAccount protegida; maxTransfer


longo protegido;

public ATSavingsAccount(long max) {


Machine Translated by Google

maxTransfer = max; }

// chamado apenas na inicialização sincronizado void


initChecking(ATCheckingAccount c) { verificação = c; }

sincronizado long transferOut() { // chamado apenas a partir da verificação de valor


longo =
saldo; if (valor > maxTransfer)
valor = maxTransfer; if (valor >= 0)
saldo -= valor;

valor devolvido;
}

depósito nulo público sincronizado (quantia longa)


throws InsufficientFunds { if (saldo +
valor < 0) throw new InsufficientFunds();

else
{ saldo += valor;
verificando.tryTransfer(); }

3.5.2 Observadores desacoplados

A melhor maneira de evitar os problemas de design e implementação relacionados a designs de ação conjunta
completa é não insistir que as operações abrangendo vários objetos independentes sejam atômicas em
primeiro lugar. A atomicidade total raramente é necessária e pode introduzir problemas adicionais de design
downstream que impedem o uso e a reutilização de classes.

Para ilustrar, considere o padrão Observer do livro Design Patterns:


Machine Translated by Google

No padrão Observer, Subjects (às vezes chamados de Observables) representam o estado de tudo o que estão
modelando (por exemplo, uma temperatura) e possuem operações para revelar e alterar esse estado.
Observadores de alguma forma exibem ou usam o estado representado por Assuntos (por exemplo,
desenhando diferentes estilos de Termômetros). Quando o estado de um Subject é alterado, ele apenas
informa a seus Observers que mudou. Os observadores são então responsáveis por sondar os Subjects para
determinar a natureza das alterações por meio de retornos de chamada, verificando se, por exemplo, as
representações do Subject precisam ser exibidas novamente em uma tela.

O padrão Observer é visto em algumas estruturas GUI, sistemas de publicação-assinatura e programas


baseados em restrições. Uma versão é definida nas classes java.util.Observable e
java.util.Observer, mas elas não são, até o momento, usadas em AWT ou Swing (consulte § 4.1.4).

É muito fácil codificar um design do Observer como uma ação conjunta sincronizada por engano, sem
perceber os possíveis problemas de vivacidade resultantes. Por exemplo, se todos os métodos em
ambas as classes forem declarados como sincronizados e Observer.changed puder ser chamado de fora do
método Subject.changeValue , seria possível que essas chamadas entrassem em conflito:
Machine Translated by Google

Este problema pode ser resolvido por uma das técnicas discutidas em § 3.5.1. No entanto, é mais fácil
e melhor apenas evitá-lo. Não há razão para sincronizar as operações em torno das notificações de
alteração, a menos que você realmente precise que as ações do Observador ocorram atomicamente em
conjunto com qualquer alteração no Assunto. Na verdade, esse requisito anularia a maioria das razões
para usar o padrão Observer em primeiro lugar.

Em vez disso, aqui você pode aplicar nossas regras padrão do § 1.1.1.1 e liberar bloqueios
desnecessários ao fazer chamadas de Subjects para Observers, o que serve para implementar o desacoplamento desejado.
Isso permite cenários em que um Subject muda de estado mais de uma vez antes que a alteração
seja percebida por um Observer, bem como cenários em que o Observer não percebe nenhuma
alteração ao invocar getValue. Normalmente, esses enfraquecimentos semânticos são perfeitamente
aceitáveis e até desejáveis.

Aqui está um exemplo de implementação em que Subject usa apenas um double como exemplo de
estado modelado. Ele usa a classe CopyOnWriteArrayList descrita em § 2.4.4 para manter
sua lista de observadores . Isso evita qualquer necessidade de bloqueio durante a travessia, o que
ajuda a satisfazer os objetivos do projeto. Para simplificar a ilustração, o Observer aqui é definido
como uma classe concreta (em vez de uma interface com várias implementações) e pode lidar com apenas um único Subject.

classe Assunto {

val duplo protegido = 0,0; // estado modelado protegido final


Observadores CopyOnWriteArrayList =
novo CopyOnWriteArrayList();

público sincronizado double getValue() { return val; } void sincronizado protegido


setValue(duplo d) { val = d; }

public void attach(Observer o) { observers.add(o); } public void detach(Observer


o) { observers.remove(o); }

public void changeValue(double newstate) { setValue(newstate);


for (Iterator it =
observers.iterator(); it.hasNext();)
((Observer)(it.next())).changed(this);
}

classe Observador {

protegido double cachedState; assunto final // último estado conhecido //


protegido assunto; apenas um permitido aqui

public Observer(Assunto s) { subj = s;


cachedState
= s.getValue(); mostrar(); }
Machine Translated by Google

público sincronizado void alterado(Assunto s){


if (s != subj) return; // apenas um assunto

double oldState = cachedState; cachedState


= subj.getValue(); // teste if (oldState != cachedState)
display();

protected void display() { // de


alguma forma exibe o estado do assunto; por exemplo apenas:
System.out.println(cachedState); }

3.5.3 Leituras Adicionais


As ações conjuntas servem como uma estrutura unificadora para caracterizar ações de várias partes na
linguagem de modelagem e especificação da DisCo:

Jarvinen, Hannu-Matti, Reino Kurki-Suonio, Markku Sakkinnen e Kari Systa. "Especificação Orientada a
Objetos de Sistemas Reativos", Proceedings, 1990 Conferência Internacional sobre Engenharia de
Software, IEEE, 1990.

Eles são perseguidos em um contexto ligeiramente diferente em PI, que também aborda diferentes sentidos de
justiça que podem ser aplicados a projetos de ação conjunta. Por exemplo, projetos para alguns
problemas evitam conspirações entre alguns participantes para matar outros de fome. Ver:

Francez, Nissim e Ira Forman. Processos interativos, ACM Press, 1996.

Para uma pesquisa abrangente de outras abordagens para coordenação de tarefas entre objetos e processos, consulte:

Malone, Thomas e Kevin Crowston. "The Interdisciplinary Study of Coordination", ACM Computing
Surveys, março de 1994.

Estruturas de ação conjunta podem fornecer a base para a implementação de mecanismos internos de suporte
a protocolos distribuídos. Para algumas apresentações e análises prospectivas de protocolos entre
objetos simultâneos e distribuídos, consulte:

Rosenschein, Jeffrey e Gilad Zlotkin. Rules of Encounter: Designing Conventions for Automated Negotiation
Between Computers, MIT Press, 1994.

Fagin, Ronald, Joseph Halpern, Yoram Moses e Moshe Vardi. Raciocínio sobre Conhecimento, MIT Press, 1995.

Uma estrutura de ação conjunta que acomoda falhas entre os participantes é descrita em:
Machine Translated by Google

Stroud, Robert e Avelino Zorzo. "A Distributed Object-Oriented Framework for Dependable Multiparty
Interactions", Proceedings of OOPSLA, ACM, 1999.

3.6 Transações

No contexto da programação OO concorrente, uma transação é uma operação realizada por um cliente
arbitrário que invoca um conjunto arbitrário de métodos em um conjunto arbitrário de objetos
participantes, tudo sem interferência de outras atividades.

A arbitrariedade dos participantes e das sequências de ação requer extensões das estratégias de
controle de ação conjunta discutidas em § 3.5. As técnicas de transação estendem a
sincronização e o controle baseados em delegação para situações em que cada participante pode não
estar ciente das restrições de atomicidade impostas às suas ações e não pode contar com soluções
estruturais mais eficientes. Em estruturas de transações, cada participante (e cada cliente) abre mão de
sua autonomia local para decidir como realizar o controle de concorrência. Os participantes devem, ao
contrário, chegar a um consenso decidindo como e quando realizar ações e/ou se comprometer com seus efeitos.

As estruturas de transação estão entre os exemplos mais famosos de como fornecer componentes
que implementam funcionalidades valiosas de uso geral às vezes tem o preço de introduzir um
grande número de obrigações do programador. As classes que suportam protocolos de transação podem
ser altamente utilizáveis e reutilizáveis. As estruturas de transação podem ser usadas para lidar com a
maioria dos problemas de simultaneidade discutidos neste livro. Mas eles contam com designs nos
quais cada classe, em cada camada de funcionalidade, suporta um protocolo de transação
padronizado que propaga o controle por camadas sucessivas. O peso dos frameworks de transação
geralmente restringe seu uso a contextos nos quais você realmente precisa configurar objetos para garantir a atomicidade de sequ

Por exemplo, você pode ignorar o controle transacional se souber todas as sequências de chamadas que
serão encontradas em um componente ou aplicativo. Nesse caso, você pode projetar especificamente
o suporte para cada um (usando quaisquer técnicas aplicáveis) sem ter que abordar o caso geral.
Essa talvez seja uma extensão extrema da ideia (consulte § 2.2.2) de preenchimento de objetos
sincronizados reutilizáveis com versões atômicas de métodos de conveniência frequentemente
necessários. Às vezes, essa é uma alternativa plausível, no espírito de fazer a coisa mais simples
e segura que poderia funcionar. Da mesma forma, você pode confiar inteiramente no bloqueio do lado do
cliente (consulte § 2.2.3) nos casos em que os clientes de alguma forma saibam como obter todos os
bloqueios necessários para uma determinada ação e como evitar possíveis impasses.

Esta seção fornece uma breve visão geral das técnicas baseadas em transações aplicáveis em contextos
de programação concorrente de propósito geral. Os designs apresentados aqui lidam apenas com
simultaneidade interna e não explicitamente com bancos de dados ou distribuição. Como até estruturas de
transações internas leves (pelo menos em um sentido relativo) são normalmente vinculadas a
outras restrições e recursos específicos do aplicativo, é improvável que você use as interfaces e
classes exatas descritas aqui (embora a maioria seja variantes simplificadas daquelas no net . pacote
jini ). E se você confiar em uma estrutura de transação padronizada, como JDBC ou JTS, encontrará
problemas adicionais vinculados ao suporte de persistência e serviços relacionados que estão fora do
escopo deste livro. No entanto, o exemplo final nesta seção (§ 3.6.4) ilustra como as ideias por
trás das transações podem ajudar a estruturar designs OO concorrentes mais comuns. Assim, os
principais objetivos desta seção são fornecer uma breve sinopse de como os sistemas de transação
estendem outras abordagens para controle de concorrência e apresentar técnicas que podem ser reduzidas conforme necessário
Machine Translated by Google

Como um exemplo em execução, considere escrever novamente uma operação de transferência para a classe
BankAccount em § 3.5.1. Do ponto de vista transacional, uma operação de transferência autônoma (sem quaisquer
provisões para transferências automáticas) se parece com:

pseudoclass AccountUser { // Pseudo-código

Registro do TransactionLogger; // qualquer tipo de instalação de registro // ...

// Tentativa de transferência; retorna verdadeiro se a transferência


pública booleana for bem-sucedida (valor longo,
fonte BankAccount,
Destino da conta bancária) {
TRANSACIONALMENTE {
if (source.balance() >= montante)
{ log.logTransfer(valor, origem, destino); source.withdraw(quantia);
destino.depósito(valor); retornar
verdadeiro;

}
senão retorna falso;
}
}
}

O pseudo-qualificador TRANSACTIONALLY indica que gostaríamos que esse código fosse executado de maneira
tudo ou nada, sem qualquer possibilidade de interferência de outras atividades. Uma vez implementada, esta
operação pode ser utilizada em um esquema de transferência automatizada do tipo descrito no § 3.5.1.
Além disso, a abordagem transacional permite maior flexibilidade do que visto em nossa solução específica,
embora com sobrecarga significativamente maior. Uma vez que as classes são equipadas com aparatos
transacionais, torna-se possível associar a transacionalidade a qualquer sequência de operações envolvendo transações bancárias.
contas.

3.6.1 Protocolos de Transação

As estruturas de transação dependem de formas estendidas das táticas antes/depois características da


maioria das estratégias de controle de simultaneidade. Aqui, a ação anterior é normalmente chamada de join
(ou às vezes, begin) e a ação posterior é chamada de commit. As principais diferenças entre junção/confirmação
e operações como aquisição/liberação de bloqueio decorrem do fato de que junção/confirmação exige
consenso entre o conjunto de objetos participantes: Todos os participantes devem concordar em iniciar e
encerrar as transações. Isso leva a protocolos de duas fases em torno do join e/ou commit primeiro para obter
consenso e depois para agir. Se algum participante discordar sobre ingressar ou se comprometer, a tentativa
de transação é abortada. A versão mais simples do protocolo básico é:

1. Para cada participante p, se p não puder entrar, aborte.


2. Para cada participante p, tente executar a ação de p.
3. Para cada participante p, se p não puder confirmar, abortar.
4. Para cada participante p, comprometa os efeitos da transação de p.
Machine Translated by Google

Como na maioria dos contextos de controle de concorrência, dois conjuntos complementares de políticas podem ser
aplicados a este protocolo. Na forma mais pura de transações otimistas, os participantes sempre podem entrar, mas nem
sempre podem se comprometer. Na forma mais pura de transações conservadoras, os participantes nem sempre podem
ingressar, mas, se o fizerem, sempre poderão se comprometer. Abordagens otimistas se aplicam melhor quando a
probabilidade de contenção é baixa o suficiente para compensar os custos de reversão. Abordagens conservadoras
se aplicam melhor quando é difícil ou impossível desfazer ações executadas durante transações. Porém, é raro encontrar
formas puras de cada um, e não é difícil criar enquadramentos que permitam misturas.

As formas mais clássicas de transações conservadoras podem ser implementadas apenas se as identidades de todos
os participantes puderem ser conhecidas antes que qualquer ação seja tomada. Isto nem sempre é possível. Em um sistema
OO, os participantes são apenas aqueles objetos cujos métodos são invocados durante alguma sequência de chamada
que é agrupada como uma transação. Devido ao polimorfismo, carregamento dinâmico, etc., geralmente é impossível
identificar todos de antemão; em vez disso, suas identidades se tornam conhecidas apenas à medida que a ação se desenrola.
Ainda assim, em muitos casos, pelo menos alguns participantes são conhecidos de antemão e podem ser sondados
antes de iniciar qualquer ação irrecuperável.

No entanto, na maioria das abordagens para transações OO conservadoras, os participantes se juntam apenas
provisoriamente. Eles ainda podem se recusar a se comprometer mais tarde, usando aproximadamente a mesma estratégia vista em transações otimist
Por outro lado, a reversão total nem sempre é necessária em abordagens otimistas. Algumas operações de avanço
podem ser permitidas se não afetarem a funcionalidade geral.

3.6.2 Participantes da Transação

Além de oferecer suporte a métodos para ingressar, confirmar, cancelar e (quando necessário) criar transações, cada
classe em uma estrutura de transação estruturada deve declarar todos os seus métodos públicos para adicionar um
argumento de controle de transação ao seu conjunto de argumentos normais.

Uma invocação de método que fornece um determinado argumento de transação serve como uma solicitação para
executar a ação associada em nome da determinada transação, mas sem se comprometer com seus efeitos até
que seja solicitado a fazê-lo. Os métodos assumem a forma:

ReturnType aMethod(Transaction t, ArgType args) lança...

Por exemplo, BankAccount.deposit seria declarado como:

depósito nulo (transação t, valor longo) lança ...

Transação é qualquer tipo que forneça as informações de controle necessárias. Essas informações de transação
devem ser propagadas por todos os métodos invocados no curso de uma transação específica, incluindo
chamadas aninhadas para objetos auxiliares. O tipo mais simples de argumento de transação é uma chave de transação
que identifica exclusivamente cada transação. Cada método em cada objeto participante é então responsável por usar
esta chave para gerenciar e isolar ações de acordo com a política de transação dada. Alternativamente, um argumento de
transação pode se referir a um objeto de controle ou coordenador especial que possui métodos que ajudam os
participantes a desempenhar suas funções nas transações.

É, no entanto, possível trapacear aqui, e muitos frameworks de transação o fazem. Por exemplo, os identificadores de
transação podem ser ocultados como dados específicos do segmento (consulte o § 2.3.2). O controle antes/depois pode
ser restrito a pontos de entrada interceptados em sessões que executam serviços fornecidos por componentes. Os
participantes podem ser determinados por meio de bytecodes de reflexão ou digitalização. E as obrigações de reversão
podem ser semiautomatizadas pela serialização de todo o estado de um componente e/ou aquisição de bloqueios na entrada em uma sessão de serviço
Esses tipos de tática podem ajudar a ocultar os detalhes do controle transacional dos criadores de aplicativos. Esse
Machine Translated by Google

vem com despesas gerais e restrições de uso que geralmente não valem a pena em estruturas de transação leves
que executam controle de simultaneidade interna.

3.6.2.1 Interfaces

As classes participantes devem implementar interfaces que definem os métodos usados no controle de transações.
Aqui está uma interface simples, mas representativa:

falha de classe estende exceção {}

interface Transactor {

// Insira uma nova transação e retorne true, se possível public boolean join(Transaction
t);

// Retorna verdadeiro se esta transação puder ser confirmada public boolean


canCommit(Transaction t);

// Atualiza o estado para refletir a transação atual public void


commit(Transaction t) throws Failure;

// Reverter estado (Sem exceção; ignorar se inaplicável) public void abort(Transaction


t);

Entre muitas outras variantes, é possível dividir a fase de junção de forma semelhante à fase de confirmação,
um canJoin preliminar seguido por uma junção obrigatória. O método canCommit geralmente é chamado de
preparação em estruturas de transação.

Para simplificar a ilustração, um único tipo de exceção de Falha está associado a essas operações, bem como a
todas as outras nesta série de exemplos. Objetos Participantes têm permissão para gerar exceções quando
encontram conflitos reais ou potenciais e quando são solicitados a participar de transações que não conhecem.
É claro que, na prática, você deseja subclassificar esses tipos de exceção e usá-los para fornecer informações
adicionais aos clientes em casos de falha.

Uma segunda interface ou classe é necessária para descrever os próprios objetos Transaction . Ao discutir as
operações básicas, podemos usar uma versão no-op:

classe Transação {
// adicione o que quiser aqui }

Novamente, nem é necessário associar um objeto a uma transação. Um argumento de transactionKey longo
e único simples pode ser suficiente no lugar de todos os usos de Transaction. No outro extremo, você pode
precisar de uma TransactionFactory para criar todas as transações. Isso permite que diferentes tipos de
objetos Transaction sejam associados a diferentes estilos de transações.
Machine Translated by Google

3.6.2.2 Implementações

Os participantes nas transações devem oferecer suporte a uma interface do participante da transação e a
uma interface que descreva suas ações básicas. Por exemplo:

interface TransBankAccount estende Transactor {

public long balance(Transaction t) lança Falha;

depósito público nulo (transação t, valor longo) gera fundos


insuficientes, falha;

retirada pública nula (transação t, valor longo)


lança InsufficientFunds, Failure;

No entanto, nem sempre é necessário fornecer assinaturas transacionais para métodos acessadores puros, como
balance aqui. Em vez disso (ou além das versões transacionais), esses métodos podem retornar o valor
confirmado mais recentemente quando chamados de clientes que não estão participando de transações.
Alternativamente, um tipo especial de transação nula (ou apenas passando null para o argumento
Transaction ) pode ser usado para denotar invocações únicas de métodos transacionais.

A abordagem mais comum para implementar classes transacionais envolve primeiro dividir as representações de
estado subjacentes em classes auxiliares separadas usando uma ação provisória ou uma abordagem de ponto
de verificação (consulte § 2.4.4 e § 3.1.1.3). Isso facilita a execução de alterações de estado virtual que são
atualizadas somente após as operações de confirmação e/ou revertidas durante as operações de
interrupção . Essa abordagem é especialmente apropriada em estruturas de transação que suportam
persistência, que geralmente exigem que as representações sejam isoladas de qualquer maneira para serem lidas
e gravadas com eficiência em discos e outras mídias.

Embora essa seja de longe a opção mais tratável, ela leva a um estilo de codificação às vezes desconfortável,
no qual os objetos devem fingir estar nos estados mantidos por representações associadas a transações
específicas. Cada método público normal realiza operações apenas na representação de estado associada
à determinada transação e invoca métodos em outros objetos que estão fazendo o mesmo.

As implementações de métodos transacionais (ambos métodos de controle e ações básicas) podem abranger
desde táticas otimistas até táticas conservadoras. A tabela a seguir esboça alguns destaques dos pontos finais
para métodos invocados com o argumento Transaction tx:

Método Optimistic Conservador

juntar Crie uma cópia do estado e associe-a a tx Retorne false se já estiver


(por exemplo, em uma tabela hash), junto participando de uma transação
com algum registro da versão do estado conflitante, opcionalmente primeiro
com o qual se originou. tentando uma espera cronometrada
Retorna verdadeiro. para que ela seja concluída.
Pergunte a todos os outros objetos
referenciados nos métodos de ação
se eles podem se juntar; retornar false se houver não pode.
Faça uma cópia de backup do atual
Machine Translated by Google

estado para recuperar no caso de um


aborto.
Registre que tx é a transação

atual; retornar verdadeiro.

métodos Se tx não for uma transação conhecida, primeiro Se tx não for uma transação atual, falha.
de ação junte-se a tx, falhando se a junção falhar.
Execute a ação básica no estado atual e/ou Execute a ação básica no estado atual e/

chamando outros métodos em outros objetos ou chamando outros métodos em


com o argumento tx, registrando a outros objetos unidos com o argumento tx.

identidade de todos esses objetos.


Em qualquer falha, marque tx como não Em qualquer falha, marque a transação
confirmável. atual como não confirmável.

abortar Jogue fora toda a contabilidade Se tx for a transação atual, redefina


associada ao tx. o estado para cópia de backup e registre
Propagar para todos os outros que não há transação atual.

participantes conhecidos.
Propagar para todos os outros
participantes conhecidos.

comprometer-se Salve a representação associada a tx como Jogue fora o backup; registrar que
estado atual. não há transação atual.

Propagar para todos os participantes conhecidos.


Propagar para todos os
participantes conhecidos.

podeCommit Retorna false se qualquer confirmação conflitante Pergunte aos outros participantes,
ocorreu desde a entrada em tx ou se qualquer retorne false se algum não puder.

outra transação conflitante já prometeu Retorna verdadeiro, a menos que

confirmar. tenha ocorrido uma falha local

Pergunte a todos os outros objetos referenciados durante uma ação.

no curso das ações se eles podem cometer;


retornar false se houver não pode.
Registre que tx prometeu cometer; retornar
verdadeiro.

Quando aplicado à classe BankAccount , tomar a opção mais simples possível em cada etapa leva a uma versão de estratégia mista que
provavelmente não é adequada para uso sério. Entre outras reduções de escala, ele mantém apenas uma única cópia de backup do
estado (como um único campo), portanto, pode ser usado apenas para transações não sobrepostas. Mas esta versão é
suficiente para ilustrar a estrutura geral das classes transacionais e também, implicitamente, quanto mais código seria necessário
para construir uma versão mais útil:

class SimpleTransBankAccount implementa TransBankAccount {

saldo longo protegido = 0; workBalance


longo protegido = 0; // Transação protegida por cópia de sombra única currentTx
= null; // transação única
Machine Translated by Google

balanço longo sincronizado público (Transação t) throws Failure { if (t != currentTx)


throw new
Failure(); return workingBalance; }

depósito nulo público sincronizado (transação t, valor longo)


lança Fundos Insuficientes, Falha {
if (t != currentTx) lançar novo Failure(); if (saldo de trabalho
< -quantia)
lança novos InsufficientFunds(); SaldoTrabalho
+= valor;
}

retirada anulada pública sincronizada (transação t, valor longo)


lança Fundos Insuficientes, Falha {
depósito(t, -quantia);
}

public sincronizado boolean join(Transaction t) { if (currentTx != null) return


false; correnteTx = t; workBalance = saldo; retornar
verdadeiro;

public sincronizado booleano canCommit(Transação t) { return (t == currentTx); }

public sincronizado void abort(Transação t) {


if (t == currentTx) currentTx
= null;
}

public sincronizado void commit(Transaction t) lança


Falha{ if (t !
= currentTx) lança novo Falha(); saldo = saldotrabalho;
correnteTx = nulo; }

As classes que obedecem à interface Transactor também podem empregar o compartilhamento arbitrário
de referências entre os participantes. Por exemplo, você pode construir uma conta Proxy que
encaminha mensagens para outra conta não relacionada e não controlada.
Machine Translated by Google

class ProxyAccount implementa TransBankAccount { private TransBankAccount


delegado;

public boolean join(Transação t) { return delegate.join(t); }

public long balance(Transação t) lança Falha {


return delegado.saldo(t); }

// e assim por diante... }

3.6.3 Criando Transações

As transações que empregam participantes obedecendo à interface Transactor assumem um formato padrão, realizando as
seguintes etapas:

Crie uma nova Transação.


Invoque a junção em todos os participantes (inicialmente conhecidos), falhando imediatamente se algum não puder entrar.
Tente toda a ação, abortando todos os participantes em qualquer falha e também revertendo quaisquer outras
ações auxiliares.
Após a conclusão, colete votos usando canCommit e, em seguida , confirme ou anule.

Na maioria dos aplicativos, simplifica as coisas se as classes que iniciam as transações também suportam a interface
Transactor . Eles também podem oferecer suporte a outros métodos que configuram registros e assuntos de contabilidade
relacionados.

É possível automatizar muitos aspectos desse protocolo, redistribuir ou centralizar funcionalidades e incorporar recursos
adicionais. Por exemplo, uma quantidade arbitrária de esforço pode ser gasta calculando se uma transação pode ser unida e/ou
confirmada para minimizar a probabilidade e a despesa de abortos. As ações e a estrutura do participante de transações
potencialmente conflitantes podem ser analisadas e manipuladas (por exemplo, por meio do uso de conjuntos de conflitos,
consulte § 3.3.2) para permitir sobreposições nos casos em que você pode determinar que nenhum conflito é possível.

Da mesma forma, as estratégias de bloqueio podem ser refinadas para usar bloqueios de leitura e gravação, ou ainda mais refinadas
para oferecer suporte a atualizações de bloqueio e bloqueios de intenção (consulte § 3.3.3.1).

3.6.3.1 Exemplo

A seguinte versão da operação de transferência lida com vários tipos de falhas potenciais:

Falha semântica. Pode não haver fundos suficientes nas contas, caso em que o método retorna false. Neste exemplo, não há
sequer uma pré-verificação de que a fonte possui saldo suficiente. Mesmo que seja verdadeiro, a tentativa de retirada pode falhar
de qualquer maneira. Da mesma forma, como o valor pode ser negativo, destination.deposit pode falhar mesmo se source.withdraw
for bem-sucedido.
(Para um valor negativo, um depósito funciona como uma retirada e vice-versa.) Exceções adicionais podem ser detectadas aqui
para lidar com outros erros encontrados nesses métodos.
Machine Translated by Google

Interferência. Se uma das contas não puder ingressar ou não puder se comprometer com esta transação
devido à interferência de outra transação simultânea, uma exceção será lançada indicando que a ação pode ser repetida.

Erro de transação. Falha de operação catastrófica e irrecuperável pode ocorrer se os objetos não forem
confirmados depois de dizerem que podem. É claro que esses métodos devem fazer tudo ao seu alcance
para evitar falha no commit, pois não há nada a ser feito sobre esse erro interno. Aqui, a exceção é propagada
de volta aos clientes. Em uma versão mais realista, isso pode, por sua vez, desencadear uma recuperação
do último registro persistente do estado do objeto.

A ação de recuperação para cada um desses casos é idêntica neste exemplo (e é fatorada em um método
auxiliar). As cláusulas abort executam as reversões de estado. Mas o log deve ser cancelado de forma
independente.

classe FailedTransferException estende exceção {} classe


RetryableTransferException estende exceção {}

class ContaUsuário {
Registro do TransactionLogger; // uma classe inventada

// método auxiliar chamado em qualquer falha void


rollback(Transaction t, long amount,
TransBankAccount src, TransBankAccount dst) {
log.cancelLogEntry(t, quantidade, origem, dst); src.abort(t);
dst.abort(t); }
transferência

pública booleana (valor longo,


TransBankAccount src,
TransBankAccount dst)
lança FailedTransferException, RetryableTransferException {

if (src == null || dst == null) // os argumentos da tela geram um novo


IllegalArgumentException(); // evita aliasing if (src == dst)
return true;

Transação t = new Transação(); log.logTransfer(t,


quantidade, origem, dst); // registro

if (!src.join(t) || !dst.join(t)) { // não pode entrar


rollback(t, quantidade, origem, dst); lançar
novo RetryableTransferException();
}

tente
{ src.withdraw(t, quantidade);
dst.deposit(t, valor);

} catch (InsufficientFunds ex) { // falha semântica


rollback(t, quantidade, origem, dst); retorna
falso;
Machine Translated by Google

} catch (falha k) { // erro de transação


rollback(t, quantidade, origem, dst); lançar
novo RetryableTransferException(); }

if (!src.canCommit(t) || !dst.canCommit(t)) { // interferência

rollback(t, quantidade, origem, dst); lançar


novo RetryableTransferException();
}

tente
{ src.commit(t);
dst.commit(t);
log.logCompletedTransfer(t, quantidade, origem, dst); retornar
verdadeiro;

} // falha de compromisso catch(Failure k) { rollback(t, amount, src, dst); lançar


novo
FailedTransferException(); }

}
}

3.6.4 Alterações Vetáveis

O fato de que as estruturas de transação podem se tornar quase arbitrariamente pesadas às vezes faz
com que os desenvolvedores negligenciem soluções transacionais mais simples em esforços de design
simultâneo de menor escala. Concluímos esta seção com um problema de design mais comum que é
facilmente resolvido de maneira transacional.

Na estrutura JavaBeansTM, os objetos de componente possuem conjuntos de campos de propriedades que


suportam os métodos get e set . As propriedades restritas podem oferecer suporte a métodos de conjunto
vetáveis . Um componente de host pode ter uma lista de ouvintes para os quais envia eventos de alteração
vetáveis no curso de um método set vetável . Se algum ouvinte responder a um evento com uma
PropertyVetoException, uma tentativa de definir a propriedade deverá ser cancelada.

Alguns componentes expressam muitas de suas operações como alterações de propriedade vetáveis. Por
exemplo, uma tentativa de sair de um aplicativo editor pode ser implementada como um método definido ,
vetável por quaisquer documentos que ainda não tenham sido salvos, bem como por diálogos de confirmação.

Mudanças vetáveis empregam um protocolo de transação simplificado que tem apenas um participante ativo,
mas possivelmente vários participantes passivos que devem ser consultados para consenso. Isso pode ser feito
de maneira conservadora (ou seja, antes de executar a atualização) ou otimista (ou seja, depois de executar a
atualização provisoriamente).

Aqui estão algumas notas básicas sobre o suporte java.beans necessário para construir qualquer solução:
Machine Translated by Google

Os ouvintes são normalmente estruturados de forma idêntica aos observadores discutidos em §


3.5.2 , exceto que são acionados por meio de eventos que contêm informações sobre
alterações. No caso normal discutido
aqui, o método baseado em evento vetoableChange(PropertyChangeEvent evt) é invocado
diretamente para cada ouvinte em vez de ser mantido nas filas descritas em § 4.1.
As classes VetoableChangeSupport e PropertyChangeSupport no pacote java.beans podem ser
usadas para gerenciar multicasts para ouvintes. Mas, como sempre, adotaremos versões copy-
on-write que permitem multicast sem bloqueio. A versão abaixo usa VetoableChangeMulticaster
e PropertyChangeMulticaster de util.concurrent, sendo que ambos suportam as mesmas interfaces
das versões java.beans . Eles fornecem métodos para anexar e desanexar ouvintes
semelhantes aos descritos em § 2.4.4.

O método VetoableChangeMulticaster.fireVetoableChange constrói e faz multicast de um


PropertyChangeEvent com campos de evento indicando o nome da propriedade, seu valor
antigo e seu novo valor proposto.

Como um exemplo simples ilustrando técnicas básicas, considere um componente ColoredThing com
uma propriedade de cor vetável . Cada ColoredThing pode ter vários vetadores, assim como vários
ouvintes comuns que são notificados a cada atualização. Usaremos uma solução simples de estilo conservador.

Quando um ColoredThing recebe uma solicitação para setColor(Color newColor), ele executa as seguintes
etapas:

1. Verifique se outra tentativa de operação setColor já está em andamento, se estiver


lançando um PropertyVetoException. Para gerenciar isso, a classe mantém uma variável de
estado de execução booleana indicando se uma alteração está pendente. Uma versão mais
sofisticada (mas provavelmente menos desejável) poderia, em vez disso, esperar outras
transações usando uma construção wait/notifyAll baseada em changePending.
2. Verifique se o argumento é nulo e, nesse caso, recuse também a alteração da propriedade. Isso
ilustra como um componente pode, de certa forma, vetar suas próprias mudanças.
3. Invoque fireVetoableChange, que faz multicast para vetoers.
4. Se uma PropertyVetoException resultar do evento change, interrompa e lance novamente a
exceção. Caso contrário, atualize o campo de cor, limpe o sinalizador pendente e envie um evento
de alteração para todos os ouvintes de propriedade. Como uma proteção extra aqui, o método
mantém uma variável completa usada para detectar exceções de tempo de execução. A cláusula
final garante que o sinalizador changePending seja redefinido corretamente se o método encontrar tal exceção.

classe Coisa Colorida {

cor protegida myColor = Color.red; // a propriedade de amostra protegida boolean


changePending;

// ouvintes vetáveis: protegidos


final VetoableChangeMulticaster vetoers =
novo VetoableChangeMulticaster(este);

// também alguns ouvintes comuns: protected


final PropertyChangeMulticaster listeners =
new PropertyChangeMulticaster(this);
Machine Translated by Google

// métodos de registro, incluindo: void


addVetoer(VetoableChangeListener l)
{ vetoers.addVetoableChangeListener(l); }

public sincronizado Color getColor() { // acessador de propriedade


return minhaCor; }

// métodos auxiliares internos protegidos


sincronizados void commitColor(Color newColor) { myColor = newColor;
alteraçãoPendente = false; }

void sincronizado protegido abortSetColor() { changePending =


false; }

public void setColor(Color newColor) lança


PropertyVetoException {
Cor corantiga = null; booleano
concluído = false;

sincronizado (este) {

if (changePending) { // permite apenas uma transação por vez


tempo
lançar novo PropertyVetoException (
"Modificação concorrente", null);

} else if (newColor == nulo) { // Triagem de argumentos


lançar novo PropertyVetoException (
"Não é possível alterar a cor para Nulo", null);

} else
{ changePending = true;
corvelha = minhaCor; } }

tente
{ vetoers.fireVetoableChange("color", oldColor, newColor); // falha se não houver
exceção: commitColor(newColor); concluído =
verdadeiro; // notifica outros
ouvintes que a alteração
foi confirmada listeners.firePropertyChange("color", oldColor, newColor);
Machine Translated by Google

} catch(PropertyVetoException ex) { // aborta no veto abortSetColor();


concluído =
verdadeiro; jogue ex; }
finalmente

{ if (! // captura qualquer exceção não verificada


completado) abortSetColor(); } }

3.6.5 Leituras Adicionais


Relatos mais completos de transações em sistemas de banco de dados podem ser encontrados em:

BACON, Jean. Sistemas Concorrentes, Addison-Wesley, 1993.

Cellary, Wojciech, E. Gelenbe e Tadeusz Morzy, Concurrency Control in Distributed Database Systems, North-Holland,
1988.

Gray, Jim e Andreas Reuter. Processamento de Transações: Conceitos e Técnicas, Morgan Kaufmann, 1993.

Khoshafian, Setrag. Bancos de Dados Orientados a Objetos, Wiley, 1993.

Lynch, Nancy, Michael Merritt, William Weihl e Alan Fekete. Transações Atômicas, Morgan Kaufmann, 1994.

O seguinte abrange a programação de banco de dados usando JDBC:

White, Seth, Maydene Fisher, Rick Cattell, Graham Hamilton e Mark Hapner. Tutorial e Referência da API JDBC,
Segunda Edição, Addison-Wesley, 1999.

3.7 Implementando Utilitários


Classes e métodos utilitários podem encapsular implementações eficientes, confiáveis e de propósito geral de
construções de controle de simultaneidade de uma maneira que permite que sejam usadas quase como se fossem
parte da linguagem propriamente dita. Essas classes podem capturar construções inteligentes, complexas e propensas a
erros e explorar casos especiais com eficiência, empacotando os resultados para que os programas que as usam
possam ser escritos de forma mais simples, confiável e, muitas vezes, com melhor desempenho. Vale a pena o esforço de
desenvolvimento para chegar a essas classes apenas uma vez e somente quando justificado por preocupações reais de design.

Esta seção ilustra algumas técnicas usadas na construção de utilidades comuns. Todos eles contam com táticas gerais
de design e implementação descritas anteriormente neste livro, mas também introduzem algumas construções
especializadas adicionais que normalmente surgem apenas ao construir classes de suporte.

A seção começa ilustrando como empacotar protocolos de aquisição e liberação em uma interface comum.
Isso é seguido por um exemplo que mostra como aplicar técnicas de design de ação conjunta para dividir classes em
partes para obter o controle de concorrência necessário e, em seguida, recombiná-las para
Machine Translated by Google

melhorar a eficiência. Por fim, discute como isolar threads em espera para gerenciar notificações.

3.7.1 Protocolos de Aquisição-Liberação

Conforme discutido em § 2.5.1 e § 3.4.1, muitas construções de controle de simultaneidade estão em


conformidade com um protocolo de liberação de aquisição que pode ser englobado na interface simples:

interface Sync
{ voidadquire() lança InterruptedException; liberação nula();
tentativa booleana
(mseg longo) lança InterruptedException; }

O suporte a essa interface sob uma determinada semântica (por exemplo, bloqueios, semáforos, latches) requer que
as representações de estado interno que conduzem esperas e notificações sejam gerenciadas pelos objetos
Sync , não pelas classes que os utilizam. Além disso, todo o controle deve ser colocado dentro dos métodos
exportados; ele não pode ser espalhado em torno de outros métodos em classes de cliente e é uma má ideia
introduzir outros métodos que os clientes devem usar de uma maneira especial e não padrão para obter o comportamento desejado.

A maioria dos problemas e preocupações resultantes pode ser ilustrada com um exemplo de implementação da classe
básica Semaphore discutida em § 3.4.1. As implementações de outras classes Sync seguem padrões semelhantes.
(Na verdade, como mostrado em § 3.4.1, classes como Mutex podem, por sua vez, ser definidas usando
semáforos.)

Tanto no nível conceitual quanto representacional, um semáforo mantém uma contagem do número de licenças
que ele gerencia. A ideia básica é que uma aquisição deve esperar (se necessário) até que haja pelo menos uma
permissão e que uma liberação deve incrementar o número de permissões e fornecer notificações para
quaisquer threads em espera. Aqui estão algumas outras observações e escolhas que levam a uma implementação:

Como todos os threads em espera estão aguardando permissões e uma versão adiciona uma permissão,
podemos usar notify em vez de notifyAll, levando a notificações mais baratas. Além disso, a técnica extra de
notificação na interrupção descrita em § 3.2.4.2 está disponível para evitar perdas quando as threads são
interrompidas na hora errada.
Como se destina a ser uma classe de uso geral, devemos jogar pelo seguro e usar long (não int) para
contagens. Isso evita qualquer possibilidade prática de estouro de valor e custa quase nada em comparação
com o overhead do monitor.
Para manter a capacidade de resposta, devemos verificar se o thread atual não foi interrompido antes de
adquirir qualquer bloqueio. Isso minimiza as janelas de vulnerabilidade para encadeamentos do cliente
que ficam presos esperando por bloqueios quando deveriam se cancelar (consulte § 3.1.2). Ele também
fornece uma garantia mais uniforme de que InterruptedException será lançado se o thread entrar em um
estado interrompido, em vez de ter a exceção lançada apenas se o thread bloquear na espera interna.

class Semaphore implementa Sync {

licenças longas protegidas; // número atual de licenças disponíveis


Machine Translated by Google

public Semaphore(long initialPermits) { permite =


initialPermits; }

public sincronizado void release() { ++permits;


notificar(); }
public void

adquir() lança InterruptedException {


if (Thread.interrupted()) lança uma nova InterruptedException(); synced(this) { try { while
(permite <= 0) wait(); --

permitem;

} catch (InterruptedException ou seja) { notify();


jogar ou
seja; }

}
}

lances de tentativas booleanas públicas (ms longos)


InterruptedException{
if (Thread.interrupted()) lança uma nova InterruptedException(); sincronizado(este) { if
(permite > 0) { --permite;
retornar verdadeiro; // o mesmo que adquirir, mas mais bagunçado

} senão se (ms <= 0) // evita espera cronometrada se não for necessário


retorna falso; else
{ try
{ long
startTime = System.currentTimeMillis(); long waitTime = ms;

for (;;)
{ wait(waitTime); if
(permite > 0) { --permite;
retornar
verdadeiro;

} // verifica o tempo limite else { long now =


System.currentTimeMillis(); waitTime = msecs - (agora -
startTime); if (waitTime <= 0) return false;

}
Machine Translated by Google

} catch(InterruptedException ou seja) { notify();


jogar ou
seja; }

}}

3.7.2 Ações delegadas


Projetos de ação conjunta podem ser usados para abordar uma fonte potencial de ineficiência em métodos protegidos
nos quais diferentes encadeamentos em um conjunto de espera aguardam diferentes condições lógicas. Um
notifyAll destinado a alertar os encadeamentos sobre uma condição também ativa os encadeamentos que aguardam
condições completamente não relacionadas. Sinais inúteis e os "rebanhos trovejantes" resultantes de trocas de contexto
podem ser minimizados delegando operações com diferentes condições de espera para diferentes objetos auxiliares.

Conseguimos esse efeito quase sem esforço usando semáforos em § 3.4.1. Aqui, procederemos de baixo para cima,
potencialmente alcançando melhor desempenho explorando as propriedades especiais de problemas de projeto
específicos. Vale a pena usar as técnicas aqui apenas quando um problema de design é passível de otimizações
que podem ser aplicadas apenas à mecânica de espera e notificação.

A divisão de classes com ações dependentes de estado estende as ideias vistas em § 2.4.2 para dividir objetos com
relação a bloqueios, bem como algumas do padrão States as Objects (consulte Padrões de projeto). No entanto, o
espaço de design é restrito a uma faixa estreita de construções devido a restrições, incluindo:

Como os auxiliares devem acessar o estado comum, não é possível isolar totalmente cada auxiliar junto
com sua própria representação independente. Acesso independente a representações comuns entre
auxiliares requer sincronização apropriada.
Cada um dos ajudantes que podem afetar as condições de guarda para outro deve fornecer
notificações eficazes, evitando problemas de vivacidade.
A sincronização de métodos auxiliares envolvendo mecânica de espera deve evitar problemas de monitores
aninhados (§ 3.3.4).

3.7.2.1 Etapas de projeto

Uma abordagem geral para essas restrições é primeiro decompor a classe Host em suas menores partes possíveis: uma
classe para a representação de estado compartilhado e uma para cada tipo de auxiliar. Você pode então lidar com o
problema de design de ação conjunta coordenada resultante. Por fim, você pode organizar as peças em classes úteis:

Defina uma classe, digamos Representação, para conter campos usados em mais de um método.
Esta é apenas uma classe de estilo de registro com campos não privados, permitindo que acessos
arbitrários e atualizações sejam realizados dentro de blocos sincronizados especiais.
Defina uma classe Helper para cada conjunto de funcionalidade que compartilhe as mesmas condições de espera.
Cada classe Helper requer variáveis de instância referenciando o host e a representação (essa referência
pode ser indireta por meio do host).
Machine Translated by Google

Defina a classe Host como um pass-through: Cada método Host público deve ser um método
de encaminhamento não sincronizado. Além disso, defina métodos não sincronizados projetados
para serem chamados por auxiliares sempre que eles mudarem de estado de maneira que possam
afetar outros auxiliares. Retransmita as chamadas de notify ou notifyAll associadas. (Como alternativa,
essas notificações podem ser enviadas diretamente entre os auxiliares.) O host também deve inicializar todos os objetos auxiliares

Cada método auxiliar deve evitar falhas de vivacidade enquanto ainda preserva a segurança. Em
particular: o Se as verificações de condição envolverem a representação compartilhada, elas
devem ser executadas enquanto a representação e o
auxiliar estiverem bloqueados. o O bloqueio de representação deve ser liberado antes de entrar em
qualquer espera, mas o bloqueio no ajudante deve ser mantido para evitar sinais perdidos
(ver § 3.2.4) nos quais as esperas são iniciadas após
as notificações já terem ocorrido. o Os relés de notificação devem ser iniciados sem sincronização para evitar possíveis
impasses.

Um método auxiliar genérico pode assumir a forma:

void doM() throws InterruptedException { for(;;) { //


loop de espera // check->wait deve bloquear
>act deve bloquear rep thissynced(this) {synched(rep) { // check-
boolean canAct = inRightState( representante); if (canAct) {

atualizar(representante); // a ação protegida


Machine Translated by Google

quebrar;

// libera o bloqueio do representante antes de


}} esperar // fall-through if !canAct // libera
espere(); } o bloqueio antes do sinal

} host.signalChange(); }

3.7.2.2 Buffers limitados

Como nossos últimos exemplos de BoundedBuffer, criaremos versões delegadas que também exploram
características especiais da estrutura de dados subjacente e do algoritmo para obter melhor desempenho. O
resultado final é apenas um pouco mais rápido que as versões anteriores, mas serve para exemplificar as técnicas.

Primeiro, precisamos dividir os objetos auxiliares para colocar e tirar. Projetos de delegação normalmente
requerem uma classe auxiliar por método. Mas aqui, podemos obter apenas uma classe auxiliar (com duas
instâncias) explorando uma observação sobre transferências de propriedade. Conforme observado
no § 2.3.4, a troca de operação única pode ser usada para transferências de estilo put e take.
Por exemplo, exchange(null) realiza uma tomada. A versão de troca baseada em buffer substitui o valor
antigo pelo argumento no slot do array atual e então avança circularmente para a próxima posição do
array.

É conveniente definir a classe auxiliar Exchanger como uma classe interna para obter acesso ao host e à
matriz que serve como representação compartilhada. Também precisamos de uma variável de contador
de slots para indicar quando uma operação de troca deve parar porque não há mais itens. Para o ajudante
que está colocando, o contador começa na capacidade; para take, começa em zero. Uma operação de
troca pode prosseguir apenas se o número de slots for maior que zero.

Cada operação de troca bem-sucedida diminui a contagem. As esperas em contagem zero podem ser
liberadas apenas pelo auxiliar que executa a operação complementar, que deve fornecer uma notificação.
Isso é implementado pela emissão de um AddedSlotNotification para o outro trocador, conforme
retransmitido pelo host.

Outra consideração especial neste projeto particular leva a outra economia menor. Mesmo que a matriz de
dados deva ser compartilhada entre os dois auxiliares, ela não precisa de proteção de sincronização,
desde que colocar e receber sejam as únicas operações com suporte. Isso pode ser garantido declarando
a classe do host como final. Podemos nos virar sem um bloqueio de sincronização porque, nesse
algoritmo, qualquer put em execução deve estar operando em um slot de array diferente daquele
que está sendo acessado por qualquer take em execução. Além disso, as sincronizações externas são
suficientes para evitar problemas de visibilidade de memória (ver § 2.2.7). Em contraste, a classe
BoundedBufferWithSemaphores requer bloqueio em torno de operações de matriz porque, de outra forma,
não restringe no máximo um put ou take a ocorrer em um determinado momento.

Como uma melhoria adicional de desempenho, as notificações aqui usam notify, desde que as condições
para seu uso (discutidas em § 3.2.4.2) sejam atendidas: (1) Cada tarefa em espera em cada auxiliar
está esperando na mesma condição lógica (não vazio para take , e não plenitude para colocar). (2) Cada
notificação permite que no máximo um único encadeamento continue, cada entrada permite uma
tomada e cada tomada permite uma entrada. (3) Podemos notificar novamente para lidar com interrupções.
Machine Translated by Google

E para extrair um pouco mais de eficiência disso, é simples aqui rastrear (conservadoramente) se há algum
encadeamento em espera e emitir notificação apenas se houver encadeamentos que precisam ser notificados.
O efeito de desempenho dessa tática varia entre as implementações da JVM. À medida que as operações de
notificação se tornam cada vez mais baratas, a pequena sobrecarga de contabilidade aqui para evitar
chamadas torna-se cada vez menos valiosa.

classe final BoundedBufferWithDelegates { private Object[]


array; taco trocador privado;
tomador de trocador particular;

public BoundedBufferWithDelegates (capacidade int)


lança IllegalArgumentException {
if (capacity <= 0) lançar novo IllegalArgumentException(); array = new
Object[capacidade]; putter = new
Exchanger(capacidade); tomador = new
Trocador(0);
}

public void put(Object x) throws InterruptedException {


putter.exchange(x); }

public Object take() lança InterruptedException {


return tomador.troca(nulo); }

void removeSlotNotification(Exchanger h) { // retransmitir if (h == putter)


taker.addedSlotNotification(); putter.addedSlotNotification(); outro

classe protegida Exchanger { protected // Classe interna //


int ptr = 0; slots int protegidos; índice circular // número
espera int protegida = 0; de slots utilizáveis // número de
tópicos espera

Trocador(int n) { slots = n; }

void sincronizado adicionadoSlotNotification() {


++ slots; if
(waiting > 0) // desbloqueia um único thread em espera
notificar();
}

Troca de objeto (objeto x) lança InterruptedException {


Objeto antigo = nulo; // valor de retorno
Machine Translated by Google

sincronizard(this) { while
(slots <= 0) { // espera pelo slot
++esperando;
tente
{espera();

} catch(InterruptedException ou seja) { notify();


jogar ou
seja; }

finalmente
{ --waiting; }

slots; // usa slot --


antigo = array[ptr];
array[ptr] = x; ptr =
(ptr + 1) % array.comprimento; // avança a posição }

removidoSlotNotification(este); // notifica a alteração return old; }

}}

3.7.2.3 Recolher classes

A divisão de sincronização de todos os tipos pode ser realizada de duas maneiras. No caso de lock-splitting (§
2.4.2), você pode criar novas classes auxiliares e encaminhar operações do host, ou pode apenas manter os
métodos no host, mas invocá-los sob sincronização de Objetos que representam conceitualmente os
diferentes ajudantes.

O mesmo princípio se aplica ao dividir ações dependentes do estado. Em vez de delegar ações para
auxiliares, você pode manter os métodos na classe host, adicionando Objetos que representam conceitualmente
os auxiliares. Objetos usados exclusivamente para sincronização servem como bloqueios. Aqueles
usados para espera e notificação servem como locais de monitores para colocar as threads que precisam esperar e serem notificadas.

A combinação de auxiliares em uma classe de host torna a classe de host mais complexa, mas também
potencialmente mais eficiente, devido a chamadas de método em curto-circuito e coisas do tipo. Realizando
tais simplificações ao longo do caminho, podemos definir uma versão mais concisa, um pouco mais
eficiente e certamente mais assustadora do BoundedBuffer:

classe final BoundedBufferWithMonitorObjects { private final


Object[] array; // os elementos

private int putPtr = 0; private int // índices circulares


takePtr = 0;
Machine Translated by Google

private int emptySlots; private // contagem de slots


int usadoSlots = 0;

private int esperandoPuts = 0; private // contagem de threads em espera


int esperandoTakes = 0;

private final Object putMonitor = new Object(); objeto final privado


takeMonitor = new Object();

public BoundedBufferWithMonitorObjects(capacidade int) throws


IllegalArgumentException {
se (capacidade <= 0)
lançar novo IllegalArgumentException();

array = new Object[capacidade];


espaçosvazios = capacidade; }

public void put(Object x) throws InterruptedException {synced(putMonitor)


{ while (emptySlots <= 0) {

++esperandoPuts;
tente { putMonitor.wait(); }
catch(InterruptedException ou seja)
{ putMonitor.notify(); jogar
ou seja; }

finalmente { --waitingPuts; } } --

emptySlots;
array[putPtr] = x; putPtr
= (putPtr + 1) % matriz.comprimento; }

sincronizado(takeMonitor) { // notifica diretamente ++usedSlots; if


(esperando leva
> 0)
takeMonitor.notify();
}
}

public Object take() lança InterruptedException {


Objeto antigo = nulo;
sincronizado(takeMonitor) { while
(usedSlots <= 0) { ++waitingTakes;
tente
{ takeMonitor.wait(); }
catch(InterruptedException ou seja)
{ takeMonitor.notify();
Machine Translated by Google

jogar ou

seja; } finalmente { --waitingTakes; } } --

usedSlots; antigo
= array[takePtr]; array[takePtr]
= null; takePtr = (takePtr + 1) %
array.length; } sincronizado(putMonitor) { ++emptySlots;

if (esperaPuts > 0)

putMonitor.notify();

} return antigo; }

3.7.3 Notificações Específicas

Em vez de tratar os pequenos objetos auxiliares em classes como


BoundedBufferWithMonitorObjects como a culminação dos esforços de design, você pode tratá-los como
ferramentas para implementar qualquer problema de design passível de solução por meio de monitores
divididos. O padrão de notificação específica desenvolvido por Tom Cargill usa exatamente essa tática.

A ideia básica é colocar as tarefas em hibernação por meio de esperas nos objetos comuns dos monitores
(ou mais tipicamente, instâncias de classes simples que ajudam na contabilidade) usadas exclusivamente para
seus conjuntos de espera. Um monitor é usado para cada tarefa ou conjunto de tarefas que devem ser
notificadas individualmente. Na maioria dos casos, isso requer um monitor por encadeamento; em outros, um grupo
de threads que devem ser todos ativados de uma vez pode usar o mesmo monitor. Esses monitores servem a
propósitos semelhantes às filas de condição que são suportadas nativamente em algumas linguagens de
programação simultâneas baseadas em monitores (consulte o § 3.4.4). A principal diferença é que, sem suporte
nativo, esses monitores auxiliares devem ser tratados com mais cuidado para evitar problemas de aninhamento.

Notificações específicas podem ser úteis sempre que você precisar que os threads aguardem e a política de
notificação não dependa dinamicamente das propriedades dos threads. Depois que um thread é colocado em seu
conjunto de espera, é impossível acessá-lo de outra maneira que não seja ativá-lo. Entre as aplicações comuns às
quais essas restrições se aplicam estão:

Apoiar políticas de agendamento específicas através do uso de uma fila explícita (por exemplo FIFO,
LIFO, prioridade).
Dividindo as tarefas recebidas em diferentes filas, dependendo do método que estão esperando para
executar. Isso pode ser usado para estender técnicas baseadas em conjuntos de conflitos (consulte § 3.3.2).

No entanto, embora possa ser tentador combinar suporte para restrições de agendamento, como FIFO, com
restrições baseadas em estado lógico ou estado de execução, as interações entre esses dois aplicativos
geralmente levam a problemas conceituais e logísticos. Por exemplo, você precisa considerar os casos em que
o encadeamento A deve ser habilitado antes do encadeamento B porque chegou antes, mas o encadeamento B é logicamente
Machine Translated by Google

capaz de prosseguir enquanto o thread A não é. Isso pode exigir aparatos elaborados para enfileirar threads,
gerenciar ordens de bloqueio e lidar arbitrariamente com casos extremos.

3.7.3.1 Etapas de projeto

As principais etapas do projeto são especializações daquelas descritas no § 3.7.2.1. Crie ou modifique uma classe,
digamos Host, da seguinte maneira:

Para cada encadeamento ou conjunto de encadeamentos que precisa de notificação específica, crie um
objeto que sirva como monitor. Esses monitores podem ser organizados em arrays ou outras coleções,
ou criados dinamicamente durante a execução.
Configurar a contabilidade nas classes que servem como monitores para gerenciar as operações de
espera e notificação e suas interações com políticas de tempo limite e interrupção. Conforme mostrado
na classe WaitNode em § 3.7.3.2, isso geralmente envolve a manutenção de um campo liberado para
lembrar se um thread em espera foi liberado devido a notificação, interrupção ou tempo limite.
Essas classes podem então oferecer suporte a métodos, como doWait, doTimedWait,
doNotify e doNotifyAll, que executam espera e notificação confiáveis e lidam com
interrupções e tempos limite da maneira desejada. Se você não puder adicionar
contabilidade às classes que servem como monitores, essas questões precisam ser abordadas nos métodos da cl
Em cada método do Host no qual as tarefas devem ser suspensas, use monitor.doWait() com o objeto de
monitor apropriado. Esse código deve evitar problemas de monitor aninhado, garantindo que a espera
seja executada nas regiões de código que não estão sincronizadas no objeto host.
A forma mais simples e desejável é: // para
sincronizada (este) { needToWait lembrar o valor após a sincronização boolean needToWait; saída
= ...; if

(needToWait) enfileirar(monitor); // ou
qualquer escrituração semelhante

} if (needToWait) monitor.doWait();
Em cada método em que as tarefas devem ser retomadas, use monitor.doNotify(), também lidando com
as consequências de time-out ou interrupção.

3.7.3.2 Semáforos FIFO

Notificações específicas podem ser usadas para implementar os tipos de classes de semáforos First-In-First-Out
discutidas em § 3.4.1.5. Os semáforos FIFO podem, por sua vez, ser usados para construir outros utilitários que
dependem das propriedades FIFO.

A seguinte classe FIFOSemaphore (uma versão simplificada de uma em util.concurrent) é definida como uma subclasse
da classe Semaphore genérica do § 3.7.1. A classe FIFOSemaphore mantém um WaitQueue vinculado contendo
WaitNodes, cada um servindo como um monitor. Uma operação de aquisição que não pode obter imediatamente uma
permissão enfileira um novo objeto de monitor que entra em espera.
A operação de liberação desenfileira o nó de espera mais antigo e o notifica.

Um campo liberado em cada WaitNode ajuda a gerenciar interações entre notificações e interrupções. Durante
uma liberação, qualquer monitor que tenha abortado devido à interrupção é ignorado.
Por outro lado, uma espera interrompida primeiro verifica se foi notificada, além de ser
Machine Translated by Google

interrompido. Nesse caso, ele deve avançar, ignorando a exceção, mas redefinindo o status de interrupção
(consulte o § 3.1.2) para preservar o status de cancelamento. (Um método doTimedWait não exibido pode ser
implementado de forma semelhante, definindo o status liberado após o tempo limite.) O potencial de
interrupções em horários inconvenientes é responsável pelo loop de repetição na liberação.

As interações entre FIFOSemaphore, WaitQueue e WaitNode garantem a atomicidade necessária, evitando


problemas de monitores aninhados. Eles também demonstram parte da arbitrariedade das decisões em torno do
apoio às políticas FIFO. Podemos prometer apenas que o semáforo é FIFO em relação a um ponto inicial e um
ponto final arbitrários. O ponto inicial começa com o sincronizado(isto) na aquisição. O
ponto final normalmente ocorre após a liberação de uma espera devido a notificação. Dois encadeamentos
que entram na aquisição podem obter o bloqueio em ordens diferentes de suas chegadas, por exemplo, se o
primeiro for agendado pela JVM antes de atingir a instrução synchronized(this) . Da mesma forma, um
thread liberado antes de outro pode finalmente retornar ao seu chamador após o outro. Especialmente em
multiprocessadores, a classe fornece uma garantia tão forte quanto os usuários da classe devem
esperar.

As regras de agendamento podem ser alteradas substituindo um tipo diferente de fila aqui; por exemplo, um
baseado em Thread.getPriority. No entanto, é mais complicado adaptar essa classe para lidar com restrições
semânticas baseadas na execução ou no estado lógico. A maioria das restrições semânticas requer
threads notificados ou interrompidos para adquirir bloqueios adicionais. Isso introduziria complicações ao
esquema aqui que explora o fato de que os threads ativados não precisam acessar o bloqueio principal. Isso
precisaria ser resolvido de maneira específica do aplicativo.

class FIFOSemaphore extends Semaphore {

fila WaitQueue final protegida = new WaitQueue();

public FIFOSemaphore(long initialPermits) {


super(permissões iniciais); }

public voidadquire() lança InterruptedException {


if (Thread.interrupted()) lança uma nova InterruptedException();

Nó WaitNode = nulo;

sincronizado(este) { if (permite
> 0) { --permite; retornar; // sem necessidade de fila

} else
{ node = new WaitNode();
fila.enq(nó); }

// deve liberar o bloqueio antes da espera do nó

node.doWait();
Machine Translated by Google

public sincronizado void release() { // tente novamente


{ WaitNode até obter sucesso para (;;)
node = queue.deq();

if (node == null) { // a fila está vazia ++permits; retornar;

} else if (node.doNotify()) return;

// else node já foi liberado devido a // interrupção ou time-out,


então deve tentar novamente }

// Classe do nó da fila. Cada nó serve como um monitor.

classe estática protegida WaitNode {


booleano liberado = false;
WaitNode próximo = nulo; // para organizar na lista encadeada

void sincronizado doWait() lança InterruptedException {


tente
{ while (!liberado) wait();

} catch (InterruptedException ou seja) {

if (!lançado) { // interrompido antes de ser notificado //


Suprimir notificações futuras: release = true; jogar
ou seja; } // interrompido
depois de

notificado else { // ignora a exceção, mas propaga o status:


Thread.currentThread().interrupt(); }

}}

sincronizado boolean doNotify() { // retorna verdadeiro se for notificado

se (liberado) // foi interrompido ou expirou


retorna falso; outro
{
Machine Translated by Google

liberado = verdadeiro;
notificar();
retornar verdadeiro;
}
}

booleano sincronizado doTimedWait(long msecs) throws


InterruptedException { // semelhante

}
}
// Classe de fila vinculada padrão.
// Usado apenas ao manter o bloqueio do semáforo.

classe estática protegida WaitQueue { cabeça WaitNode


protegida = nulo; WaitNode protegido último =
nulo;

void protegido enq(nó WaitNode) { if (último == nulo)

cabeça = último = nó; else


{ last.next
= node; último = nó; }

protegido WaitNode deq() { Nó WaitNode


= cabeça; if (node != null) { head
= node.next; if (cabeça ==
nulo) último = nulo;
node.next = nulo; } nó de retorno; } }

3.7.4 Leituras Adicionais


Técnicas para implementar travas elementares usando, por exemplo, o algoritmo de Dekker e algoritmos
baseados em tickets são apresentadas nos textos de programação concorrente de Andrews e outros listados em §
1.2.5. No entanto, não há razão para basear utilitários de controle de simultaneidade de uso geral nessas
técnicas, em vez de métodos e blocos sincronizados integrados.

O padrão de notificação específica foi descrito pela primeira vez em:


Machine Translated by Google

Cargil, Thomas. "Notificação específica para sincronização de thread Java", Proceedings of the Pattern Languages of
Programming Conference, 1996.

Uma conta alternativa para refinar as construções notifyAll usando notificações específicas pode ser encontrada em:

Mizuno, Masaaki. "Uma Abordagem Estruturada para o Desenvolvimento de Programas Concorrentes em Java",
Information Processing Letters, 1999.

Outros exemplos e extensões das técnicas descritas nesta seção podem ser encontrados no suplemento online.

Capítulo 4. Criando Tópicos


É impossível categorizar todas as formas de explorar a funcionalidade associada aos threads. Mas duas abordagens
gerais podem ser distinguidas por seus pontos de vista sobre a declaração:

novo Thread(aRunnable).start();

Esta é uma maneira sofisticada de invocar um método (ou seja, um método run de Runnable ) ou é uma maneira de
criar um objeto sofisticado (ou seja, uma nova instância da classe Thread)? Claramente é ambos, mas focar em um
aspecto versus o outro leva a duas abordagens para usar threads que estavam implícitas nas discussões do Capítulo 1:

Baseado em tarefas Aqui, o principal motivo para usar um thread é invocar de forma assíncrona um método que executa
alguma tarefa. A tarefa pode variar de um único método a uma sessão inteira. Técnicas baseadas em thread podem
suportar esquemas de troca de mensagens que escapam das limitações de chamadas procedurais puras. Projetos
baseados em tarefas são vistos em estruturas de eventos, computação paralela e sistemas intensivos de E/S.

Baseado em ator Aqui, o principal motivo para usar um thread é criar e colocar em movimento um novo objeto autônomo,
ativo e semelhante a um processo. Esse objeto pode, por sua vez, reagir a eventos externos, interagir com outros atores
e assim por diante. Projetos baseados em atores são vistos em sistemas reativos, de controle e distribuídos. Eles também
são o foco da maioria das abordagens formais para simultaneidade.

(Ambos os termos tarefa e ator têm muitos significados sobrecarregados e quase sinônimos. Limitaremos o uso aos
sentidos acima.)

Em sistemas baseados em tarefas, objetos passivos às vezes enviam mensagens ativas (produzidas por thread),
enquanto em sistemas baseados em atores, objetos ativos normalmente enviam mensagens passivas. Como
geralmente é o caso de dicotomias artificiais, nenhuma das abordagens é sempre a melhor, e há um enorme meio-
termo que pode ser projetado a partir de uma ou de ambas as perspectivas.

Abordagens baseadas em atores são comumente usadas na construção de daemons que interagem com outros
sistemas. Eles também são empregados ao definir entidades intrinsecamente ativas, por exemplo, o
GamePlayer em § 3.2.4. Seus métodos principais geralmente assumem uma forma de loop reativo:

for(;;) { acceptAndProcessCommand(); }

Abordagens baseadas em tarefas são comumente usadas quando há algum motivo conceitual ou baseado em
desempenho para executar uma determinada tarefa, serviço ou computação de forma assíncrona, em vez de depender de execução direta.
Machine Translated by Google

invocação processual. Projetos baseados em tarefas fornecem uma separação de preocupações entre
assincronia lógica e mapeamentos para encadeamentos e construções baseadas em encadeamentos. Eles
recebem a maior parte da discussão neste capítulo.

Como exemplo inicial, aqui está uma maneira de abordar um design comum baseado em thread, um serviço da web.
Aqui, um WebService em execução é um encadeamento de estilo de ator de "processo daemon" que interage
continuamente com seu ambiente, ouvindo novas solicitações recebidas. Mas as invocações
para handler.process são emitidas de maneira baseada em tarefa, uma nova tarefa é acionada para lidar com cada
solicitação recebida. Aqui, para fins de ilustração concisa, a solicitação é simplesmente um número, e o manipulador
apenas retorna a negação do número de volta ao cliente.

classe WebService implementa Runnable {


estático final int PORT = 1040; // apenas para demonstração
Handler handler = new Handler();

public void run() { tente {

Socket ServerSocket = new ServerSocket(PORT); para (;;) {

final Conexão de soquete = socket.accept(); new Thread(new


Runnable() { public void run()

{ handler.process(connection); }}).start();

} catch(Exceção e) { } } // morrer

public static void main(String[] args) { new Thread(new


WebService()).start(); } } classe Manipulador {

void process(Socket s) {
Machine Translated by Google

DataInputStream em = null;
DataOutputStream out = null; tente { in
= new
DataInputStream(s.getInputStream()); out = new
DataOutputStream(s.getOutputStream()); pedido int = in.readInt();
int resultado = -pedido; // retorna a
negação ao cliente out.writeInt(result);

} catch(IOException ex) {} // Cair em

finalmente { // limpe try { if (in != null)


in.close(); } catch (IOException ignore) {} try { if
(out != null) out.close(); } catch
(IOException ignore) {} tente { s.close(); } captura
(IOException ignora) {}

}
}

Este capítulo divide a cobertura das técnicas de construção e estruturação de threads da seguinte forma:

O § 4.1 apresenta uma série de opções para implementar mensagens conceitualmente


unidirecionais, às vezes iniciando tarefas de forma assíncrona usando threads ou estruturas
de execução leves
baseadas em threads. A Seção 4.2 discute o projeto de sistemas nos quais redes de
componentes
empregam estratégias de mensagens unidirecionais. § 4.3 apresenta alternativas para a construção
de threads que calculam
resultados ou fornecem serviços aos clientes que os iniciam. O § 4.4 examina as técnicas de
decomposição de problemas
que podem ser usadas para melhorar o desempenho explorando multiprocessadores. § 4.5
fornece uma visão geral de construções e estruturas para projetar sistemas de objetos ativos, ilustrados em parte usando CS

Muitos dos projetos apresentados neste capítulo ultrapassam as fronteiras entre programação concorrente,
distribuída e paralela. As apresentações se concentram em soluções simultâneas de JVM única. Mas
eles incluem construções frequentemente vistas ao desenvolver o suporte de encanamento para
sistemas e estruturas que envolvem vários processos ou computadores.

4.1 Mensagens Unidirecionais


Machine Translated by Google

Um objeto host emite uma mensagem logicamente unidirecional para um ou mais destinatários sem depender das
consequências dessa mensagem. Enviar uma mensagem unidirecional de alguma forma resulta na execução
de alguma tarefa. A tarefa pode consistir em apenas uma única linha de código ou pode representar uma
sessão que envolve a aquisição de muitos recursos e horas de computação. Mas o resultado do encadeamento que
emite uma mensagem unidirecional não depende do resultado da tarefa, nem de quando a tarefa é concluída ou
(normalmente) de se ela é concluída. Exemplos comuns incluem:

Eventos Cliques do mouse, etc.


Notificações Alertas de alteração de

postagens status Mensagens de e-mail, cotações de ações, etc.


Ativações Criação de Applets, daemons, etc.
Comandos Pedidos de impressão, etc.

relés Encaminhamentos e despachos de mensagens

As interações unidirecionais entre remetentes e destinatários não precisam ser estritamente assíncronas. Por
exemplo, o remetente pode ser responsável por garantir que o destinatário realmente receba a mensagem. Além
disso, o remetente ou outro objeto pode posteriormente desejar cancelar ou reverter os efeitos da tarefa resultante
(o que obviamente nem sempre é possível, por exemplo, se a tarefa já tiver sido concluída, consulte § 3.1.2 ).

Se todas as tarefas pudessem ser executadas instantaneamente, você poderia disparar mensagens
unidirecionais por meio de invocações procedurais nas quais o chamador espera a tarefa disparada pela mensagem,
mesmo que não tenha motivo para fazê-lo. Mas muitas vezes há motivos logísticos, conceituais e baseados em
desempenho para emitir algumas dessas mensagens por meio de construções baseadas em encadeamentos
nas quais as tarefas associadas prosseguem independentemente.

4.1.1 Formatos de Mensagem

Muitos estilos diferentes de invocação são incluídos na noção de passagem de mensagem unidirecional.
Embora alguns deles estejam mais intimamente associados a aplicativos distribuídos ou multiprocessos (consulte
§ 1.2.2), qualquer um deles pode ser usado em conjunto com as construções discutidas nesta seção. Além
das invocações de método direto, os formatos de mensagem podem incluir:

Cadeias de comando O destinatário deve analisar, decodificar e, em seguida, despachar a tarefa associada.
Mensagens de sequência de comandos são amplamente usadas em comunicação baseada em socket e pipe,
especialmente em serviços da web.

Objetos de evento A mensagem contém uma descrição estruturada de um evento. O destinatário então despacha
alguma tarefa de manipulação arbitrária que associa ao evento. Objetos de evento são usados extensivamente em
Machine Translated by Google

Estruturas GUI como java.awt, bem como estruturas de componentes suportadas por java.beans.

Objetos de solicitação A mensagem contém uma codificação de um nome de método e argumentos (empacotados ou
serializados). O destinatário emite a chamada de método correspondente para um objeto auxiliar que executa esse
método. Os objetos de solicitação são usados em sistemas de suporte a objetos distribuídos, como os de java.rmi e
org.omg.corba. As variantes são usadas nas tarefas Ada.

Objetos de classe A mensagem é uma representação de uma classe (por exemplo, por meio de um arquivo .class ) que
o destinatário instancia. Este esquema é usado na estrutura java.applet , bem como em protocolos de ativação
remota.

Objetos executáveis A mensagem consiste em algum código que o destinatário executa. Formas mistas de eventos
executáveis (que incluem uma descrição de evento e uma ação associada) são usadas em algumas estruturas de
evento. Formas estendidas que empregam objetos executáveis serializados são vistas em estruturas de agentes
móveis.

Objetos arbitrários Um remetente pode tratar qualquer tipo de objeto como uma mensagem, incluindo-o como
argumento de método ou passando-o por um Canal (consulte § 4.2.1). Por exemplo, na estrutura JavaSpacesTM,
os remetentes podem postar qualquer objeto serializado como uma mensagem (também conhecida como entrada). Os
destinatários aceitam apenas as entradas com tipos e valores de campo que estejam em conformidade com um
conjunto especificado de critérios correspondentes. Os destinatários processam esses objetos de maneira apropriada.

As diferenças entre esses formatos refletem (entre outras coisas) quanto o chamador sabe sobre o código que o
destinatário precisa executar para executar sua tarefa. Frequentemente, é mais conveniente e mais eficiente usar objetos
executáveis, especialmente em estruturas baseadas em thread que usam instâncias da classe Runnable como argumentos
em construtores Thread . Vamos nos concentrar neste formulário, mas ocasionalmente ilustraremos outros.

4.1.2 Chamadas Abertas

Considere o objeto Host central em uma cadeia de chamadas na qual o Host recebe requisições de qualquer número
de Clientes e, no decorrer do processamento, deve emitir mensagens logicamente unidirecionais para um ou mais
objetos Auxiliares. Novamente, vamos ignorar os fatos de que uma quantidade arbitrária de esforço pode ser necessária
para decodificar a solicitação antes de agir sobre ela, que a solicitação pode realmente ser lida de um soquete como
visto na classe WebService e assim por diante . Além disso, todas as classes discutidas nesta seção podem ser
estendidas para emitir multicasts para vários auxiliares usando as construções descritas em § 2.4.4 e § 3.5.2.

A principal força de design aqui é a latência. Se um Host estiver ocupado atendendo a solicitações, ele não poderá
aceitar novas solicitações. Isso adiciona tempo de resposta a novas solicitações de Clientes, reduzindo a disponibilidade geral do serviço.

Alguns aspectos da latência podem ser resolvidos simplesmente usando os designs de passagem e chamada aberta
descritos em § 2.4:

class OpenCallHost { protegido // Esboço de código genérico


por muito tempo localState;
Machine Translated by Google

helper final protegido helper = new Helper();

protegido sincronizado void updateState(...) {


estadolocal = ...;
}

public void req(...) { updateState(...);


helper.handle(...); }

Aqui, mesmo que a chamada helper.handle seja relativamente demorada, o objeto Host ainda poderá aceitar novas solicitações
de clientes em execução em diferentes threads. A taxa de aceitação de solicitação é limitada apenas pelo tempo que leva para
atualizar o estado local.

O uso de chamadas abertas geralmente elimina pontos de gargalo em torno de um determinado host, mas não aborda a
questão mais ampla de como introduzir simultaneidade em um sistema para começar. Chamadas abertas são úteis apenas
quando os clientes de alguma forma já sabem o suficiente para usar alguma outra abordagem que permita a execução
independente quando necessário ou desejado.

4.1.3 Thread-Per-Message

A simultaneidade pode ser introduzida em projetos de mensagens unidirecionais emitindo uma mensagem em seu próprio
encadeamento, como em:

class ThreadPerMessageHost { protegido // Esboço de código genérico


por muito tempo localState; helper final
protegido helper = new Helper();

protegido sincronizado void updateState() {


estadolocal = ...;
}

public void req(...) { updateState(...);


new Thread(new Runnable()
{ public void run() {

helper.handle(...); }

}).começar();

}}
Machine Translated by Google

Essa estratégia melhora o throughput quando várias tarefas paralelas podem ser executadas mais rapidamente do que
uma sequência delas, normalmente porque são vinculadas a E/S ou a computação e executadas em um
multiprocessador. Também pode aumentar a imparcialidade e melhorar a disponibilidade se os clientes não
precisarem esperar que as tarefas de cada um sejam concluídas.

As decisões sobre criar e iniciar threads para executar tarefas não são muito diferentes das decisões sobre
criar outros tipos de objetos ou enviar outros tipos de mensagens: os benefícios devem superar os custos.

Projetos de encadeamento por mensagem introduzem latência de resposta porque a criação de encadeamento é
mais cara do que a invocação direta do método. Quando as tarefas consomem tempo em comparação com o tempo de
construção do encadeamento, são baseadas em sessão, precisam ser isoladas de outras atividades independentes
ou podem explorar IO ou paralelismo de CPU, as compensações geralmente valem a pena. Mas problemas de
desempenho podem surgir mesmo quando as latências de construção são aceitáveis. A implementação da JVM
e/ou o sistema operacional podem não responder bem à construção de muitos encadeamentos. Por exemplo,
eles podem ficar sem recursos do sistema associados a threads. Além disso, à medida que o número de threads
aumenta, o agendamento de threads e a sobrecarga de troca de contexto podem sobrecarregar os tempos de processamento.

4.1.3.1 Executores

O estilo de codificação visto na classe ThreadPerMessage pode se tornar um problema por causa de sua dependência
direta da classe Thread. Esses usos podem dificultar o ajuste dos parâmetros de inicialização do thread, bem
como dados específicos do thread (consulte o § 2.3.2) usados em um aplicativo. Isso pode ser evitado criando
uma interface, digamos:

interface Executor { void


execute(Runnable r); }

Essa interface pode ser implementada com classes como:

class PlainThreadExecutor implementa Executor {


public void execute(Runnable r) {
novo Thread(r).start(); } }

Essas implementações podem ser usadas em classes como:

classe HostComExecutor { // Esboço de código genérico


localState longo protegido; helper final
protegido helper = new Helper(); executor final protegido do executor;

public HostWithExecutor(Executor e) { executor = e; }

protegido sincronizado void updateState(...) {


estadolocal = ...;
}
Machine Translated by Google

public void req(...)


{ updateState(...);
executor.execute(new Runnable() {
public void run() {
helper.handle(...); } });

}}

O uso de tais interfaces também permite a substituição de threads por estruturas executáveis leves.

4.1.4 Tópicos de trabalho

Estruturas executáveis leves preenchem a lacuna entre chamadas abertas e designs de encadeamento por mensagem.
Eles se aplicam quando você precisa introduzir simultaneidade limitada, às custas de algumas
restrições de uso, a fim de maximizar (ou pelo menos melhorar) a taxa de transferência e minimizar as latências médias.

Estruturas executáveis leves podem ser construídas de várias maneiras, mas todas decorrem da ideia básica de
usar um thread para executar muitas tarefas não relacionadas (aqui, em sucessão). Esses threads são conhecidos
como threads de trabalho, threads de segundo plano e pools de threads quando mais de um thread é usado.

Cada trabalhador aceita continuamente novos comandos executáveis de hosts e os mantém em algum tipo
de canal (uma fila, buffer, etc. consulte § 3.4.1) até que possam ser executados. Esse design tem a forma
clássica de uma relação produtor-consumidor: o host produz tarefas e os trabalhadores as consomem ao
executá-las.
Machine Translated by Google

Estruturas executáveis leves podem melhorar a estrutura de alguns programas simultâneos baseados em tarefas,
permitindo que você empacote muitas unidades de execução logicamente assíncronas menores como tarefas sem ter
que se preocupar muito com as consequências de desempenho: Inserir um Runnable em uma fila provavelmente será mais
rápido do que criar um novo objeto Thread . E como você pode controlar o número de threads de trabalho, pode
minimizar as chances de esgotamento de recursos e reduzir a sobrecarga de troca de contexto. O enfileiramento
explícito também permite maior flexibilidade no ajuste da semântica de execução.
Por exemplo, você pode implementar Canais como filas prioritárias que ordenam tarefas com mais controle
determinístico do que o garantido por Thread.setPriority. (Consulte o § 4.3.4 para um exemplo.)

Para interoperar com versões puramente baseadas em thread, os threads de trabalho podem ser empacotados como Executores.
Aqui está uma implementação genérica que pode ser usada na classe HostWithExecutor em vez da versão thread-per-
message:

class PlainWorkerPool implementa Executor { protected final


Channel workQueue;

public void execute(Runnable r) {


tente
{ workQueue.put(r);

} catch (InterruptedException ie) { // adia a resposta


Thread.currentThread().interrupt(); }

public PlainWorkerPool(Canal ch, int nworkers) { workQueue = ch; for


(int i = 0; i < nworkers;
++i) activate(); }

void protegido ativar() {


Runnable runLoop = new Runnable() {
public void run() { tente
{ para
(;;) {
Machine Translated by Google

Executável r = (Executável)(workQueue.take()); C-corram(); }

} catch (InterruptedException ou seja) {} // morre } }; novo

Thread(runLoop).start(); } }

4.1.4.1 Escolhas de projeto

A primeira decisão a ser tomada em torno de estruturas executáveis leves baseadas em threads de trabalho é criá-las
ou usá-las. A questão principal é se existe alguma propriedade dos Threads comuns que você não precisa ou está
disposto a abrir mão. Caso contrário, é improvável que você chegue a uma solução que supere o suporte de
encadeamento integrado em implementações JVM de produção.

As compensações que obtêm as vantagens de desempenho dos threads de trabalho têm vários parâmetros
ajustáveis adicionais, consequências de uso e obrigações de programação que podem afetar o design e o uso de
classes de thread de trabalho (incluindo aquelas contidas no pacote util.concurrent disponível no suplemento
online ).

Identidade. A maioria dos threads de trabalho deve ser tratada "anonimamente". Como o mesmo thread de trabalho
é reutilizado para várias tarefas, o uso de ThreadLocal e outras técnicas de controle contextual específico do thread
(consulte § 2.3.2) torna-se mais complicado. Para lidar com isso, você precisa conhecer todos esses dados contextuais
e, de alguma forma, redefini-los, se necessário, ao executar cada tarefa. (Isso inclui informações sobre
contextos de segurança mantidos por classes de suporte de tempo de execução.) No entanto, a maioria das
estruturas executáveis leves evita qualquer dependência de técnicas específicas de encadeamento.

Se a identidade for a única propriedade dos encadeamentos que você está disposto a abrir mão, o único
valor de desempenho potencial dos encadeamentos de trabalho é a minimização da sobrecarga de inicialização,
reutilizando os encadeamentos existentes para executar várias tarefas Runnable, enquanto ainda limita o consumo de recursos .

Fila. Tarefas executáveis que estão em filas não são executadas. Essa é uma fonte de benefícios de desempenho
na maioria dos designs de encadeamento de trabalho. Se cada ação fosse associada a um encadeamento, ela precisaria
ser agendada independentemente pela JVM. Mas, como consequência, a execução em fila geralmente não pode ser
usada quando há alguma dependência entre as tarefas. Se uma tarefa atualmente em execução bloquear a espera por
uma condição produzida por uma tarefa que ainda está esperando na fila, o sistema pode congelar. As opções
aqui incluem:

Use tantos threads de trabalho quantas tarefas forem executadas simultaneamente. Neste caso, o Canal
não precisa realizar nenhum enfileiramento, então você pode usar SynchronousChannels (ver § 3.4.1.4),
canais sem fila que exigem que cada put espere por um take e vice-versa.
Aqui, os objetos de host simplesmente entregam tarefas para threads de trabalho, que imediatamente
começam a executá-las. Para que isso funcione bem, os pools de threads de trabalho devem ser
dinamicamente expansíveis.
Restrinja o uso a contextos nos quais as dependências de tarefas são impossíveis, por exemplo, em
servidores HTTP, onde cada mensagem é emitida por um cliente externo não relacionado solicitando um
arquivo. Exija que os objetos auxiliares criem Threads reais quando não puderem garantir a independência.
Machine Translated by Google

Crie filas personalizadas que entendam as dependências entre os tipos específicos de tarefas que estão sendo
processadas pelos threads de trabalho. Por exemplo, a maioria dos pools usados para processar tarefas que
representam transações (consulte o § 3.6) deve acompanhar as dependências da transação. E a estrutura paralela
leve descrita em § 4.4.1 depende de políticas de enfileiramento especiais que se aplicam apenas a subtarefas criadas
em algoritmos de divisão e conquista.

Saturação. À medida que a taxa de solicitação aumenta, um pool de trabalhadores acabará ficando saturado. Todos os threads de
trabalho estarão processando tarefas e o(s) objeto(s) Host usando o pool não poderão entregar o trabalho.
Possíveis respostas incluem:

Aumente o tamanho da piscina. Em muitas aplicações, os limites são estimativas heurísticas. Se um limite for apenas um
palpite com base em valores mostrados para funcionar bem em uma plataforma específica em cargas de trabalho de
teste, ele poderá ser aumentado. Em algum momento, porém, uma das outras opções deve ser tomada, a menos que
você possa tolerar falhas se a JVM ficar sem recursos suficientes para construir um novo Thread.
Se a natureza do serviço permitir, use um canal com buffer ilimitado e deixe as solicitações se acumularem. Isso corre o
risco de falha potencial do sistema devido ao esgotamento da memória, mas isso leva mais tempo para acontecer do
que o esgotamento de recursos em torno da construção do Thread .
Estabeleça um esquema de notificação de contrapressão para pedir aos clientes que parem de enviar tantas solicitações.
Se os clientes finais fizerem parte de um sistema distribuído, eles poderão usar outro servidor.

Solte (descarte) novas solicitações após a saturação. Essa pode ser uma boa opção se você souber que os clientes
tentarão novamente de qualquer maneira. No entanto, a menos que as novas tentativas sejam automáticas, você precisa
adicionar retornos de chamada, eventos ou notificações aos clientes para alertá-los sobre as quedas, para que
eles saibam o suficiente para tentar novamente (consulte § 4.3.1).
Abra espaço para a nova solicitação descartando solicitações antigas que foram enfileiradas, mas ainda não foram
executadas, ou até mesmo cancelando uma ou mais tarefas em execução. Essa preferência por novas solicitações
sobre as antigas após a saturação às vezes combina bem com os padrões de uso. Por exemplo, em alguns sistemas de
telecomunicações, tarefas antigas não atendidas geralmente são solicitadas por clientes que já desistiram e se
desconectaram.
Bloqueie até que algum segmento esteja disponível. Essa pode ser uma boa opção quando os manipuladores
são previsíveis e de curta duração, para que você possa ter certeza de que a espera será desbloqueada sem atrasos
inaceitáveis.
O Host pode executar a tarefa diretamente, em seu thread atual. Geralmente, essa é a melhor opção padrão. Em
essência, o Host momentaneamente se torna single-threaded. O ato de atender a solicitação limita a taxa na qual ela
pode aceitar novas solicitações, evitando assim mais interrupções locais.

Gerenciamento de threads. A classe PlainWorkerPool é um tanto dispendiosa porque cria todos os threads de trabalho na
inicialização, sejam eles necessários ou não, e permite que todos vivam indefinidamente, mesmo quando o serviço não está
sendo usado. Esses problemas podem ser aliviados usando uma classe de gerenciamento que suporta:

Construção preguiçosa: ativa um novo encadeamento somente quando uma solicitação não pode ser atendida imediatamente
por um encadeamento ocioso existente. A construção preguiçosa permite que os usuários forneçam limites de tamanho
de pool grandes o suficiente para evitar problemas de subutilização que ocorrem quando menos threads estão em
execução do que um determinado computador pode suportar. Isso ocorre com a menor despesa de latências
ocasionalmente maiores quando uma nova solicitação faz com que um novo thread seja criado. Os efeitos
iniciais da construção preguiçosa podem ser moderados criando um pequeno número de threads "quentes"
durante a construção do pool.
Tempo limite ocioso: permite que os threads atinjam o tempo limite esperando pelo trabalho e terminem após o tempo limite.
Isso eventualmente faz com que todos os trabalhadores saiam se a piscina não for usada por períodos prolongados. Quando
Machine Translated by Google

juntamente com a construção preguiçosa, esses threads mortos serão substituídos por novos se a taxa de
solicitação aumentar posteriormente.

Em aplicativos altamente dependentes de recursos, você também pode associar outros recursos (como conjuntos de objetos
gráficos reutilizáveis) a cada thread de trabalho, combinando assim pools de recursos (consulte o § 3.4.1.2) com pools de
threads.

Cancelamento. Você pode precisar distinguir o cancelamento (consulte § 3.1.2) de uma tarefa do cancelamento do thread de
trabalho que executa essa tarefa. Uma abordagem é:

Após a interrupção, permita que o thread de trabalho atual morra, mas substitua-o, se necessário, por um novo
thread de trabalho se a fila de trabalho não estiver vazia ou quando uma nova tarefa chegar.
Forneça um método de desligamento na classe de encadeamento do trabalhador que faça com que os trabalhadores
existentes morram e nenhum trabalhador adicional seja criado.

Além disso, pode ser necessário acionar algum tipo de tratamento de erro se um encadeamento do Host for cancelado durante a
transferência de uma tarefa. Embora a absorção silenciosa de InterruptedException sem enfileirar uma tarefa vista em
PlainWorkerPool esteja em conformidade com os requisitos mínimos de estruturas de passagem de mensagem
unidirecional, a maioria dos aplicativos precisa tomar outras ações corretivas.

4.1.4.2 Filas de eventos

Muitos frameworks baseados em eventos (incluindo aqueles suportados nos pacotes java.awt e javax.swing )
contam com designs nos quais exatamente um thread de trabalho opera em uma fila ilimitada. A fila contém instâncias
de EventObject que devem ser despachadas (ao contrário de objetos Runnable que se autodespacham), normalmente
para objetos ouvintes definidos pelo aplicativo. Freqüentemente, os ouvintes são os mesmos objetos que inicialmente
geram eventos.

O uso de um único thread operando em uma única fila de eventos simplifica o uso em comparação com designs gerais de
threads de trabalho, mas também impõe algumas limitações que são características das estruturas de eventos:

As propriedades de ordenação de uma fila podem ser exploradas para otimizar o manuseio. Por exemplo,
técnicas automáticas de filtragem de eventos podem ser usadas para remover ou combinar redesenhos duplicados
Machine Translated by Google

eventos para a mesma área da tela antes de atingirem o início da fila e serem levados pelo thread de trabalho.

Você pode exigir que todos os métodos que operam em determinados objetos sejam invocados apenas pela emissão
de eventos na fila e, portanto, sejam executados por um único thread de trabalho. Isso resulta em uma forma de
confinamento de thread (consulte § 2.3.2) desses objetos. Se for seguido sem falhas, isso elimina a necessidade de bloqueio
dinâmico nas operações nesses objetos, melhorando assim o desempenho. Isso também pode reduzir a
complexidade de aplicativos que não precisam construir threads.

Esta é a base para a regra de thread único do Swing: Com apenas algumas exceções, toda manipulação de
objetos Swing deve ser executada pelo thread do manipulador de eventos. Embora não seja declarado no AWT, é uma
boa ideia observar essa regra também.

Os eventos não devem ser ativados até que seus manipuladores estejam totalmente construídos e, portanto, prontos para
lidar com eventos. Isso vale também para outros projetos baseados em thread (consulte § 2.2.7), mas é uma fonte de erro
mais comum aqui porque registrar um manipulador de evento ou ouvinte dentro de seu construtor não é uma
maneira tão óbvia de ativar prematuramente a execução simultânea como é construindo um fio.

Os usuários da estrutura de eventos nunca devem despachar ações que bloqueiam de maneiras que podem ser desbloqueadas
apenas como resultado da manipulação de um evento futuro. Esse problema é encontrado ao implementar diálogos modais
na maioria das estruturas de eventos e requer uma solução ad hoc. No entanto, soluções mais localizadas podem ser
obtidas simplesmente definindo um estado desativado para componentes interativos que não devem ser usados
até que um determinado evento de reativação seja recebido. Isso evita o bloqueio da fila de eventos sem permitir que ações
indesejadas sejam acionadas.
Além disso, para manter a capacidade de resposta da estrutura de eventos, as ações não devem ser bloqueadas e não
devem executar operações demoradas.

Esse conjunto de opções de design faz com que as estruturas de evento tenham um desempenho muito melhor do que os designs
de encadeamento por evento e os torna mais simples de programar por desenvolvedores que não usam encadeamentos. No entanto, as
restrições de uso têm mais impacto em programas que constroem outros threads.
Por exemplo, devido à regra de encadeamento único, mesmo as menores manipulações de componentes da GUI (como alterar o texto
em um rótulo) devem ser executadas emitindo objetos de evento executáveis que encapsulam uma ação a ser executada pelo
encadeamento do manipulador de eventos.

Em aplicativos Swing e AWT, os métodos


javax.swing.SwingUtilities.invokeLater e java.awt.EventQueue.invokeLater podem ser

usados para executar comandos relacionados à exibição no encadeamento do manipulador de eventos. Esses métodos criam objetos
de evento executáveis que são executados quando retirados da fila. O suplemento online contém links para uma classe de utilitário
SwingWorker que automatiza parcialmente a conformidade com essas regras para threads que produzem resultados que levam a
atualizações de tela.

4.1.4.3 Temporizadores

O fato de que as tarefas Executáveis em projetos de encadeamento de trabalho podem ficar na fila sem serem executadas é um problema
a ser contornado em alguns aplicativos. Mas às vezes se torna um recurso quando as ações devem ser adiadas.

O uso de threads de trabalho pode melhorar a eficiência e simplificar o uso de ações atrasadas e periódicas que são acionadas em
determinados horários, após determinados atrasos ou em intervalos regulares (por exemplo, todos os dias ao meio-dia). Um recurso
de cronômetro padronizado pode automatizar cálculos de tempo confusos e evitar a construção excessiva de encadeamentos
reutilizando os encadeamentos de trabalho. A principal desvantagem é que, se um trabalhador
Machine Translated by Google

bloqueia ou leva muito tempo processando uma tarefa, o acionamento de outras pode ficar mais atrasado do que
seria se Threads separados fossem criados e agendados pela JVM subjacente.

Daemons baseados em tempo podem ser construídos como variantes do projeto básico de thread de trabalho
descrito em § 4.1.4.1. Por exemplo, aqui estão os destaques de uma versão que depende de uma classe de fila
de prioridade não mostrada (que pode assumir uma forma semelhante à fila de agendamento ilustrada em §
4.3.4) e está configurada para suportar apenas um thread de trabalho:

class TimerDaemon { // Fragmentos

static class TimerTask implements Comparable { // ... final Runnable command;


final long execTime; // tempo para
executar em public int compareTo(Object x) { long otherExecTime =
((TimerTask)(x)).execTime; return (execTime <
otherExecTime) ? -1 : (execTime == otherExecTime)? 0 : 1;

}
}

// uma pilha ou lista com métodos que preservam // ordenação em


relação a TimerTask.compareTo

classe estática PriorityQueue { void


put(TimerTask t);
TimerTask pelo menos ();
void removeLeast(); booleano
isEmpty(); }

protected final PriorityQueue pq = new PriorityQueue();

public sincronizado void executeAfterDelay(Runnable r,long t){ pq.put(new TimerTask(r, t +

System.currentTimeMillis())); notificarTodos(); } public sincronizado void executeAt(Runnable


r, Date time) {

pq.put(new TimerTask(r, time.getTime())); notificarTodos(); }

// espera e depois retorna a próxima tarefa para executar runnable


sincronizado protegido take()
lança InterruptedException {
para (;;) {
while (pq.isEmpty()) wait();
TimerTask
t = pq.least();
Machine Translated by Google

longo agora = System.currentTimeMillis(); long


waitTime = agora - t.execTime; if (waitTime <=
0) { pq.removeLeast(); return
t.command; } else
wait(waitTime);

}
}

public TimerDaemon() { activate(); } // apenas um

void activate() { // igual


a PlainWorkerThread, exceto pelo método take acima }

As técnicas discutidas em § 3.7 podem ser usadas aqui para melhorar a eficiência das operações de espera e
notificação.

Essa classe pode ser estendida para lidar com tarefas periódicas, incluindo contabilidade adicional para recolocá-las na fila
antes de executá-las. No entanto, isso também requer lidar com o fato de que as ações agendadas periodicamente
quase nunca são exatamente periódicas, em parte porque as esperas cronometradas não são necessariamente ativadas
exatamente nos atrasos fornecidos. As principais opções são ignorar atrasos e reagendar pelo horário do relógio, ou
ignorar o relógio e reagendar a próxima execução em um atraso fixo após iniciar a atual. Esquemas mais sofisticados são
normalmente necessários para sincronização multimídia, consulte as Leituras Adicionais em § 1.3.5.

Daemons de timer[1] também podem suportar métodos que cancelam ações atrasadas ou periódicas. Uma abordagem
é fazer com que executeAt e outros métodos de agendamento aceitem ou retornem adequadamente um TimerTask retrabalhado
com suporte a um método de cancelamento que define um sinalizador de status honrado pelo thread de trabalho.

[1]
No momento em que este livro foi escrito, uma classe semelhante está programada para ser suportada em uma próxima versão do SDK

4.1.5 Pesquisa e E/S orientada a eventos

A maioria dos designs de thread de trabalho depende de canais de bloqueio nos quais o thread de trabalho aguarda a
execução de comandos de entrada. No entanto, há alguns contextos nos quais os loops de repetição de estilo otimista
fornecem uma solução melhor. A maioria envolve a execução de comandos provenientes de mensagens recebidas em fluxos
de IO.

Pode ser um desafio atingir baixas latências e altas taxas de transferência em sistemas vinculados a E/S altamente
carregados. O tempo gasto para criar um thread que executa uma tarefa baseada em IO adiciona latência, mas a maioria
dos sistemas de tempo de execução são ajustados de forma que, uma vez que os threads são criados, eles respondem
muito bem às novas entradas que chegam nos fluxos de IO. Na entrada, eles desbloqueiam com latências mais curtas do que
você provavelmente conseguiria por meio de outras técnicas. Especialmente no caso de E/S baseada em soquete, essas
forças geralmente favorecem designs de encadeamento por sessão de E/S, em que um encadeamento diferente é usado (ou
reutilizado) para cada sessão, dependendo da entrada de uma conexão diferente.
Machine Translated by Google

No entanto, à medida que o número de conexões ativas simultaneamente aumenta, outras abordagens são
(apenas) às vezes mais atraentes. Considere, por exemplo, um servidor de jogos multiplayer ou um servidor de
transações com:

Milhares de conexões de soquete simultâneas que entram e saem em uma taxa constante, por
exemplo, quando as pessoas começam e terminam um jogo.
Taxas de entrada relativamente baixas em qualquer soquete a qualquer momento. No entanto, somando
todas as conexões, as taxas de E/S agregadas podem ser muito altas.
Computação não trivial associada a pelo menos algumas entradas, por exemplo, aquelas que causam
mudanças globais de estado nos jogos.

Em grandes sistemas de mainframe, às vezes esse tipo de problema é resolvido com a criação de uma
máquina front-end de propósito especial que multiplexa todas as entradas em um único fluxo que é tratado pelo
serviço principal. O serviço principal geralmente é multithreaded, mas sua estrutura é simplificada e tornada
mais eficiente porque não precisa lidar com tantos clientes aparentes ao mesmo tempo.

Uma família de designs orientados a eventos e polling aborda esses problemas sem a necessidade de front-ends
especiais. Embora não sejam (no momento em que este livro foi escrito) explicitamente suportados pelas classes
java.io e java.net , são fornecidos ingredientes suficientes para permitir a construção de designs que podem
obter bom desempenho nesses tipos de situações. (Os projetos são análogos àqueles que usam operações de
seleção e pesquisa de soquete em outros sistemas e linguagens.) Ilustraremos com entradas em soquetes, mas
a abordagem também se aplica a saídas, a arquivos e a E/S usando dispositivos mais exóticos, como sensores .

4.1.5.1 Tarefas orientadas a eventos

Muitas tarefas baseadas em IO são inicialmente escritas em um estilo baseado em sessão (consulte § 2.3.1),
puxando continuamente comandos de soquetes e processando-os. Por exemplo:

class SessionTask implements Runnable { // Esboço de código genérico protegido final


Socket socket; entrada InputStream final
protegida;
SessionTask(Socket s) lança IOException {
soquete = s; entrada = socket.getInputStream(); }
Machine Translated by Google

public void run() { // Normalmente roda em uma nova thread


byte[] commandBuffer = new byte[BUFFSIZE]; tente (;;) {

int bytes = input.read(commandBuffer, 0, BUFFSIZE); if (bytes != BUFFSIZE)


break; processCommand(commandBuffer,
bytes); }

} catch (IOException ex)


{ cleanup(); }

finalmente
{ tente { input.close(); socket.close(); } catch(IOException
ignore) {} }

}}

Para permitir que muitas sessões sejam manipuladas sem usar muitos encadeamentos, as tarefas primeiro devem ser
refatoradas em um estilo orientado a eventos, onde um evento aqui significa disponibilidade de E/S. Nesse estilo, uma
sessão consiste possivelmente em muitas execuções de suas tarefas acionadas por eventos, cada uma das
quais é invocada quando a entrada se torna disponível.

As tarefas de E/S orientadas a eventos são semelhantes em forma aos manipuladores de eventos da GUI. Um design baseado em
sessão pode ser convertido em um formulário orientado a eventos por:

Isolar a funcionalidade básica por comando em um método de execução de tarefa reformulado que lê um
comando e executa a ação associada.
Definir o método de execução para que ele possa ser acionado repetidamente sempre que a entrada estiver
disponível para leitura (ou ocorrer uma exceção de E/S).
Manter manualmente o status de conclusão para que a ação por evento não seja mais acionada quando a
sessão terminar, normalmente porque a entrada foi esgotada ou a conexão foi encerrada.

Por exemplo:

class IOEventTask implements Runnable { // Esboço de código genérico

soquete de soquete final protegido; entrada


InputStream final protegida; protegido volátil
booleano feito = false; // trava verdadeiro

IOEventTask(Socket s) lança IOException {


soquete = s; entrada = socket.getInputStream(); }

public void run() { // dispara apenas quando a entrada estiver disponível if (done)
return;
Machine Translated by Google

byte[] commandBuffer = new byte[BUFFSIZE]; tente { int bytes =

input.read(commandBuffer, 0, BUFFSIZE); if (bytes != BUFFSIZE) feito = verdadeiro;


else processCommand(commandBuffer, bytes); } catch
(IOException ex) { cleanup(); feito = verdadeiro; } finalmente {

if (!feito) return; tente


{ input.close(); socket.close(); } catch(IOException ignore)
{} }

// Métodos de acesso necessários para disparar o agente: boolean done()


{ retorno feito; }
InputStream input() { return input; } }

4.1.5.2 Acionamento

Quando os eventos que conduzem cada tarefa orientada a eventos são relativamente pouco frequentes, um grande
número de tarefas pode ser processado por um pequeno número de threads de trabalho. O caso mais simples
ocorre quando o número de threads de trabalho é exatamente um. Aqui, o thread de trabalho pesquisa repetidamente
uma lista de soquetes abertos para ver se eles têm alguma entrada disponível (via InputStream.available) ou se
encontraram outras alterações de status relacionadas a IO. Nesse caso, o trabalhador executa o método de execução associado .

Esse estilo de thread de trabalho difere daqueles em § 4.1.4.1 porque, em vez de extrair tarefas de uma fila de
bloqueio e executá-las cegamente, o trabalhador deve verificar repetidamente uma lista de tarefas registradas para
ver se alguma pode ser executada. Ele remove cada tarefa da lista somente quando afirma ter concluído.

Uma forma genérica é:

class PollingWorker implementa Runnable { private Lista de // Incompleto


tarefas = ...; private long sleepTime = ...;

void register(IOEventTask t) void { tarefas.add(t); }


deregister(IOEventTask t) { tasks.remove(t); }

public void run() { tente { para


(;;) {

for (Iterator it = tasks.iterator(); it.hasNext();) {


IOEventTask t = (IOEventTask)(it.next()); if (t.feito())
Machine Translated by Google

cancelar registro(t);
else
{ gatilho booleano;
tente
{ gatilho = t.input().available() > 0; } catch

(IOException ex) {
gatilho = verdadeiro; // dispara se a exceção for verificada } if (trigger)

t.run();

}
}
Thread.sleep(sleepTime); // pausa entre varreduras }

} catch (InterruptedException ou seja) {} } }

Várias preocupações de design surgem aqui:

O polling depende intrinsecamente de loops de espera ocupada (consulte § 3.2.6), que são intrinsecamente
um desperdício (mas às vezes ainda menos do que a troca de contexto). Lidar com isso requer decisões
empiricamente orientadas sobre como inserir suspensões, rendimentos ou ações alternativas para encontrar
um equilíbrio entre conservar o tempo da CPU e manter latências de resposta médias aceitáveis.
O desempenho é muito sensível às características da estrutura de dados subjacente que mantém a
lista de tarefas registradas. Se novas tarefas vêm e vão regularmente, a lista de tarefas pode mudar com
bastante frequência. Nesse caso, esquemas como copy-on-write (consulte § 2.4.4) geralmente não funcionam
bem. Mas há todos os motivos para tornar a travessia da lista o mais barata possível.
Uma abordagem é manter uma lista em cache para passagem e atualizá-la (se necessário) somente no final de
cada varredura.
As tarefas orientadas a eventos devem ser acionadas somente quando tiverem dados suficientes para
executar suas ações associadas. No entanto, em muitos aplicativos (por exemplo, aqueles que usam comandos
baseados em string de forma livre), a quantidade mínima de dados necessária para o acionamento não é conhecida com antecedência.
Na prática (conforme ilustrado aqui), geralmente basta verificar se pelo menos um byte está disponível.
Isso explora o fato de que os clientes baseados em soquete enviam pacotes normalmente, cada pacote
contém um comando inteiro. No entanto, quando os comandos não chegam como unidades, o thread de
trabalho pode travar, aumentando assim as latências de outras tarefas, a menos que esquemas de buffer sejam
adicionados.
Um único thread de trabalho provavelmente não será aceitável se algumas entradas levarem a cálculos
demorados ou bloqueio de E/S. Uma solução é exigir que tais cálculos sejam executados em novos threads ou
por pools de threads de trabalho separados. No entanto, às vezes é mais eficiente empregar vários
encadeamentos de trabalho de pesquisa; o suficiente para que, em média, sempre haja uma pesquisa de
encadeamento para entradas.
O uso de vários encadeamentos de trabalho de pesquisa requer coordenação adicional para garantir que dois
trabalhadores não estejam tentando executar a mesma tarefa ao mesmo tempo, sem impedir de outra
forma as varreduras um do outro na lista de tarefas. Uma abordagem é definir as classes de tarefas e honrar
o status ocupado, por exemplo, via testAndSet (consulte § 3.5.1.4).
Machine Translated by Google

Dadas essas preocupações e a dependência do contexto das decisões de design associadas, não é
surpreendente que a maioria dos frameworks seja customizada para atender às demandas de aplicativos específicos.
No entanto, o pacote util.concurrent disponível no suplemento online inclui alguns utilitários que podem ser usados
para ajudar a criar soluções padronizadas.

4.1.6 Leituras Adicionais


A maioria dos detalhes sobre mensagens, formatos, transportes, etc., usados na prática são específicos para
pacotes e sistemas particulares, então as melhores fontes são os manuais e documentação que os acompanham.

Discussões sobre passagem de mensagens em sistemas distribuídos podem ser encontradas nas fontes listadas em § 1.2.5.
Qualquer um dos vários pacotes e estruturas pode ser usado para estender as técnicas discutidas aqui para aplicação
em contextos distribuídos. Por exemplo, a maioria desses designs (bem como a maioria na § 4.2 e em outras partes
deste livro) pode ser adaptada para uso em JavaSpaces. Por outro lado, muitas técnicas de passagem de
mensagens distribuídas podem ser reduzidas para serem aplicadas em configurações simultâneas e não distribuídas.

O design e a implementação usando JavaSpaces são discutidos em:

Freeman, Eric, Susan Hupfer e Ken Arnold. JavaSpaces: Princípios, Padrões e Prática, Addison-Wesley, 1999.

Para diferentes abordagens, consulte, por exemplo, os pacotes Aleph, JMS e Ninja, acessíveis por meio de links do
suplemento online. Muitos sistemas distribuídos comerciais são baseados em CORBA e estruturas relacionadas, que
também incluem algum suporte para passagem de mensagem unidirecional. Ver:

Henning, Michi e Steve Vinoski. Programação CORBA avançada com C++, Addison-Wesley, 1999.

Papa, Alan. O Guia de Referência CORBA, Addison-Wesley, 1998.

Algumas estratégias de mensagens unidirecionais em nível de sistema semelhantes àquelas apresentadas aqui
são descritas em:

Langendoen, Koen, Raoul Bhoedjang e Henri Bal. "Modelos para manipulação de mensagens assíncronas", IEEE
Concurrency, abril-junho de 1997.

Um argumento de que as estruturas de evento de fila única e thread único são uma base melhor para a
programação de aplicativos do que as estruturas baseadas em thread pode ser encontrada em:

Ousterhout, John. "Por que os threads são uma má ideia (para a maioria dos propósitos)", Conferência
Técnica USENIX, 1996.

4.2 Compondo mensagens unidirecionais

Muitos projetos interprocessos e distribuídos envolvem grupos de objetos que trocam mensagens unidirecionais
(consulte § 1.2 e § 4.5). Técnicas semelhantes podem ser aplicadas em programas simultâneos individuais. De fato,
conforme discutido em § 4.1, uma gama maior de opções de projeto está disponível em programas concorrentes do
que em sistemas distribuídos. As mensagens não precisam ser restritas a, digamos, comandos baseados em
soquete. Os programas simultâneos também podem empregar alternativas mais leves, incluindo invocações
diretas e comunicação baseada em eventos.
Machine Translated by Google

No entanto, essa ampla gama de opções também apresenta oportunidades para a criação de designs caóticos e
difíceis de entender. Esta seção descreve algumas técnicas simples de estruturação em nível de programa
(ou nível de subsistema) que tendem a produzir projetos bem comportados, prontamente compreensíveis e
prontamente extensíveis.

Uma rede de fluxo é uma coleção de objetos que passam mensagens unidirecionais, transferindo informações e/
ou objetos uns para os outros ao longo de caminhos desde as fontes até os sorvedouros. Os padrões de fluxo podem
ocorrer em qualquer tipo de sistema ou subsistema que suporte uma ou mais séries de etapas ou estágios conectados,
em que cada estágio desempenha o papel de produtor e/ou consumidor. As categorias amplas incluem:

Sistemas de controle. As entradas de sensores externos, em última análise, fazem com que os sistemas de
controle gerem saídas efetoras específicas. Aplicações como sistemas de controle de aviônicos contêm dezenas de
tipos de entradas e saídas. Para um exemplo mais simples, considere um controle de aquecedor termostático esquelético:

Sistemas de montagem. Objetos recém-criados passam por uma série de mudanças e/ou se integram a outros novos
objetos antes de finalmente serem usados para algum propósito; por exemplo, uma linha de montagem para
embalagens cartonadas:

Sistemas de fluxo de dados. Cada estágio transforma ou processa os dados. Por exemplo, em sistemas multimídia
em pipeline, os dados de áudio e/ou vídeo são processados em vários estágios. Em sistemas de publicação-assinatura,
possivelmente muitas fontes de dados enviam informações para possivelmente muitos consumidores. Em
programas shell de pipes e filtros do Unix, os estágios enviam dados de caracteres, como em um verificador ortográfico simples:

Sistemas de fluxo de trabalho. Cada estágio representa uma ação que precisa ser executada de acordo com algum
conjunto de políticas de negócios ou outros requisitos; por exemplo, um sistema de pagamento simples:
Machine Translated by Google

Sistemas de eventos. Os estágios passam e, finalmente, executam o código associado a objetos que representam
mensagens, entradas do usuário ou fenômenos físicos simulados. O início de muitos sistemas de eventos assume a
forma:

4.2.1 Composição

O desenvolvimento de redes de fluxo envolve dois conjuntos principais de preocupações: projeto dos dados que estão
sendo transmitidos e projeto dos estágios que fazem a passagem.

4.2.1.1 Representações

As redes de fluxo passam em torno de componentes representacionais, famílias de valores ou objetos que representam
as coisas de que trata o fluxo. Nos exemplos introdutórios, temperaturas, folhas de papelão, palavras, faturas e eventos
são os tipos básicos de valores e objetos transmitidos por estágios conectados. Freqüentemente, esses componentes
são objetos interessantes em seus próprios direitos que podem executar serviços, comunicar-se com outros objetos
e assim por diante. Mas quando vistos como matéria-prima para um fluxo, eles são tratados como meras representações
passivas, fornecendo dados ou informações ao invés de comportamento.

Embora desempenhem funções semelhantes no projeto geral de um sistema de fluxo, diferentes categorias
de tipos de representação afetam os detalhes do restante do sistema:

Os tipos de informação que representam o estado do mundo (por exemplo, valores como leituras de
temperatura, mantidos como escalares ou objetos ADT imutáveis) diferem da maioria dos outros, pois
geralmente é aceitável reutilizar valores de melhor estimativa antigos ou atuais, se necessário. Em
essência, os produtores têm um suprimento inesgotável de tais valores.
Os indicadores de evento normalmente podem ser usados no máximo uma vez, embora possam ser
passados várias vezes antes de serem usados.
Tipos de recursos mutáveis (como caixas) podem ser transferidos (ver § 2.3.4) de cada estágio para o próximo,
garantindo que cada objeto esteja sendo operado por no máximo um estágio a qualquer momento.

Como alternativa, se as identidades dos objetos de representação mutáveis não importam, elas podem ser
copiadas entre os estágios conforme necessário. Abordagens baseadas em cópia são usadas com mais
frequência em redes de fluxo distribuído nas quais a propriedade não pode ser transferida entre estágios
simplesmente atribuindo campos de referência.
Tipos de dados artificiais podem ser usados para fins de controle. Por exemplo, um token nulo especial pode
ser usado como um terminador que aciona o cancelamento e o desligamento. Da mesma forma, um keepalive
especial pode ser enviado para informar a um estágio que outro ainda existe. Alternativamente, um
conjunto distinto de métodos de controle de banda lateral pode ser empregado nos estágios. Controles de
banda lateral são métodos usados para definir estágios em diferentes modos que influenciam seu
processamento principal. Por exemplo, um comparador de termostato pode ter um controle separado para alterar seu limite.
Machine Translated by Google

4.2.1.2 Etapas

Todos os estágios em redes de fluxo bem comportadas obedecem a conjuntos de restrições que lembram aqueles vistos no
projeto de circuitos elétricos. Aqui está um conjunto conservador de regras de composição que geram um pequeno
número de tipos básicos de estágios:

Direcionalidade. O fluxo mantém uma única direcionalidade, das fontes aos sumidouros. Não há loops ou ramificações
de consumidores para produtores. Isso resulta em um gráfico acíclico direcionado (DAG) de informações ou fluxo
de objetos.

Interoperabilidade. Métodos e formatos de mensagem são padronizados entre os componentes, normalmente por
meio da conformidade com um pequeno conjunto de interfaces.

Conectividade. Os estágios mantêm conectividade fixa: os consumidores podem receber mensagens apenas de produtores
conhecidos e vice-versa. Assim, por exemplo, enquanto um serviço da Web pode ter qualquer número de clientes anônimos,
um determinado objeto TemperatureComparator pode ser projetado para receber mensagens de atualização de temperatura
apenas de um objeto TemperatureSensor designado .

A conectividade geralmente é organizada mantendo referências diretas de produtores para consumidores ou vice-versa, ou
fazendo com que eles compartilhem o acesso a um canal. Alternativamente, uma rede pode ser baseada no uso restrito
de quadros-negros, canais multicast ou JavaSpaces (ver § 4.1.6) nos quais os produtores marcam especialmente as
mensagens destinadas a determinados consumidores.

Protocolos de transferência. Toda mensagem transfere informações ou objetos. Depois que um estágio transfere um
objeto mutável, ele nunca mais manipula esse objeto. Quando necessário, estágios de buffer especiais podem ser
interpostos para conter elementos transferidos de um estágio que ainda não podem ser aceitos por outros estágios.

Os protocolos de transferência geralmente dependem das operações básicas de venda e recebimento descritas em § 2.3.4.
Quando todas as mensagens envolvem transferências baseadas em put, as redes são normalmente rotuladas como push
flow; quando envolvem transferências baseadas em take, são normalmente rotulados como pull flow; quando envolvem
canais que suportam tanto put quanto take (e possivelmente troca), podem assumir várias formas mistas.

Tópicos. Os estágios podem implementar a passagem de mensagem unidirecional usando qualquer um dos padrões descritos
em § 4.1, desde que cada conexão ao vivo (potencialmente) simultânea de um determinado produtor para um
determinado consumidor empregue um encadeamento diferente ou construção de envio de mensagens baseada em encadeamento.

Raramente é necessário atender a esse requisito emitindo todas as mensagens, ou todos os fluxos de mensagens
de um produtor para um consumidor, em um thread diferente. Em vez disso, você pode explorar as regras de conectividade
para usar encadeamentos apenas quando necessário. A maioria das fontes em sistemas baseados em push emprega threads intrinsecamente.
Além disso, qualquer estágio de envio com vários sucessores que podem atingir um estágio Combiner deve emitir as
mensagens de forma independente. Caso contrário, se um thread for bloqueado no ponto de combinação, pode haver a
possibilidade de o Combiner nunca ver as outras entradas necessárias para desbloqueá-lo.

Fontes não têm


predecessores.
Machine Translated by Google

Pias não tem


sucessores.

Os estágios lineares têm


no máximo um predecessor
e um sucessor.

Os roteadores enviam uma


mensagem para um de seus
sucessores.

Multicasters enviam
mensagens para todos
os seus sucessores.

Os coletores aceitam
mensagens de um de seus
predecessores por vez.

Combiners requerem
mensagens de todos os
seus predecessores.
Machine Translated by Google

Por outro lado, a maioria dos coletores em sistemas baseados em pull emprega intrinsecamente
construções de mensagens baseadas em thread, assim como os estágios envolvidos em conexões split/join
procedendo da direção oposta mostrada acima.

Essas regras podem ser liberalizadas de várias maneiras. Na verdade, você pode adotar qualquer conjunto de regras
de composição que desejar. Mas as restrições listadas servem para eliminar grandes classes de problemas de
segurança e vivacidade, ao mesmo tempo em que satisfazem metas comuns de reutilização e desempenho:
fluxo unidirecional evita deadlock, gerenciamento de conectividade evita intercalações indesejadas em diferentes
fluxos, protocolos de transferência evitam problemas de segurança devido ao compartilhamento
inadvertido sem a necessidade de ampla sincronização dinâmica e conformidade de interface garantem a segurança
de tipo enquanto ainda permitem a interoperabilidade entre os componentes.

4.2.1.3 Scripts

A adoção de um conjunto padrão de regras de composição torna possível a construção de ferramentas de alto
nível que permitem que os estágios operem cooperativamente, sem impor o controle centralizado de
sincronização dinâmica. A composição de redes de fluxo pode ser tratada como uma forma de script no sentido
usual da palavra programação semiautomática do código que une instâncias de tipos de objetos existentes. Esse é
o tipo de programação associado a linguagens como JavaScript, Visual Basic, shells Unix e FlowMark (uma
ferramenta de fluxo de trabalho). O desenvolvimento de uma ferramenta de script ou integração com uma já
existente é uma etapa opcional na construção de sistemas baseados em fluxos.

Essa arquitetura é análoga à dos construtores de GUI, consistindo em um conjunto básico de widgets, empacotadores
e gerenciadores de layout, código para instanciar uma GUI específica e um scripter visual que ajuda a configurar tudo.
Como alternativa, pode ser possível criar fluxos de script por meio de ferramentas de manipulação direta pelas
quais, por exemplo, os componentes se comunicam instantaneamente quando arrastados e soltos para conectar-se a outros.
Machine Translated by Google

4.2.2 Linha de Montagem

O restante desta seção ilustra o projeto e a implementação de sistemas de fluxo por meio de um exemplo
de miniaplicativo de linha de montagem que constrói uma série de "pinturas" em um estilo que lembra
vagamente os artistas Piet Mondrian e Mark Rothko. Apenas as classes principais são dadas aqui. Alguns
incluem declarações de método não implementadas. O código completo pode ser encontrado no suplemento
online, que também inclui outros exemplos de nível de aplicativo de sistemas baseados em fluxo.

4.2.2.1 Representações

Para começar, precisamos de alguns tipos básicos de representação. Neste sistema, todos os elementos
podem ser definidos como subclasses da classe abstrata Box, onde cada Box tem uma cor e um tamanho,
pode se exibir quando solicitado e pode ser feito para clonar (duplicar) profundamente . A mecânica de
cores é implementada por padrão. Outros são deixados abstratos, para serem definidos de forma diferente em diferentes subclasses:

classe abstrata Caixa { cor


protegida cor = Cor.branco;

public sincronizado Color getColor() {return color;} public sincronizado void


setColor(Color c) {color = c;} public abstract java.awt.Dimension size(); // clona a
caixa abstrata pública duplicada(); public abstract void
g, Point origin);// display } show(Graphics
Machine Translated by Google

O tema geral deste exemplo é começar com fontes que produzem caixas básicas simples e, em seguida,
empurrá-las por estágios que pintam, juntam, invertem e incorporam para formar as pinturas.
As BasicBoxes são a matéria-prima:

class BasicBox estende Box {


tamanho da dimensão final protegida;

public BasicBox(int xdim, int ydim) { tamanho = new


Dimension(xdim, ydim); }

tamanho da dimensão sincronizada pública() { return tamanho; }

public void show(Graphics g, Point origin) {


g.setColor(getColor());
g.fillRect(origem.x, origem.y, tamanho.largura, tamanho.altura); }

public sincronizado Box duplicado() { Box p = new


BasicBox(size.width, size.height); p.setColor(getColor()); retornar p; }

Dois tipos mais sofisticados de caixas podem ser feitos juntando-se duas caixas existentes lado a lado e
adicionando uma borda baseada em linha ao redor delas. Caixas unidas também podem virar sozinhas.
Tudo isso pode ser feito na horizontal ou na vertical. As duas classes resultantes podem ser subclasses
de JoinedPair para permitir o compartilhamento de algum código comum:

classe abstrata JoinedPair estende Caixa { caixa protegida


fst; // uma das caixas protegidas Box snd; // o outro

protegido JoinedPair(Caixa a, Caixa b) { fst = a; snd =


b; }

public sincronizado void flip() { // troca fst/snd


Caixa tmp = fst; fst = snd; snd = tmp; }

// outros métodos auxiliares internos }

class HorizontallyJoinedPair extends JoinedPair {

public HorizontallyJoinedPair(Caixa l, Caixa r) {


Machine Translated by Google

super(l, r); }

caixa pública sincronizada duplicada () {


HorizontallyJoinedPair p =
novo HorizontallyJoinedPair(fst.duplicate(), snd.duplicate());

p.setColor(getColor()); retornar
p; }

// ... outras implementações de métodos Box abstratos }

class VerticallyJoinedPair extends JoinedPair { // semelhante }

O tipo final de caixa sofisticada envolve uma caixa dentro de uma borda:

class WrappedBox extends Box { protegido


Dimensão wrapperSize; Caixa protegida interna;

public WrappedBox(Box innerBox, tamanho da dimensão) {


interior = caixainterna;
wrapperSize = tamanho; }

// ... outras implementações de métodos Box abstratos }

4.2.2.2 Interfaces

Pensando em como podemos querer encadear os estágios, vale a pena padronizar as interfaces.
Gostaríamos de ser capazes de conectar qualquer estágio a qualquer outro estágio para o qual possa fazer
sentido, então queremos nomes suaves e não comprometedores para os métodos principais.

Como estamos fazendo um fluxo baseado em push unidirecional, essas interfaces descrevem principalmente
métodos de estilo put. Na verdade, poderíamos simplesmente chamá-los de todos colocados, exceto que isso não
funciona muito bem para estágios de duas entradas. Por exemplo, um VerticalJoiner precisa de dois métodos
de colocação, um fornecendo a caixa superior e outro a caixa inferior. Poderíamos evitar isso projetando Joiners
para obter entradas alternativas como os topos e os fundos, mas isso os tornaria mais difíceis de controlar. Em
vez disso, usaremos os nomes um tanto feios, mas facilmente extensíveis , putA , putB e assim por diante:
Machine Translated by Google

interface PushSource { void


produzir();
}

interface PushStage { void


putA(Box p);
}

interface DualInputPushStage estende PushStage {


void putB(Caixa p);
}

4.2.2.3 Adaptadores

Podemos tornar os canais "B" de DualInputPushStages completamente transparentes para outros


estágios, definindo uma classe Adapter simples que aceita um putA , mas o retransmite para o putB do
destinatário pretendido . Dessa forma, a maioria dos estágios pode ser construída para invocar putA sem
saber ou se importar se a caixa está sendo alimentada em algum canal B do sucessor:

class DualInputAdapter implementa PushStage { estágio final


protegido de DualInputPushStage;

public DualInputAdapter(DualInputPushStage s) { estágio = s; }

public void putA(Caixa p) { stage.putB(p); }

}
Machine Translated by Google

4.2.2.4 Pia

Sinks não têm sucessores. O tipo mais simples de coletor nem mesmo processa sua entrada e, portanto, serve
como uma maneira de descartar elementos. No espírito dos tubos e filtros Unix, podemos chamá-lo de:

class DevNull implementa PushStage { public void


putA(Box p) { } }

Coletores mais interessantes requerem códigos mais interessantes. Por exemplo, no applet usado para
produzir a imagem mostrada no início desta seção, a própria subclasse Applet foi definida para
implementar PushStage. Serviu como a pia final, exibindo os objetos montados.

4.2.2.5 Conexões

As interfaces padronizam os nomes dos métodos para os estágios, mas não fazem nada sobre as ligações aos
sucessores, que devem ser mantidas usando algum tipo de variável de instância em cada objeto do estágio.
Exceto para coletores como DevNull, cada estágio tem pelo menos um sucessor. Existem várias opções de
implementação, incluindo:

Faça com que cada objeto mantenha um objeto de coleção contendo todos os seus sucessores.
Use um registro de conexão mestre com o qual cada estágio interage para descobrir seu(s) sucessor(es).
Crie a representação mínima: defina uma classe base para estágios com exatamente um sucessor
e uma para aqueles com exatamente dois sucessores.

A terceira opção é mais simples e funciona bem aqui. (Na verdade, é sempre uma opção válida. Estágios com
três ou mais saídas podem ser construídos em cascata para apenas dois. Claro, você não gostaria de fazer
isso se a maioria dos estágios tivesse um número grande e/ou variável de sucessores .)

Isso leva a classes base que suportam um ou dois links e têm um ou dois métodos de anexo
correspondentes, nomeados usando uma convenção de sufixo feia semelhante (attach1, attach2). Como as
conexões são atribuíveis dinamicamente, elas são acessadas somente sob sincronização:

class SingleOutputPushStage { private


PushStage next1 = nulo; PushStage
sincronizado protegido next1() { return next1; } public sincronizado void
attach1(PushStage s) { next1 = s; } }
Machine Translated by Google

class DualOutputPushStage extends SingleOutputPushStage {


pushStage privado next2 = nulo; PushStage
sincronizado protegido next2() { return next2; } public void attach2(PushStage s)
{ next2 = s; } }

4.2.2.6 Etapas lineares

Agora podemos construir todos os tipos de classes que estendem qualquer uma das classes
base, implementando simultaneamente qualquer uma das interfaces padrão. Os estágios
transformacionais mais simples são lineares, estágios de entrada/saída única. Painters, Wrappers e Flippers são meramente:

classe Painter estende SingleOutputPushStage


implementa PushStage { cor
final protegida da cor; // a cor para pintar as caixas

public Painter(Cor c) { cor = c; }

public void putA(Caixa p)


{ p.setColor(cor);
next1().putA(p); }

classe Wrapper estende SingleOutputPushStage


Machine Translated by Google

implementa PushStage
{ espessura int final protegida;

public Wrapper(int t) { espessura = t; }

public void putA(Caixa p) {


Dimensão d = nova Dimensão(espessura, espessura); next1().putA(new
WrappedBox(p, d)); } }

classe Flipper estende SingleOutputPushStage


implementa PushStage { public
void putA(Box p) {
if (p instância de JoinedPair)
((Pair Unidos) p).flip();
next1().putA(p); }

Os estágios Painter e Wrapper se aplicam a qualquer tipo de caixa. Mas Flippers só


fazem sentido para JoinedPairs: se um Flipper receber algo diferente de JoinedPair,
ele apenas o passará. Em uma versão mais "fortemente tipada", podemos optar
por descartar caixas diferentes de JoinedPairs, talvez enviando-as para DevNull.

4.2.2.7 Combinadores

Temos dois tipos de Combiners, Joiners horizontais e verticais. Como as classes de representação,
essas classes têm o suficiente em comum para fatorar uma superclasse. Os estágios Joiner
bloqueiam entradas adicionais até que possam combinar um item de cada putA e putB. Isso pode
ser implementado por meio de mecanismos de guarda que impedem a aceitação de itens
adicionais de putA até que os existentes sejam emparelhados com os de putB e vice-versa:

classe abstrata Joiner estende SingleOutputPushStage implementa


DualInputPushStage {
Machine Translated by Google

Caixa protegida a = null; // entrada de putA protected Box b = null; //


entrada de putB

junção de caixa abstrata protegida (Caixa p, Caixa q);

Caixa sincronizada protegida joinFromA(Box p) { // espera até o


while (a != null) tente último consumo
{ wait(); } catch
(InterruptedException e) { return null; }
a = p;
return tryJoin(); } Caixa

sincronizada protegida joinFromB(Box p) { // simétrica


while (b != null) tente
{ wait(); } catch
(InterruptedException ou seja) { return null; } b = p; return tryJoin(); }

Caixa sincronizada protegida tryJoin() { if (a == null || b


== null) return null; // Não pode participar
Caixa unida = join(a, b); // faz caixa combinada a = b = null; // esquece caixas
antigas // permite novos puts notifyAll(); retorno unido; }

public void putA(Caixa p) { Caixa j =


joinFromA(p); if (j != null)
next1().putA(j); }

public void putB(Caixa p) { Caixa j =


joinFromB(p); if (j != null)
next1().putA(j); }

} class HorizontalJoiner extends Joiner { Protected Box


join(Box p, Box q) {
return novo HorizontallyJoinedPair(p, q); } } class

VerticalJoiner extends Joiner { Protected Box join(Box


p, Box q) {
return novo VerticallyJoinedPair(p, q); }

}
Machine Translated by Google

4.2.2.8 Coletores

Um coletor aceita mensagens em qualquer um dos canais e as retransmite para um único sucessor:

class Collector extends SingleOutputPushStage implements


DualInputPushStage { public void putA(Box
p) { next1().putA(p);} public void putB(Box p) { next1().putA(p); } }

Se por algum motivo precisássemos impor um gargalo aqui, poderíamos definir uma forma alternativa de
coletor em que esses métodos sejam declarados como sincronizados. Isso também pode ser usado
para garantir que no máximo uma atividade esteja progredindo em um determinado coletor em um determinado momento.

4.2.2.9 Estágios de saída dupla

Nossos estágios de múltiplas saídas devem gerar threads ou usar uma das outras opções discutidas em §
4.1 para conduzir pelo menos uma de suas saídas (não importa qual). Isso mantém a vivacidade quando os
elementos são finalmente passados para os estágios Combiner (aqui, os Joiners). Para simplificar a
ilustração, as classes a seguir criam novos Threads. Como alternativa, podemos configurar um pool de
threads de trabalho simples para processar essas mensagens.

Os alternadores emitem entradas alternativas para sucessores alternativos:

classe Alternator estende DualOutputPushStage


implementa PushStage { boolean
outTo2 protegido = false; // controla a alternância

protegido sincronizado booleano testAndInvert() {


booleano b = outTo2;
Machine Translated by Google

outTo2 = !outTo2; retornar


b; }

public void putA(final Box p) { if (testAndInvert())


next1().putA(p); else { new
Thread(new Runnable()
{ public
void run() { next2().putA(p); } }).start();

}
}
}

Os clonadores fazem multicast do mesmo elemento para ambos os sucessores:

class Cloner extends DualOutputPushStage implementa


PushStage {

public void putA(Caixa p) {


caixa final p2 = p.duplicate();
próximo1().putA(p); new
Thread(new Runnable() { public void run()
{ next2().putA(p2); } }).start();

Um Screener é um estágio que direciona todas as entradas obedecendo algum predicado para um canal, e todos
os outros para o outro:
Machine Translated by Google

Podemos construir um Screener genérico encapsulando o BoxPredicate para fazer o check-in de uma
interface e implementá-lo, por exemplo, com uma classe que garanta que um Box caiba
dentro de um determinado limite (simétrico, neste caso). O próprio Screener aceita um BoxPredicate
e o utiliza para direcionar as saídas:

interface BoxPredicate { boolean


test(Box p); }

classe MaxSizePredicate implementa BoxPredicate {

protegido final int máximo; // tamanho máximo para deixar passar

public MaxSizePredicate(int máximo) { max = máximo; }

public boolean test(Box p) { return


p.size().height <= max && p.size().width <= max; } }

class Screener estende DualOutputPushStage implementa


PushStage {

predicado BoxPredicate final protegido; public


Screener(BoxPredicate p) { predicate = p; }

public void putA(final Box p) { if


(predicate.test(p)) {
new Thread(new Runnable() { public
void run() { next1().putA(p); } }).start();

} else
next2().putA(p);

}}

4.2.2.10 Fontes
Machine Translated by Google

Aqui está uma fonte de exemplo, que produz BasicBoxes de tamanhos aleatórios. Por conveniência,
também é equipado com um método de execução de loop autônomo invocando repetidamente produtos,
intercalados com atrasos de produção aleatórios:

class BasicBoxSource extends SingleOutputPushStage implementa


PushSource, Runnable {

tamanho da dimensão final protegida; // tamanhos máximos


protegido final int ProductionTime; // atraso simulado

public BasicBoxSource(Dimension s, int delay) {


tamanho =
s; ProductionTime = atraso; }

Caixa protegida makeBox() {


return new BasicBox((int)(Math.random() * size.width) + 1,
(int)(Math.random() * size.height) + 1);
}

public void produzir() {


next1().putA(makeBox()); }

public void run() { tente


{ para
(;;) {
produzir();
Thread.sleep((int)(Math.random() * 2* ProductionTime)); }

} catch (InterruptedException ou seja) { } } // morrer

4.2.2.11 Coordenação

Sem uma ferramenta de script baseada nessas classes, temos que programar linhas de montagem
criando manualmente instâncias dos estágios desejados e vinculando-os. Isso é fácil em princípio, mas
tedioso e sujeito a erros na prática devido à falta de orientação visual sobre quais estágios estão conectados
a quê.
Machine Translated by Google

Aqui está um fragmento do fluxo usado no applet que produziu a imagem exibida no início desta seção:

O código que configura isso pode ser encontrado no suplemento online. O construtor principal consiste
principalmente em muitas linhas do formulário:

Estágio aEstágio = new Estágio();


aStage.attach(anotherStage);

Isso é seguido pela invocação de start nos threads que executam todas as fontes.

4.2.3 Leituras Adicionais


Os padrões de fluxo geralmente servem como versões computacionais de casos de uso, cenários, scripts e
conceitos relacionados da análise orientada a objetos de alto nível. A maioria dos livros sobre projeto OO e
sobre padrões de projeto listados em § 1.3.5 e § 1.4.5 descreve questões relevantes para a análise,
projeto e implementação de sistemas baseados em fluxo. Questões específicas de domínio em torno de
redes de pacotes, telecomunicações e sistemas multimídia, muitas vezes exigindo projetos baseados em fluxo
mais elaborados, são discutidas nos textos sobre sistemas concorrentes e distribuídos em § 1.2.5.

4.3 Serviços em Threads

Muitas tarefas calculam resultados ou fornecem serviços que não são usados imediatamente por seus clientes,
mas são eventualmente solicitados por eles. Nessas situações, ao contrário daquelas que envolvem mensagens
unidirecionais, as ações de um cliente em algum ponto tornam-se dependentes da conclusão da tarefa.

Esta seção descreve algumas das alternativas de design disponíveis: adicionar retornos de chamada a
mensagens unidirecionais, depender de Thread.join, criar utilitários com base em Futures e criar threads de
trabalho. A Seção § 4.4 revisita e estende essas técnicas no contexto de melhorar o desempenho de
tarefas computacionalmente intensivas em processadores paralelos.
Machine Translated by Google

4.3.1 Callbacks de Conclusão

Do ponto de vista da passagem de mensagem unidirecional pura, a maneira mais natural de lidar com a
conclusão é um cliente ativar uma tarefa por meio de uma mensagem unidirecional para um servidor e,
posteriormente, o servidor indicar a conclusão enviando uma mensagem de retorno unidirecional ao
chamador . Esse estilo eficiente, assíncrono e baseado em notificação aplica-se melhor em projetos
fracamente acoplados nos quais a conclusão do serviço aciona alguma ação independente no cliente. Projetos de
retorno de chamada de conclusão às vezes são estruturalmente idênticos aos projetos do Observador (consulte § 3.5.2).

Por exemplo, considere um aplicativo que oferece vários recursos, dos quais um ou mais exigem que um
determinado arquivo seja lido primeiro. Como o IO é relativamente lento, você não deseja desabilitar outros
recursos enquanto o arquivo está sendo lido, pois isso diminuiria a capacidade de resposta. Uma solução
é criar um serviço FileReader que leia o arquivo de forma assíncrona e, em seguida, emita uma mensagem de
volta ao aplicativo quando ele for concluído, para que o aplicativo possa prosseguir com o(s) recurso(s) que o
requer(em).

4.3.1.1 Interfaces
Machine Translated by Google

Para configurar um FileReader ou qualquer outro serviço usando retornos de chamada de conclusão,
você deve primeiro definir uma interface de cliente para mensagens de retorno de chamada. Os métodos
nessa interface são tipos de substitutos para os tipos de tipos de retorno e exceções que seriam associados
às versões processuais do serviço. Isso geralmente requer dois tipos de métodos, um associado à conclusão
normal e outro associado à falha que é invocada em qualquer exceção.

Além disso, os métodos de retorno de chamada geralmente exigem um argumento indicando qual ação foi
concluída, para que o cliente possa resolvê-los quando houver várias chamadas. Em muitos casos, isso
pode ser feito simplesmente enviando de volta alguns dos argumentos da chamada. Em esquemas mais
gerais, o serviço devolve um identificador exclusivo (geralmente conhecido como cookie) como valor de
retorno para a solicitação inicial e como argumento em qualquer método de retorno de chamada.
Variantes dessa técnica são usadas nos bastidores em estruturas de invocação remota que implementam chamadas processuais por m

No caso do FileReader, poderíamos usar interfaces como:

interface Leitor de Arquivos {


void read(String filename, cliente FileReaderClient);
}

interface FileReaderClient { void


readCompleted(String filename, byte[] data); void readFailed(String
filename, IOException ex);
}

4.3.1.2 Implementações
Machine Translated by Google

Existem dois estilos para implementar essas interfaces, dependendo se você deseja que o cliente ou o servidor crie o
thread que executa o serviço. Geralmente, se o serviço pode ser útil sem ser executado em seu próprio thread, o controle
deve ser atribuído aos clientes.

No caso mais típico em que o uso de encadeamentos é intrínseco aos projetos de retorno de chamada de conclusão, o
controle é atribuído ao método de serviço. Observe que isso faz com que os métodos de retorno de chamada sejam
executados no encadeamento construído pelo serviço, não em um construído diretamente pelo cliente. Isso pode
levar a resultados surpreendentes se algum código depender de propriedades específicas do
encadeamento, como ThreadLocal e java.security.AccessControlContext (consulte § 2.3.2) que não são conhecidas
pelo serviço.

Aqui poderíamos implementar um cliente e servidor usando uma abordagem de criação de thread de serviço como:

classe FileReaderApp implementa FileReaderClient { //


fragmentos
Leitor FileReader protegido = new AFileReader();

public void readCompleted(String filename, byte[] data) { // ... use data ...

public void readFailed(String filename, IOException ex){


// ... lidar com o fracasso ...
}

public void actionRequiringFile() { reader.read("AppFile",


this); }

public void actionNotRequiringFile() { ... } }

class AFileReader implementa FileReader {

public void read(final String fn, final FileReaderClient c) { new Thread(new Runnable() {

public void run() { doRead(fn, c); }


}).começar();
}

void protegido doRead(String fn, cliente FileReaderClient) { byte[] buffer = new byte[1024]; //
apenas para ilustração tente { FileInputStream s = new FileInputStream(fn); s.read(buffer);
if
(client != null) client.readCompleted(fn, buffer);

} catch (IOException ex) { if (client !=


null) client.readFailed(fn, ex); }
Machine Translated by Google

}
}

A classe de serviço aqui é escrita para lidar com um argumento de cliente nulo , acomodando assim clientes que não precisam
de retornos de chamada. Embora isso não seja particularmente provável aqui, os retornos de chamada em muitos serviços
podem ser tratados como opcionais. Como alternativa, você pode definir e usar uma classe NullFileReaderClient que contém
versões não operacionais dos métodos de retorno de chamada (consulte Leituras adicionais). Além disso, no que diz respeito
ao serviço, o destino do retorno de chamada pode ser qualquer objeto, por exemplo, algum auxiliar do objeto que solicita o
serviço. Você também pode substituir retornos de chamada por notificações de eventos usando as técnicas ilustradas em §
3.1.1.6.

4.3.1.3 Guardando métodos de callback

Em alguns aplicativos, os clientes podem processar retornos de chamada de conclusão somente quando estão em determinados estados.
Aqui, os próprios métodos de retorno de chamada devem conter guardas que suspendem o processamento de cada
retorno de chamada recebido até que o cliente possa lidar com isso.

Por exemplo, suponha que temos um FileReaderClient que inicia um conjunto de leituras de arquivos assíncronos e precisa
processá-los na ordem emitida. Essa construção imita como as invocações remotas geralmente são tratadas: normalmente, cada
solicitação recebe um número de sequência e as respostas são processadas em ordem de sequência. Essa pode ser uma
estratégia arriscada, pois fará com que indefinidos nunca sejam concluídos. Essa desvantagem pode ser resolvida
associando tempos limite com as esperas.

classe FileApplication implementa FileReaderClient { //


Fragmentos
private String[] nomes de arquivo;
private int currentCompletion; // índice do arquivo pronto

público void sincronizado readCompleted(String fn, byte[] d)


{
// espere até que esteja pronto para processar este callback
while (!fn.equals(filenames[currentCompletion])) { try { wait(); }

catch(InterruptedException ex) { return; } } // ... processa

dados... // ativa qualquer outro


thread aguardando nesta condição: ++currentCompletion; notificarTodos(); }

public sincronizado void readFailed(String fn, IOException e){ // similar...

arquivos de leitura void sincronizados public() {


conclusãoatual = 0; for (int i =
0; i < filenames.length; ++i) reader.read(filenames[i],this);
Machine Translated by Google

}
}

4.3.2 Unindo Tópicos

Embora os retornos de chamada de conclusão sejam muito flexíveis, eles são, na melhor das hipóteses, difíceis de usar quando
um chamador só precisa esperar uma tarefa específica iniciada.

Se uma operação ocorrendo em algum thread A não puder continuar até que algum thread B seja concluído, você pode
bloquear o thread A por meio de qualquer uma das técnicas de espera e notificação discutidas no Capítulo 3. Por exemplo,
assumindo a existência de um Latch (consulte § 3.4.2) nomeado encerrado acessível a partir de ambos os encadeamentos A e
B, o encadeamento A pode aguardar via terminado.acquire() e o encadeamento B pode executar terminado.release()
após concluir sua tarefa.

No entanto, geralmente não há razão para configurar suas próprias construções de espera e notificação, pois essa
funcionalidade já é fornecida pelo Thread.join: O método join bloqueia o chamador enquanto o destino está ativo. Encerrar
threads executa notificações automaticamente. O objeto monitor usado internamente para essa espera e notificação é
arbitrário e pode variar entre as implementações da JVM. Na maioria, o próprio objeto Thread de destino é
usado como o objeto monitor. (Esta é uma razão para não estender a classe Thread para adicionar métodos de execução que
invocam métodos de espera ou notificação.) Nos casos em que esses detalhes de Thread.join não atendem às
necessidades de um aplicativo específico, você sempre pode recorrer ao manual abordagem.

Thread.join ou variantes explicitamente codificadas podem ser usadas em projetos onde um cliente precisa que um serviço
seja executado, mas não depende imediatamente de seus resultados ou efeitos. (Às vezes, isso é conhecido como
invocação síncrona adiada.) Geralmente, esse é o caso quando a tarefa de serviço é demorada e pode se beneficiar do
paralelismo de CPU e/ou IO, de modo que executá-la em um thread separado pode melhorar a taxa de transferência geral.

Uma aplicação comum é o processamento de imagens. Obter os dados brutos de uma imagem de um arquivo ou soquete
e, em seguida, convertê-los em um formato que possa ser exibido são operações demoradas que envolvem processamento
de CPU e E/S. Freqüentemente, esse processamento pode ser sobreposto a outras operações relacionadas à exibição.

Uma versão dessa estratégia é usada por java.awt.MediaTracker e classes relacionadas, que devem ser usadas quando
aplicáveis. Aqui, ilustraremos uma versão mais genérica que pode ser estendida e refinada de várias maneiras para oferecer
suporte a aplicativos especializados.

Para configurar isso, suponha que haja uma interface Pic genérica para imagens e uma interface Renderer descrevendo
serviços que aceitam uma URL apontando para dados de imagem e, por fim, retornam uma Pic. (Em mais
Machine Translated by Google

configuração realista, o método render certamente também lançaria várias exceções de falha. Aqui,
assumiremos que ele simplesmente retorna nulo em qualquer falha.) Além disso, suponha
a existência de uma classe StandardRenderer implementando a interface Renderer.

Thread.join pode ser usado para escrever clientes como a seguinte classe PictureApp (que invoca
vários métodos inventados apenas para fins de ilustração). Ele cria um objeto de espera Runnable que
inicia o thread de renderização e acompanha o resultado retornado.

Embora seja uma prática comum, o uso de acesso não sincronizado (ou direto) de campos de resultados
internos , como visto no objeto Waiter , é um pouco delicado. Como o acesso não é sincronizado, a correção
depende do fato de que tanto a terminação do encadeamento quanto o método join empregam
intrinsecamente métodos ou blocos sincronizados (consulte § 2.2.7).

interface Pic { byte[]


getImage(); }

renderizador de interface {
Machine Translated by Google

Pic render(URL src); }

class PictureApp { // ... // Esboço do código


private
final Renderer renderer = new StandardRenderer();

public void show (URL final imageSource) {

classe Waiter implementa Runnable {


resultado da foto privada = null;
Pic getResult() { return resultado; } public void
executar() {
resultado = renderer.render(imageSource); } };

Garçom garçom = new Garçom(); Thread t =


new Thread(garçom); t.start();

exibirBorders(); // faz outras coisas displayCaption(); //


durante a renderização

tente
{ t.join(); }

catch(InterruptedException e) { cleanup();
retornar;

Pic pic = garçom.getResult(); if (pic != null)


displayImage(pic.getImage());
else // ... lidar com falha de renderização

assumida
}
}

Thread.join retorna o controle para o chamador se o thread foi concluído com êxito ou anormal. Para
simplificar a ilustração, a nulidade do campo de resultado é usada aqui para indicar qualquer tipo de falha,
incluindo o cancelamento do renderizador. A versão em § 4.3.3.1 ilustra uma abordagem mais responsável.

4.3.3 Futuros
As operações subjacentes às construções baseadas em junção podem ser empacotadas de maneira
mais conveniente e estruturada por:
Machine Translated by Google

Criação de objetos de dados "virtuais" do Futures que bloqueiam automaticamente quando os clientes tentam
invocar seus acessadores de campo antes que a computação seja concluída. Um Futuro atua como um
"IOU" para um determinado objeto de dados.
Criar versões de métodos de serviço que iniciam um ou mais threads e, em seguida, retornam objetos Future
que são desbloqueados quando os cálculos são concluídos.

Como a mecânica que envolve os futuros é construída no acesso a dados e nos métodos de serviço, eles podem ser
aplicados de maneira geral apenas se os objetos de dados e os métodos de serviço forem definidos usando interfaces,
não classes. No entanto, se as interfaces associadas estiverem definidas, os Futures são fáceis de configurar.
Por exemplo, um AsynchRenderer baseado no futuro pode empregar proxies em torno de classes de
implementação concretas (consulte § 1.4.2):

class AsynchRenderer implements Renderer { private final


Renderer renderer = new StandardRenderer();

static class FuturePic implements Pic { // classe interna private Pic pic = null;
privado booleano pronto = false;
void sincronizado setPic(Pic p) { pic = p;
Machine Translated by Google

pronto =
verdadeiro;
notificarTodos(); }

byte público sincronizado[] getImage() {


enquanto (!pronto)
tente {espera(); } catch
(InterruptedException e) { return null; } return pic.getImage(); }

renderização de imagem pública (origem da URL final) {


final FuturePic p = new FuturePic(); new Thread(new
Runnable() { public void run()
{ p.setPic(renderer.render(src)); }
}).começar();
retornar p; }

Para ilustração, o AsynchRenderer usa operações explícitas de espera e notificação com base
em um sinalizador pronto em vez de depender de Thread.join.

Os aplicativos que dependem dessa classe podem ser escritos de maneira simples:

class PicturAppWithFuture { esboço // Código

private final Renderer renderer = new AsynchRenderer();

public void show (URL final imageSource) { Pic pic =


renderer.render(imageSource);

exibirBorders(); // fazer outras coisas... displayCaption();

byte[] im = pic.getImage(); if (im != null)


displayImage(im);
else // lida com falha de
renderização assumida }

4.3.3.1 Responsáveis

A maioria dos projetos baseados em Futures assume exatamente a forma ilustrada na classe AsynchRenderer. A
construção e o uso de tais classes podem ser ainda mais padronizados e automatizados com uma interface mais
branda.
Machine Translated by Google

Da mesma forma que a interface Runnable descreve qualquer ação pura, uma interface Callable pode ser usada
para descrever qualquer método de serviço que aceite um argumento Object , retorne um resultado Object e
possa gerar uma exceção:

interface Chamável {
Chamada de objeto (objeto arg) lança exceção;
}

O uso de Object aqui (desajeitadamente) acomoda, por exemplo, a adaptação de métodos que aceitam vários
argumentos, colocando-os em objetos de array.

Embora existam outras opções, é mais conveniente empacotar a mecânica de suporte por meio de uma única
classe que coordena o uso. A seguinte classe FutureResult mostra um conjunto de opções. (É uma
versão simplificada do pacote util.concurrent disponível no suplemento online.)

A classe FutureResult mantém métodos para obter o Object resultante que é retornado, ou a Exception que
é lançada por um Callable. Ao contrário de nossas versões do Pic , onde todas as falhas foram apenas indicadas
por meio de valores nulos , ele lida com as interrupções de forma mais honesta, lançando exceções de volta aos
clientes que tentam obter resultados.

Para diferenciar adequadamente entre as exceções encontradas no serviço e aquelas encontradas ao


tentar executar o serviço, as exceções lançadas pelo Callable são reempacotadas usando
java.lang.reflect.InvocationTargetException, uma classe de propósito geral para agrupar uma exceção
dentro de outra.

Além disso, por uma questão de generalidade, o FutureResult não cria encadeamentos. Em vez disso, ele
oferece suporte ao configurador de método que retorna um Runnable que os usuários podem executar em um
thread ou em qualquer outro Executor de código (consulte § 4.1.4). Isso torna os Callables utilizáveis em
estruturas executáveis leves que, de outra forma, são configuradas para lidar com tarefas iniciadas por meio
de mensagens unidirecionais. Como uma estratégia alternativa, você pode configurar uma estrutura do Caller
que seja semelhante ao Executor, mas mais especializada para as necessidades de tarefas de estilo de serviço,
por exemplo, métodos de suporte para agendar a execução, verificar o status e controlar as respostas às exceções.

class FutureResult { valor do // Fragmentos


objeto protegido = nulo; booleano protegido
pronto = false; exceção InvocationTargetException
protegida = nulo;

objeto público sincronizado get() lança


InterruptedException, InvocationTargetException {

while (!pronto) espere();

if (exception != null) lançar


exceção; senão retorna
o
valor;
}
Machine Translated by Google

public Runnable setter(final Callable function) { return new Runnable()


{ public void run() { try
{ set(function.call());

} catch(Throwable e)
{ setException(e);
}
}
};
}

void sincronizado set(objeto resultado) { valor = resultado;


pronto = verdadeiro;
notificarTodos();

sincronizado void setException(Throwable e) {


exceção = new InvocationTargetException(e); pronto = verdadeiro;
notificarTodos();

// ... outros métodos auxiliares e de conveniência ...

A classe FutureResult pode ser usada diretamente para oferecer suporte a Futures genéricos ou como
um utilitário na construção de versões mais especializadas. Como exemplo de uso direto:

class PictureDisplayWithFutureResult { esboço // Código

private final Renderer renderer = new StandardRenderer(); // ...

public void show (URL final imageSource) {

tente
{ FutureResult futurePic = new FutureResult(); Comando executável
= futurePic.setter(new Callable() {
public Object call() { return
renderer.render(imageSource); } }); novo

Thread(comando).start();
Machine Translated by Google

exibirBorders();
displayCaption();

displayImage(((Pic)(futurePic.get())).getImage()); }

catch (InterruptedException ex) { cleanup();


retornar;

} catch (InvocationTargetException ex) {


limpar();
retornar;

}}

Este exemplo demonstra algumas das pequenas dificuldades introduzidas pela dependência de utilitários
genéricos que ajudam a padronizar os protocolos de uso. Esse é um dos motivos pelos quais você pode
querer usar FutureResult sucessivamente para construir uma versão mais especializada e fácil de usar com os
mesmos métodos e estrutura da classe AsynchRenderer .

4.3.4 Agendamento de Serviços

Conforme discutido em § 4.1.4, os designs de thread de trabalho às vezes podem melhorar o desempenho em
comparação com os designs de thread por tarefa. Eles também podem ser usados para agendar e otimizar a execução
de solicitações de serviço feitas por diferentes clientes.

Como um exemplo famoso, considere uma classe que controla o acesso de leitura e gravação para um disco contendo
muitos cilindros, mas apenas um cabeçote de leitura/gravação. A interface do serviço contém apenas métodos de leitura
e gravação . Na prática, ele certamente usaria indicadores de blocos de arquivos em vez de números brutos de
cilindros e lidaria ou lançaria várias exceções de E/S, aqui abreviadas como uma única exceção de falha .

disco de interface {
void read(int cilindroNumber, byte[] buffer) lança Falha; void write(int cilindroNumber, byte[]
buffer) lança
Falha; }

Em vez de atender às solicitações de acesso na ordem em que são feitas, é mais rápido, em média, varrer o cabeçote
pelos cilindros, acessando os cilindros em ordem crescente e, em seguida, redefinindo a posição do cabeçote de volta
ao início após cada varredura. (Dependendo em parte do tipo de disco, pode ser ainda melhor organizar as solicitações
em varreduras ascendentes e descendentes, mas vamos nos ater a esta versão.)

Essa política seria complicada de implementar sem algum tipo de estrutura de dados auxiliar. A condição de habilitação
para a execução de uma solicitação é:
Machine Translated by Google

Aguarde até que o número do cilindro de solicitação atual seja o menor número de cilindro maior em
relação ao do cabeçote do disco atual de todos os que estão esperando, ou seja o cilindro de menor
número se o número do cilindro do cabeçote for maior que o de todos os pedidos.

Essa condição é muito complicada, ineficiente e possivelmente até propensa a impasses para tentar
coordenar um conjunto de clientes independentes. Mas pode ser implementado facilmente com a ajuda de
uma fila ordenada empregada por um único thread de trabalho. As tarefas podem ser adicionadas à fila
em ordem de cilindro e, em seguida, executadas quando chegar a sua vez. Esse "algoritmo de elevador"
é mais fácil de organizar usando uma fila de duas partes, uma para a varredura atual e outra para a próxima varredura.

A estrutura resultante combina construções semelhantes a Future com os designs de thread de trabalho do § 4.1.4.
Para configurar isso, podemos definir uma classe Runnable para incluir a contabilidade extra associada
a DiskTasks. A classe queue usa a abordagem baseada em semáforos discutida em § 3.4.1, mas aqui
aplicada a listas encadeadas ordenadas. A classe do servidor constrói um thread de trabalho que
executa tarefas da fila. Os métodos de serviço público criam tarefas, colocam-nas na fila e, em seguida,
aguardam-nas antes de retornar aos clientes.

classe abstrata DiskTask implementa Runnable { cilindro int final


protegido; // parâmetros de leitura/gravação protegidos final byte[] buffer; exceção
de falha protegida = nulo; // para retransmitir //
para uso na fila protegida DiskTask next = null; latch final protegido concluído =
status new Latch(); // Indicador de

DiskTask(int c, byte[] b) { cilindro = c; tampão = b; }

abstract void access() lança Falha; // lê ou escreve


Machine Translated by Google

public void run() { try


{ access(); } catch (Falha
ex) { setException(ex); } finalmente { done.release(); } }

void awaitCompletion() lança InterruptedException { done.acquire();

Falha sincronizada getException() { return exceção; } sincronizado void


setException(Falha f) { exceção = f; } } class DiskReadTask extends DiskTask {

DiskReadTask(int c, byte[] b) { super(c, b); } void access() throws


Failure { /* ... raw read ... */ } }

classe DiskWriteTask estende DiskTask {


DiskWriteTask(int c, byte[] b) { super(c, b); } void access() throws
Failure { /* ... raw write ... */ } } class ScheduledDisk implements Disk { protected

final DiskTaskQueue tasks = new DiskTaskQueue();

public void read(int c, byte[] b) lança Falha { readOrWrite(new


DiskReadTask(c, b)); }

public void write(int c, byte[] b) throws Failure { readOrWrite(new


DiskWriteTask(c, b)); }

protected void readOrWrite(DiskTask t) lança Falha { tasks.put(t); tente


{ t.awaitCompletion();

} catch (InterruptedException ex)


{ Thread.currentThread().interrupt(); // propaga lançar new Failure(); //
converte para exceção de falha }

Falha f = t.getException(); if (f != nulo)


jogue f; }

public ScheduledDisk() { // constrói thread de trabalho


new Thread(new Runnable() { public
void run() {
Machine Translated by Google

tente
(;;) {
tarefas.take().run(); }

} catch (InterruptedException ou seja) {} // morrer }

}).começar();
}

} class DiskTaskQueue { Protected


DiskTask thisSweep = null; DiskTask protegido nextSweep
= nulo; protegido int atualCilindro = 0;

Semaphore final protegido disponível = new Semaphore(0);

void put(DiskTask t) { insert(t);

disponível.release(); }

DiskTask take() lança InterruptedException { available.acquire(); return


extrato(); }

sincronizado void insert(DiskTask t) { DiskTask q; if


(t.cylinder >=
currentCylinder) { q = thisSweep; if (q == null) { thisSweep // determina a fila
= t; retornar; }

} else { q
= nextSweep; if (q ==
null) { nextSweep = t; retornar; }
}
Trilha DiskTask = q; q = // inserção de lista encadeada ordenada
trilha.próximo; para (;;)
{
if (q == nulo || t.cilindro < q.cilindro) { trilha.próximo = t; t.next = q;
retornar;

} else
{ trilha = q; q = q.próximo;
}
}
}
Machine Translated by Google

sincronizado DiskTask extract() { // PRE: não vazio if (thisSweep == null)


{ thisSweep = nextSweep; // possivelmente troca filas
nextSweep = null;

}
DiskTask t = thisSweep;
thisSweep = t.next;
currentCilindro = t.cilindro; retornar t;

}
}

4.3.5 Leituras Adicionais


ABCL foi uma das primeiras linguagens concorrentes orientadas a objetos a oferecer Futures como uma
construção de linguagem. Ver:

Yonezawa, Akinori e Mario Tokoro. Programação Concorrente Orientada a Objetos, MIT Press, 1988.

Futuros são conhecidos como construções de espera por necessidade em Eiffel// (uma extensão paralela a Eiffel). Ver:

CAROMEL, Denis e Yves Roudier. "Reactive Programming in Eiffel//", em Jean-Pierre Briot, Jean Marc Geib e Akinori
Yonezawa (eds.) Object Based Parallel and Distributed Computing, LNCS 1107, Springer Verlag, 1996.

Futuros e construções relacionadas nas linguagens de programação Scheme e Multilisp são descritos em:

Dybvig, R. Kent e Robert Hieb. "Engines from Continuations", Computer Languages, 14(2):109-123, 1989.

Feeley, Marc. Uma implementação eficiente e geral de futuros em multiprocessadores de memória compartilhada em
grande escala, tese de doutorado, Brandeis University, 1993.

Técnicas adicionais associadas a retornos de chamada de conclusão em aplicativos de rede são descritas em:

Pyarali, Irfan, Tim Harrison e Douglas C. Schmidt. "Token de conclusão assíncrona", em Robert Martin, Dirk Riehle e
Frank Buschmann (eds.), Pattern Languages of Program Design, Volume 3, Addison-Wesley, 1999.

O padrão Null Object geralmente é útil para simplificar projetos de retorno de chamada nos quais os clientes nem sempre
exigem mensagens de retorno de chamada. Ver:

Woolf, Bobby. "Null Object", em Robert Martin, Dirk Riehle e Frank Buschmann (eds.), Pattern Languages of Program
Design, Volume 3, Addison-Wesley, 1999.

4.4 Decomposição Paralela


Os programas paralelos são projetados especificamente para tirar proveito de várias CPUs para resolver
problemas de computação intensiva. Os principais objetivos de desempenho são normalmente throughput e escalabilidade
Machine Translated by Google

o número de cálculos que podem ser realizados por unidade de tempo e o potencial de melhoria
quando recursos computacionais adicionais estão disponíveis. No entanto, estes são muitas vezes interligados
com outros objetivos de desempenho. Por exemplo, o paralelismo também pode melhorar as latências de resposta
para um serviço que transfere o trabalho para um recurso de execução paralela.

Entre os principais desafios do paralelismo na linguagem de programação Java está a construção de programas
portáteis que possam explorar várias CPUs quando presentes, enquanto ao mesmo tempo funcionam bem em
processadores únicos, bem como em multiprocessadores de tempo compartilhado que geralmente processam
não relacionados programas.

Algumas abordagens clássicas de paralelismo não combinam bem com esses objetivos. Abordagens que
assumem arquiteturas, topologias, recursos de processador ou outras restrições ambientais fixas específicas não
são adequadas para implementações de JVM comumente disponíveis. Embora não seja crime construir
sistemas de tempo de execução com extensões especificamente voltadas para determinados computadores
paralelos e escrever programas paralelos especificamente voltados para eles, as técnicas de programação associadas
necessariamente estão fora do escopo deste livro. Além disso, RMI e outras estruturas distribuídas podem ser
usadas para obter paralelismo entre máquinas remotas. Na verdade, a maioria dos projetos discutidos aqui pode ser
adaptada para usar serialização e invocação remota para obter paralelismo em redes locais. Isso está se tornando
um meio comum e eficiente de processamento paralelo de granulação grossa. No entanto, essas mecânicas
também estão fora do escopo deste livro.

Em vez disso, focamos em três famílias de projetos baseados em tarefas, paralelismo bifurcação/junção, árvores de
computação e barreiras. Essas técnicas podem gerar programas muito eficientes que exploram várias CPUs quando
presentes, mas ainda mantêm a portabilidade e a eficiência sequencial. Empiricamente, eles são conhecidos por
escalar bem, pelo menos até dezenas de CPUs. Além disso, mesmo quando esses tipos de programas paralelos
baseados em tarefas são ajustados para explorar ao máximo uma determinada plataforma de hardware, eles
requerem apenas pequenos reajustes para explorar ao máximo outras plataformas.

No momento em que este livro foi escrito, provavelmente os alvos mais comuns para essas técnicas são servidores
de aplicativos e servidores de computação que geralmente são, mas nem sempre, multiprocessadores. Em ambos
os casos, assumimos que os ciclos de CPU geralmente estão disponíveis, então o objetivo principal é explorá-los
para acelerar a solução de problemas computacionais. Em outras palavras, é improvável que essas técnicas
sejam muito úteis quando os programas são executados em computadores que já estão quase saturados.

4.4.1 Bifurcação/União

A decomposição de bifurcação/junção depende de versões paralelas de técnicas de divisão e conquista


familiares no projeto de algoritmos sequenciais. As soluções assumem a forma:

pseudoclass Solver { // ... // Pseudo-código

Resultado resolvido (problema de parâmetro) {


if (problem.size <= BASE_CASE_SIZE) return
directSolve(problem); else { Resultado l, r; EM

PARALELO {

l = resolve(metadeesquerda(problema)); r =
resolve(meiadireita(problema)); } return

combine(l, r);
Machine Translated by Google

}
}
}

É preciso muito trabalho e inspiração para inventar um algoritmo de divisão e conquista. Mas muitos problemas
computacionalmente intensivos comuns têm soluções conhecidas aproximadamente dessa forma. Claro, pode
haver mais de duas chamadas recursivas, casos de base múltiplos e pré e pós-processamento arbitrários em torno de
qualquer um dos casos.

Exemplos sequenciais familiares incluem quicksort, mergesort e muitos algoritmos de processamento de


imagem, matriz e estrutura de dados. Projetos recursivos sequenciais de divisão e conquista são fáceis de paralelizar
quando as tarefas recursivas são completamente independentes; isto é, quando eles operam em diferentes partes de
um conjunto de dados (por exemplo, diferentes seções de uma matriz) ou resolvem diferentes subproblemas e
não precisam se comunicar ou coordenar ações. Isso geralmente ocorre em algoritmos recursivos, mesmo naqueles
que não foram originalmente planejados para implementação paralela.

Além disso, existem versões recursivas de algoritmos (por exemplo, multiplicação de matrizes) que não são muito
usadas em contextos sequenciais, mas são mais amplamente usadas em multiprocessadores devido à sua forma
facilmente paralelizável. E outros algoritmos paralelos executam extensas transformações e pré-processamento
para converter problemas em uma forma que pode ser resolvida usando técnicas de bifurcação/junção. (Ver
Leituras Adicionais em § 4.4.4.)

O pseudocódigo IN-PARALLEL é implementado pela bifurcação e posterior junção de tarefas que executam as
chamadas recursivas. No entanto, antes de discutir como fazer isso, primeiro examinamos questões e estruturas
que permitem a execução paralela eficiente de tarefas geradas recursivamente.

4.4.1.1 Granularidade e estrutura da tarefa

Muitas das forças de projeto encontradas ao implementar projetos de bifurcação/junção envolvem a


granularidade da tarefa:

Maximizando o paralelismo. Em geral, quanto menores as tarefas, mais oportunidades de paralelismo. Todas as
outras coisas sendo iguais, usar muitas tarefas refinadas em vez de apenas algumas tarefas refinadas mantém
mais CPUs ocupadas, melhora o balanceamento de carga, localidade e escalabilidade, diminui a porcentagem de
tempo que as CPUs devem esperar umas pelas outras e leva para maior rendimento.

Minimizando despesas gerais. Construir e gerenciar um objeto para processar uma tarefa em paralelo, em vez de
apenas invocar um método para processá-lo em série, é a principal sobrecarga inevitável associada à programação
baseada em tarefas em comparação com soluções sequenciais. É intrinsecamente mais custoso criar e usar
objetos de tarefa do que criar e usar quadros de pilha. Além disso, o uso de objetos de tarefa pode aumentar a
quantidade de argumentos e dados de resultado que devem ser transmitidos e podem afetar a coleta de lixo.
Todas as outras coisas sendo iguais, a sobrecarga total é minimizada quando há apenas algumas tarefas de baixa
granularidade.

Minimizando a contenção. Uma decomposição paralela não levará a muita aceleração se cada tarefa se comunicar
frequentemente com outras ou precisar bloquear a espera por recursos mantidos por outras pessoas. As tarefas
devem ter tamanho e estrutura que mantenham o máximo de independência possível. Eles devem minimizar (na
maioria dos casos, eliminar) o uso de recursos compartilhados, variáveis globais (estáticas), bloqueios e outras dependências.
Idealmente, cada tarefa conteria um código simples de linha reta que é executado até a conclusão e, em seguida, finalizado.
No entanto, os projetos de bifurcação/junção requerem pelo menos alguma sincronização mínima. O objeto
principal que inicia o processamento normalmente espera que todas as subtarefas terminem antes de prosseguir.
Machine Translated by Google

Maximizando a localidade. Cada subtarefa deve ser a única operando em uma pequena parte de um problema,
não apenas conceitualmente, mas também no nível de recursos de nível inferior e padrões de acesso à memória.
As refatorações que atingem uma boa localidade de referência podem melhorar significativamente o desempenho em
processadores modernos com cache pesado. Ao lidar com grandes conjuntos de dados, não é incomum
particionar computações em subtarefas com boa localidade, mesmo quando o paralelismo não é o objetivo principal.
A decomposição recursiva costuma ser uma maneira produtiva de conseguir isso. O paralelismo acentua os efeitos da
localidade. Quando todas as tarefas paralelas acessam diferentes partes de um conjunto de dados (por exemplo, diferentes
regiões de uma matriz comum), as estratégias de particionamento que reduzem a necessidade de transmitir atualizações
entre os caches geralmente alcançam um desempenho muito melhor.

4.4.1.2 Estruturas

Não há uma solução ideal geral para granularidade e problemas relacionados à estruturação de tarefas. Qualquer
escolha representa um compromisso que melhor resolve as forças concorrentes para o problema em questão. No
entanto, é possível construir estruturas de execução leves que suportem uma ampla gama de opções ao longo do
continuum.

Objetos de encadeamento são veículos pesados desnecessariamente para suportar tarefas de bifurcação/junção
puramente computacionais. Por exemplo, essas tarefas nunca precisam bloquear no IO e nunca precisam dormir. Eles
exigem apenas uma operação para sincronizar entre as subtarefas. As técnicas de thread de trabalho discutidas em §
4.1.4 podem ser estendidas para construir estruturas que suportam eficientemente apenas as construções necessárias.
Embora existam várias abordagens, para concretude, limitaremos a discussão a uma estrutura
em util.concurrent que restringe todas as tarefas a serem subclasses da classe FJTask. Aqui está um breve esboço

dos principais métodos. Mais detalhes são discutidos juntamente com exemplos em § 4.4.1.4 a § 4.4.1.7.

classe abstrata FJTask implementa Runnable {


boolean isDone(); void // Verdadeiro depois que a tarefa é executada
cancel(); void garfo(); // Definido prematuramente como concluído
void iniciar(); // Inicia uma tarefa dependente
estático void yield(); // Inicia uma tarefa arbitrária
// Permite que outra tarefa
correr

void join(); void // Yield chamador até terminar


estático invocar(FJTask t); static void // Executa diretamente t
coInvoke(FJTask t,
FJTask u); // Bifurca e junta t e u
static void tarefas coInvoke(FJTask[]); // coInvocar todos void reset();
// Limpar para permitir a reutilização
}

Uma classe FJTaskRunnerGroup associada fornece controle e pontos de entrada nessa estrutura.
Um FJTaskRunnerGroup é construído com um determinado número de threads de trabalho que normalmente deve
ser igual ao número de CPUs em um sistema. A classe suporta a invocação de método que inicia uma tarefa principal,
que por sua vez normalmente criará muitas outras.

As FJTasks devem empregar apenas esses métodos de controle de tarefa, não Thread arbitrário ou métodos de monitor.
Embora os nomes dessas operações sejam iguais ou semelhantes aos da classe Thread, suas implementações
são muito diferentes. Em particular, não há instalações gerais de suspensão. Por exemplo, a operação de junção
é implementada simplesmente fazendo com que o thread de trabalho subjacente seja executado
Machine Translated by Google

outras tarefas até a conclusão da tarefa de destino (via isDone). Isso não funcionaria com threads comuns,
mas é eficaz e eficiente quando todas as tarefas são estruturadas como métodos fork/join.

Esses tipos de compensações tornam a construção e a invocação de FJTask substancialmente mais baratas
do que seria possível para qualquer classe que suporte a interface Thread completa . No momento em que este
livro foi escrito, pelo menos em algumas plataformas, a sobrecarga de criar, executar e gerenciar um FJTask
para os tipos de exemplos ilustrados abaixo é apenas entre quatro e dez vezes maior que a execução de
chamadas de método sequencial equivalentes.

O principal efeito é diminuir o impacto dos fatores de sobrecarga ao fazer escolhas sobre
particionamento e granularidade de tarefas. O limite de granularidade para o uso de tarefas pode ser bastante
pequeno, da ordem de alguns milhares de instruções, mesmo nos casos mais conservadores, sem
degradação perceptível do desempenho em uniprocessadores. Os programas podem explorar quantas CPUs
estiverem disponíveis, mesmo nas maiores plataformas, sem a necessidade de ferramentas especiais para extrair ou gerenciar o paralelism
No entanto, o sucesso também depende da construção de classes de tarefas e métodos que minimizam a
sobrecarga, evitam contenção e preservam a localidade.

4.4.1.3 Definindo tarefas

Os algoritmos sequenciais de divisão e conquista podem ser expressos como classes baseadas em bifurcação/junção
por meio das seguintes etapas:

1. Crie uma classe de tarefa com:


o Campos para armazenar argumentos e resultados. A maioria deve ser estritamente local para
uma tarefa, nunca acessada de qualquer outra tarefa. Isso elimina a necessidade
de sincronização em torno de seu uso. No entanto, no caso típico em que as variáveis
de resultado são acessadas por outras tarefas, elas devem ser declaradas como
voláteis ou acessadas apenas por meio de métodos
sincronizados . o Um construtor que inicializa variáveis de
argumento. o Um método de execução que executa o código do método retrabalhado.
2. Substitua o caso recursivo original pelo código que:
o Cria objetos de subtarefa. o
Forks cada um para executar em paralelo.
o Junta-se a cada um
deles. o Combina resultados acessando variáveis de resultado nos objetos de subtarefa.
3. Substitua (ou estenda) a verificação do caso base original por uma verificação de limite. Tamanhos de
problema menores que o limite devem usar o código sequencial original. Essa generalização de
verificações de caso base mantém a eficiência quando os tamanhos dos problemas são tão pequenos
que a sobrecarga da tarefa ofusca os ganhos potenciais da execução paralela. Ajuste o desempenho
determinando um bom tamanho de limite para o problema em questão.
4. Substitua o método original por um que crie a tarefa associada, aguarde e retorne quaisquer resultados.
(Na estrutura FJTask , a chamada externa é realizada por meio de
FJTaskRunnerGroup.invoke.)

4.4.1.4 Fibonacci
Machine Translated by Google

Ilustraremos as etapas básicas com um exemplo clássico muito chato e irrealista, mas muito simples:
calcular fib recursivamente, a função de Fibonacci. Esta função pode ser programada sequencialmente como:

int seqFib(int n) { if (n <= 1)


return n; senão
retorna

seqFib(n-1) + seqFib(n-2);
}

Este exemplo não é realista porque há uma solução não recursiva muito mais rápida para este problema
específico, mas é uma das favoritas para demonstrar recursão e paralelismo. Por fazer tão pouca
computação, torna a estrutura básica dos projetos de bifurcação/junção mais fácil de ver, mas gera muitas
chamadas pelo menos fib(n) chamadas para calcular fib(n). Os primeiros valores da sequência são 0, 1,
recursivas 1, 2, 3, 5, 8; fib(10) é 55; fib(20) é 6.765; fib(30) é 832.040; fib(40) é 102.334.155.

A função seqFib pode ser transformada em uma classe de tarefa como a seguinte:

classe Fib estende FJTask {


static final int sequencialThreshold = 13; // para ajustar o número int volátil;
// argumento/resultado

Fib(int n) { número = n; }

int getAnswer() { if (!
isDone())
throw new IllegalStateException("Ainda não calculado");
número de retorno;
}

public void run() { int n =


número;
Machine Translated by Google

if (n <= limiar sequencial) número = seqFib(n); // caso base


outro {

Fib f1 = new Fib(n - 1); // cria subtarefas


Fib f2 = novo Fib(n - 2);

coInvoke(f1, f2); // bifurca e junta os dois

número = f1.número + f2.número; // combina resultados


}
}

public static void main(String[] args) { // exemplo de driver


tente
{ int groupSize = 2; // calcula // 2 threads de trabalho
fib(35) int num = 35;
Grupo FJTaskRunnerGroup = novo
FJTaskRunnerGroup(grupoTamanho); Fib f
= new Fib(num); group.invoke(f);
int resultado =
f.getAnswer(); System.out.println("Resposta:
" + resultado);

} catch (InterruptedException ex) {} // morrer


}
}

Notas:

A classe mantém um campo contendo o argumento para o qual calcular a função Fibonacci.
Além disso, precisamos de uma variável para armazenar o resultado. No entanto, como é bastante
comum nessas classes, não há necessidade de manter duas variáveis. Para economia (tendo em
mente que muitos milhões de objetos Fib podem ser construídos no decorrer de uma computação),
podemos micro otimizar para usar uma variável e sobrescrever o argumento com seu resultado depois de calculado.
(Esta é a primeira de várias otimizações manuais que são desconfortavelmente mesquinhas, mas
são mostradas aqui para demonstrar pequenos ajustes que podem ser pragmaticamente importantes
na construção de programas paralelos eficientes.)
O campo de número é declarado como volátil para garantir a visibilidade de outras tarefas/threads
depois de computado (consulte § 2.2.7). Aqui e nos exemplos subsequentes, os campos voláteis são
lidos e/ou gravados apenas uma vez por execução da tarefa e, caso contrário, mantidos em variáveis
locais. Isso evita a interferência com possíveis otimizações do compilador que, de outra forma,
seriam desativadas ao usar voláteis.
Como alternativa, poderíamos ter acesso sincronizado ao campo numérico . Mas não há nenhuma boa
razão para fazê-lo. O uso de campos voláteis é muito mais comum em estruturas leves de tarefas
paralelas do que em programação concorrente de propósito geral. As tarefas geralmente não
requerem outra mecânica de sincronização e controle, mas geralmente precisam comunicar os
resultados por meio de acesso de campo. O motivo mais comum para usar sincronizado
em vez de volátil é lidar com arrays. Os elementos individuais da matriz não podem ser
declarados como voláteis. O processamento de arrays dentro de métodos ou blocos sincronizados é a
maneira mais simples de garantir a visibilidade das atualizações do array, mesmo no caso típico em que o bloqueio não é
Machine Translated by Google

exigido de outra forma. Uma alternativa ocasionalmente atraente é, em vez disso, criar arrays cujos elementos
sejam um objeto de encaminhamento com campos voláteis .
O método isDone retorna true após a conclusão de um método run que foi executado por meio de invocar
ou coInvoke. Ele é usado como um guarda em getAnswer para ajudar a detectar erros de programação que
podem ocorrer se o consumidor final de uma resposta tentar acessá-la prematuramente. (Não há chance de
isso acontecer aqui, mas essa proteção ajuda a evitar usos não intencionais.)

A constante sequencialThreshold estabelece granularidade. Representa o ponto de equilíbrio em que não vale a
pena o overhead para criar tarefas, refletindo também o objetivo de manter um bom desempenho
sequencial. Por exemplo, em um conjunto de execuções em um sistema de quatro CPUs, definir
sequencialThreshold como 13 resultou em uma degradação de desempenho de 4% em relação a
seqFib para grandes valores de argumento ao usar um único encadeamento de trabalho.
Mas acelerou por um fator de pelo menos 3,8 com quatro threads de trabalho, processando vários milhões de
tarefas Fib por segundo.
Em vez de conectar uma constante de tempo de compilação, poderíamos ter definido o limite como uma
variável de tempo de execução e defini-lo como um valor baseado no número de CPUs disponíveis ou em
outras características da plataforma. Isso é útil em programas baseados em tarefas que não escalam
linearmente, como provavelmente é verdade mesmo aqui. À medida que o número de CPUs aumenta,
também aumentam os custos de comunicação e gerenciamento de recursos, que podem ser compensados pelo aumento do limite.
O análogo paralelo da recursão é executado por meio de um método conveniente,
coInvoke(FJTask t, FJTask u), que por sua vez atua como:
t.fork(); invocar(u); t.join();
O método fork é um análogo especializado de Thread.start. Uma tarefa bifurcada é sempre processada na
ordem LIFO baseada em pilha quando é executada pelo mesmo thread de trabalho subjacente que a gerou, mas
na ordem FIFO baseada em fila em relação a outras tarefas, se executada por outro thread de trabalho
em execução em paralelo. Isso representa uma espécie de cruzamento entre chamadas sequenciais baseadas
em pilha normais e agendamento de encadeamento normal baseado em fila. Essa política (implementada
por meio de filas de agendamento duplas) é ideal para paralelismo recursivo baseado em tarefas (consulte
Leituras adicionais) e, de maneira mais geral, sempre que lidar com tarefas estritamente dependentes, aquelas
que são geradas pelas tarefas que finalmente se juntam a elas ou por suas subtarefas .
Em contraste, FJTask.start se comporta mais como Thread.start. Ele emprega agendamento FIFO baseado
em fila em relação a todos os threads de trabalho. É utilizado, por exemplo, pelo
FJTaskRunnerGroup.invoke para iniciar a execução de uma nova tarefa principal.
O método join deve ser usado apenas para tarefas iniciadas via fork. Ele explora os padrões de dependência
de terminação de subtarefas fork/join para otimizar a execução.
O método FJTask.invoke executa o corpo de uma tarefa dentro de outra tarefa e aguarda a conclusão. Visto de
forma diferente, é a versão de uma tarefa de coInvoke, uma otimização de u.fork(); u.join().

O uso eficaz de qualquer estrutura executável leve requer o mesmo entendimento dos métodos de suporte e sua
semântica, assim como a programação com Threads comuns. A estrutura FJTask explora a simbiose entre
recursão e decomposição paralela e, portanto, incentiva o estilo de programação dividir e conquistar visto em Fib. No
entanto, a variedade de idiomas de programação e padrões de design em conformidade com esse estilo geral é bastante
ampla, conforme ilustrado pelos exemplos a seguir.

4.4.1.5 Vinculando subtarefas

As técnicas de bifurcação/junção podem ser aplicadas mesmo quando o número de subtarefas bifurcadas varia dinamicamente.
Entre várias táticas relacionadas para realizar isso, você pode adicionar campos de link para que as subtarefas possam ser
Machine Translated by Google

mantidos em listas. Depois de gerar todas as tarefas, uma operação de acumulação (também conhecida como redução) pode percorrer
a lista sequencialmente, juntando e usando os resultados de cada subtarefa.

Ampliando um pouco o exemplo Fib, a classe FibVL ilustra uma maneira de configurar isso. Esse estilo de solução não é

especialmente útil aqui, mas é aplicável em contextos nos quais um número dinâmico de subtarefas é criado, possivelmente em
diferentes métodos. Observe que as subtarefas aqui são unidas na ordem oposta em que foram criadas. Como a ordem de processamento
dos resultados não importa aqui, usamos o algoritmo de ligação mais simples possível (anterior), que reverte a ordem das tarefas durante
a travessia. Essa estratégia se aplica sempre que o passo de acumulação for comutativo e associativo em relação aos resultados, de
forma que as tarefas possam ser processadas em qualquer ordem. Se a ordem importasse, precisaríamos ajustar a construção da
lista ou a travessia de acordo.

classe FibVL estende FJTask {


número int volátil; // como antes final FibVL next;
tarefas // lista encadeada incorporada de irmãos

FibVL(int n, lista FibVL) { número = n; próximo = lista; }

public void run() { int n =


número; if(n <= limiar
sequencial) número = seqFib(n); outro {

FibVL bifurcado = nulo; // lista de subtarefas

bifurcado = novo FibVL(n - 1, bifurcado); // anexa à lista forked.fork();

bifurcado = novo FibVL(n - 2, bifurcado); forked.fork();

número = acumular(bifurcada); }

// Percorrer a lista, juntando cada subtarefa e adicionando ao resultado int acumula(FibVL


list) { int sum = 0; for (FibVL f = lista; f !=
null; f = f.next)
{ f.join(); soma += f.número;

} soma de retorno;
}
}

4.4.1.6 Chamadas de retorno


Machine Translated by Google

O paralelismo bifurcação/junção baseado em tarefa recursiva pode ser estendido para ser aplicado quando outras
condições de sincronização local são usadas em vez de junção. Na estrutura FJTask , t.join() é implementado como
uma versão otimizada de:

while (!t.isDone()) yield();

O método yield aqui permite que o thread de trabalho subjacente processe outras tarefas. (Mais especificamente, na
estrutura FJTask , o encadeamento processará pelo menos uma outra tarefa, se houver.)

Qualquer outra condição pode ser usada nesta construção em vez de isDone, desde que você tenha certeza de que o
predicado esperado acabará por se tornar verdadeiro devido às ações de uma subtarefa (ou de uma de suas subtarefas
e assim por diante). Por exemplo, em vez de depender de junção, o controle de tarefas pode contar com contadores
que rastreiam a criação e a conclusão da tarefa. Um contador pode ser incrementado em cada bifurcação e
decrementado quando a tarefa bifurcada produzir um resultado. Este e esquemas relacionados baseados em contador
podem ser opções atraentes quando as subtarefas comunicam os resultados por meio de retornos de chamada em
vez de acesso aos campos de resultado. Os contadores deste formulário são versões localizadas em pequena
escala das barreiras discutidas no § 4.4.3.

Projetos de fork/join baseados em callback são vistos, por exemplo, em algoritmos de solução de problemas,
jogos, buscas e programação lógica. Em muitos desses aplicativos, o número de subtarefas bifurcadas pode variar
dinamicamente e os resultados das subtarefas são melhor capturados por chamadas de método do que por extração de campo.

Abordagens baseadas em callback também permitem maior assincronia do que técnicas como as tarefas vinculadas
em § 4.4.1.5. Isso pode levar a um melhor desempenho quando as subtarefas diferem na duração esperada, pois
o processamento do resultado da conclusão rápida de subtarefas às vezes pode se sobrepor ao processamento
contínuo de tarefas mais longas. No entanto, esse design abre mão de todas as garantias de ordenação de resultados
e, portanto, é aplicável somente quando o processamento de resultados de subtarefas é completamente independente da ordem em que os result

Os contadores de retorno de chamada são usados na seguinte classe FibVCB, que não é adequada para o
problema em questão, mas serve para exemplificar técnicas. Este código ilustra uma combinação típica, mas
delicada, de variáveis locais de tarefa, voláteis e bloqueio em um esforço para manter a sobrecarga de controle
de tarefa no mínimo:

class FibVCB extends FJTask { // ... volátil


int
number = 0; pai FibVCB final; // como antes // é
retornos de chamada nulo para chamada externa
esperados int = 0; volátil int
callbacksReceived = 0;

FibVCB(int n, FibVCB p) { número = n; pai = p; }

// Método de retorno de chamada invocado por subtarefas após a conclusão


sincronizada void addToResult(int n) {
número += n; +
+chamadas de retorno
recebidas; }

public void run() { // mesma estrutura da versão baseada em junção int n = número;
Machine Translated by Google

if (n <= limiar sequencial) número =


seqFib(n); outro {

// Limpa o número para que as subtarefas possam preencher


o número = 0;
// Estabelece o número de callbacks esperados
callbacksExpected = 2;

novo FibVCB(n - 1, this).fork(); novo FibVCB(n


- 2, this).fork();

// Aguarda callbacks dos filhos while


(callbacksReceived < callbacksExpected) yield(); }

// Chama de volta o pai if


(parent != null) parent.addToResult(number);
}
}

Notas:

Todo bloqueio de exclusão mútua é restrito a pequenos segmentos de código que protegem acessos de
campo, como deve ser verdade para qualquer classe em uma estrutura de tarefa leve. As tarefas não
podem ser bloqueadas, a menos que tenham certeza de que poderão continuar em breve. Em
particular, esta estrutura requer de forma inexequível que os blocos sincronizados não se estendam
por bifurcações e junções ou rendimentos subsequentes .
Para ajudar a eliminar alguma sincronização, a contagem de retorno de chamada é dividida em dois
contadores, callbacksExpected e callbacksReceived. A tarefa é concluída quando eles são iguais.

O contador callbacksExpected é usado apenas pela tarefa atual, portanto, o acesso não precisa ser
sincronizado e não precisa ser volátil. Na verdade, como sempre são esperados exatamente dois retornos
de chamada no caso recursivo e o valor nunca é necessário fora do método run, essa classe poderia
ser facilmente retrabalhada de forma a eliminar toda a necessidade dessa variável.
No entanto, essa variável é necessária em projetos baseados em retorno de chamada mais típicos, nos quais
o número de bifurcações pode variar dinamicamente e pode ser gerado em vários métodos.
O método de retorno de chamada addToResult deve ser sincronizado para evitar problemas de interferência
quando as subtarefas chamam de volta aproximadamente ao mesmo tempo.
Desde que number e callbacksReceived sejam declarados como voláteis e callbacksReceived seja atualizado
como a última instrução de addToResult, o teste de loop yield não precisa envolver sincronização porque está
esperando por um limite de travamento que, uma vez alcançado, nunca mudará (consulte o § 3.4.2.1).

Também poderíamos definir um método getAnswer retrabalhado que usa essa mecânica para retornar uma
resposta se todos os retornos de chamada forem recebidos. No entanto, como esse método foi projetado
para ser chamado por clientes externos (não-tarefa) após a conclusão do cálculo geral, não há motivo
convincente para fazer isso. A versão da classe Fib original é suficiente.
Apesar dessas medidas, a sobrecarga associada ao controle de tarefas nesta versão é maior que a da
versão original usando coInvoke. Se você fosse usá-lo de qualquer maneira, provavelmente escolheria um
limite sequencial um pouco maior e, assim, exploraria um paralelismo um pouco menos.
Machine Translated by Google

4.4.1.7 Cancelamento

Em alguns projetos, não há necessidade de manter contagens de retornos de chamada ou percorrer exaustivamente as
listas de subtarefas. Em vez disso, as tarefas são concluídas quando qualquer subtarefa (ou uma de suas subtarefas e
assim por diante) chega a um resultado adequado. Nesses casos, você pode evitar o desperdício de computação
cancelando quaisquer subtarefas no meio da produção de resultados que não serão necessários.

As opções aqui são semelhantes às observadas em outras situações de cancelamento (ver § 3.1.2). Por exemplo, as
subtarefas podem invocar regularmente um método (talvez isDone) em seus pais que indica que uma resposta já foi
encontrada e, se for o caso, retornar antecipadamente. Eles também devem definir seu próprio status, para que qualquer
uma de suas subtarefas possa fazer o mesmo. Isso pode ser implementado aqui usando FJTask.cancel que apenas
define prematuramente o status isDone . Isso suprime a execução de tarefas que ainda não foram iniciadas, mas
não tem efeito sobre as tarefas no meio da execução, a menos que os próprios métodos de execução das tarefas
detectem o status atualizado e lide com ele.

Quando um conjunto inteiro de tarefas está tentando calcular um único resultado, uma estratégia ainda mais simples é
suficiente: as tarefas podem verificar regularmente uma variável global (estática) que indica a conclusão. No entanto,
quando há muitas tarefas e muitas CPUs, estratégias mais localizadas ainda podem ser preferíveis a uma que coloque
tanta pressão no sistema subjacente, gerando muitos acessos ao mesmo local de memória, especialmente se ele deve
ser acessado em sincronização. Além disso, lembre-se de que a sobrecarga total associada ao cancelamento deve ser
menor do que o custo de apenas permitir que pequenas tarefas sejam executadas, mesmo que seus resultados não
sejam necessários.

Por exemplo, aqui está uma aula que resolve o clássico problema das N-Rainhas, buscando a colocação de N rainhas que
não se atacam em um tabuleiro de xadrez de tamanho NxN. Para simplificar a ilustração, ele depende de uma variável
Result estática . Aqui as tarefas verificam o cancelamento apenas na entrada no método. Eles continuarão
percorrendo possíveis extensões, mesmo que um resultado já tenha sido encontrado. No entanto, as tarefas geradas
serão encerradas imediatamente. Isso pode ser um pouco desnecessário, mas pode obter uma solução mais
rapidamente do que uma versão que verifica a conclusão a cada iteração de cada tarefa.

Observe também aqui que as tarefas não se incomodam em juntar suas subtarefas, pois não há razão para fazê-lo. Somente
o chamador externo final (no principal) precisa aguardar uma solução; isso é suportado aqui adicionando métodos
padrão de espera e notificação à classe Result . (Além disso, para compactação, esta versão não emprega nenhum tipo
de limite de granularidade. É fácil adicionar um, por exemplo, explorando movimentos diretamente em vez de
bifurcar subtarefas quando o número de linhas é próximo ao tamanho do tabuleiro.)

classe NQueens estende FJTask {


estático int boardSize; // corrigido após a inicialização no main
// Os tabuleiros são arrays onde cada célula representa uma linha, // e contém o
número da coluna da rainha naquela linha

Result { private int[] board = // detentor do resultado final static class


null; // não nulo quando resolvido

sincronizado booleano resolvido() { placa de retorno != nulo; }

sincronizado void set(int[] b) { // Suporta uso por não


Tarefas
if (placa == null) { placa = b; notificarTodos(); }

Você também pode gostar