Introdução ao sincronismo e monitores

em 04/08/2003 , por Leandro Rangel Santos
Introdução
O fato das Threads poderem dividir areas de memoria , significa que uma thread pode consultar/atualizar um objeto durante a consulta/atualização do mesmo objeto por outras threads. Poucas operações são atômicas em Java. Em geral, as atribuições simples, com exceção dos tipos long e double, são atômicas, de forma que o programador não precisa se preocupar em ser interrompido no meio de uma operação de atribuição. No entanto, no caso de operações mais complexas sobre variáveis compartilhadas é preciso que o programador garanta o acesso exclusivo a essas variáveis. Os trechos de código onde é feito o acesso às variáveis compartilhadas são chamados de Seções Críticas ou Regiões Críticas. Uma vez determinada uma região crítica como garantir o acesso exclusivo? A linguagem Java permite que o programador garanta o acesso exclusivo por meio utilizando o conceito de monitor.
Monitores
O conceito de monitor foi proposto por C. A. R. Hoare em 1974 e pode ser encarado como um objeto que garante a exclusão mútua na execução dos procedimentos a ele associados. Ou seja, apenas um procedimento associado ao monitor pode ser executado em um determinado momento. Por exemplo, suponha que dois procedimentos A e B estão associados a um monitor. Se no momento da invocação do procedimento A algum o procedimento B estiver sendo executando o processo ou thread que invocou o procedimento A fica suspenso até o término da execução do procedimento B. Ao término do procedimento B o processo que invocou o procedimento A é "acordado" e sua execução retomada. O uso de monitores em Java é uma variação do proposto por Hoare. Na linguagem Java todo objeto possui um monitor associado. Para facilitar o entendimento podemos encarar o monitor como um detentor de um "passe". Toda thread pode pedir "emprestado" o passe ao monitor de um objeto antes de realizar alguma computação. Como o monitor possui apenas um passe, apenas um thread pode adquirir o passe em um determinado instante. O passe tem que ser devolvido para o monitor para possibilitar o empréstimo do passe a outro thread.
A palavra chave synchronized
Nos resta saber como solicitar o passe ao monitor. Isto é feito por meio da palavra chave synchronized. Existem duas formas de se usar a palavra chave synchronized: na declaração de métodos e no início de blocos. O exemplo abaixo mostra duas versões da classe FilaCirc que implementa uma fila circular de valores inteiros: uma com métodos synchronized e outra com blocos synchronized. Um objeto desta classe pode ser compartilhado por duas ou mais threads para implementar o exemplo clássico de concorrência do tipo produtor/consumidor. Versão com métodos synchronized

Versão com blocos synchronized

A palavra chave synchronized na frente dos métodos de instância significa que o método será executado se puder adquirir o monitor do objeto a quem pertence o método. Caso contrário a thread que invocou o método será suspensa até que possa adquirir o monitor. Portanto, se alguma thread chamar algum método de um objeto da classe FilaCirc nenhuma outra thread que compartilha o mesmo objeto poderá executar um método do objeto até que o método chamado pela primeira thread termine. Caso outra thread invoque um método do mesmo objeto ficará bloqueada até que possa adquirir o monitor. Apesar da classe FilaCirc aparentar fazer apenas atribuições simples ,ha a necessidade de sincronizar seus métodos , não pelo fato de atribuir elementos individuais de um vetor ( pois essas atribuições são atômicas ) mas sim pelo da indexação do array , por exemplo a instrução :

do método getElement() não é atômica. Suponha que a os métodos da classe FilaCirc não são sincronizados e que as variáveis inicio e total possuem os valores 9 e 1 respectivamente. Suponha também que a thread invocou o método getElement() e foi interrompida na linha de código mostrada acima após o incremento da variável inicio mas antes da conclusão da linha de código.
Nesse caso o valor de inicio é 10. Se neste instante outra thread executar o método getElement() do mesmo objeto ocorrerá uma exceção IndexOutOfBoundsException ao atingir a linha de código

Se alterarmos a linha de código para

evitaremos a exceção, mas não evitaremos o problema de retornar mais de uma vez o mesmo elemento. Por exemplo, se uma thread for interrompida no mesmo local do caso anterior, outra thread pode obter o mesmo elemento, uma vez que os valores de inicio e total não foram alterados. Na verdade o número de situações problemáticas, mesmo para esse exemplo pequeno, é enorme e perderíamos muito tempo se tentássemos descreve-las em sua totalidade. Em alguns casos pode ser indesejável sincronizar todo um método, ou pode-se desejar adquirir o monitor de outro objeto, diferente daquele a quem pertence o método. Isto pode ser feito usando a palavra chave synchronized na frente de blocos. Esta forma de sincronização é mostrada no exemplo acima ( o caso b ). Neste modo de usar a palavra-chave synchronized é necessário indicar o objeto do qual tentara-se adquirir o monitor. Caso o monitor seja adquirido o bloco é executado, caso contrário o thread é suspenso até que possa adquirir o monitor. O monitor é liberado no final do bloco. No exemplo acima ( o caso b ) o monitor usado na sincronização é o do próprio objeto do método, indicado pela palavra chave this. Qualquer outro objeto referenciável no contexto poderia ser usado. O que importa que os grupos de threads que possuem áreas de código que necessitam de exclusão mútua usem o mesmo objeto. No exemplo acima não existe vantagem da forma de implementação a) sobre a forma de implementação b) ou vice-versa. Isso ocorre principalmente quando os métodos são muito pequenos ou não realizam computações muito complexas. No entanto, se o método for muito longo ou levar muito tempo para ser executado, sincronizar todo o método pode "travar" em demasia a execução da aplicação. Nesses casos, a sincronização somente das seções críticas é mais indicada. Outra vantagem da segunda forma de sincronização é a liberdade no uso de monitores qualquer objeto referenciável. É importante observar que o monitor em Java por si só não implementa a exclusão mútua. Ele é apenas um recurso que pode ser usado pelo programador para implementar o acesso exclusivo à variáveis compartilhadas. Cabe ao programador a responsabilidade pela uso adequado deste recurso. Por exemplo se o programador esquecer de sincronizar um bloco ou método que necessita de exclusão mútua, de nada adianta ter sincronizado os outros métodos ou blocos. A thread que executar o trecho não sincronizado não tentará adquirir o monitor, e portanto de nada adianta os outros threads terem o adquirido. Outro ponto que é importante chamar a atenção é ter o cuidado de usar a palavra chave synchronized , a sincronização custa muito caro em se tratando de ciclos de CPU. A chamada de um método sincronizado é por volta de 10 vezes mais lenta do que a chamada de um método não sincronizado. Por essa razão use sempre a seguinte regra: não sincronize o que não for preciso.
O exemplo acima não é um modelo de uma boa implementação de programa. A thread que adiciona elementos à fila tenta adicionar um elemento à cada volta do laço de iteração mesmo que a fila esteja cheia. Por outro lado, o thread que retira os elementos da fila tenta obter um elemento a cada volta do laço de iteração mesmo que a fila esteja vazia. Isto é um desperdício de tempo de processador e pode tornar o programa bastante ineficiente. Alguém poderia pensar em uma solução onde a thread testaria se a condição desejada para o processamento ocorre , caso a condição não ocorra a thread poderia executar o método sleep() para ficar suspensa por algum tempo para depois testar novamente a condição. A thread procederia desta forma até que a condição fosse satisfeita. Este tipo de procedimento economizaria alguns ciclos de CPU, evitando que a tentativa incessante de executar o procedimento mesmo quando não há condições. O nome desta forma de ação, onde o procedimento a cada intervalo de tempo pré-determinado testa se uma condição é satisfeita é chamado de espera ocupada (pooling ou busy wait). No entanto, existem alguns problemas com este tipo de abordagem. Primeiramente, apesar da economia de ciclos de CPU ainda existe a possibilidade de ineficiência, principalmente se o tempo não for bem ajustado. Se o tempo for muito curto ocorrerá vários testes inúteis. Se for muito longo, a thread ficará suspensa além do tempo necessário. Porém, mais grave que isto é que o método sleep() faz com que a thread libere o monitor. Portanto, se o trecho de código for uma região sincronizada, como é o caso do exemplo acima, de nada adiantará a thread ser suspensa. A thread que é capaz de realizar a computação que satisfaz a condição esperada pela primeira thread ficará impedida de entrar na região sincronizada, ocorrendo assim um deadlock: a thread que detém o monitor espera que a condição seja satisfeita e a thread que pode satisfazer a condição não pode prossegui porque não pode adquirir o monitor. O que precisamos é um tipo de comunicação entre threads que comunique que certas condições foram satisfeitas. Além disso, é preciso que, ao esperar por determinada condição, a thread libere o monitor. Esta forma de interação entre threads é obtido em Java com o uso dos métodos de instância wait(), notify() e notifyAll(). Como vimos anteriormente, esses métodos pertencem à classe Object e não à classe Thread. Isto ocorre porque esses métodos atuam sobre os monitores, que são objetos relacionados a cada instância de uma classe Java e não sobre as threads. Ao invocar o método wait() de um objeto a thread é suspensa e inserida em uma fila do monitor do objeto, permanecendo na fila até receber uma notificação. Cada monitor possui sua própria fila. Ao invocar o método notify() de um objeto, uma thread que está na fila do monitor do objeto é notificada. Ao invocar o método notifyAll() de um objeto, todas as threads que estão na fila do monitor do objeto são notificadas. A única exigência é que esses métodos sejam invocados em um thread que detenham a posse do monitor do objeto a que pertencem. Essa exigência faz sentido uma vez que eles sinalizam a threads que esperam na fila desses monitores. Devido a essa exigência a invocação desses métodos ocorre em métodos ou blocos sincronizados. O exemplo abaixo mostra as formas mais comuns de chamadas desses métodos. Note que a thread deve possuir o monitor do objeto ao qual pertence o método. Por isso, no exemplo abaixo iten b o objeto sincronizado no bloco é o mesmo que invoca o método notify() .



Outra observação importante é que a thread que invoca o método wait() o faz dentro de um laço sobre a condição de espera. Isto ocorre porque apesar de ter sido notificada isto não assegura que a condição está satisfeita. A thread pode ter sido notificada por outra razão ou entre a notificação e a retomada da execução da thread a condição pode ter sido novamente alterada. Uma vez notificado a thread não retoma imediatamente a execução. É preciso primeiro retomar a posse do monitor que no momento da notificação pertence a thread que notificou. Mesmo após a liberação do monitor nada garante que a thread notificado ganhe a posse do monitor. Outros threads podem ter solicitado a posse do monitor e terem preferência na sua obtenção.