Sincronismo de Threads Utilizando Mutex

Quando apenas uma thread acessa uma determinada variável do espaço de memória compartilhado, não há nenhum tipo de problema aparente. Porém, quando duas ou mais threads precisam acessar uma mesma região de memória para fazer operações de leitura/escrita, pode haver inconsistência nos resultados. Isto se deve à possibilidade de acesso simultâneo dessas threads ao mesmo local. Isto gera duas situações-problema: a primeira acontece quando essas threads entram em conflito e precisam evitar umas as outras. A segunda está relacionada com uma sequência adequada de operações para que o processamento ocorra de maneira correta e o resultado final seja íntegro. Neste artigo, veremos essas duas situações com mais detalhes, bem como maneiras de tratar cada situação-problema utilizando recursos da API Pthreads.

Condições de corrida

Em sistemas operacionais, processos ou threads que trabalham juntos podem compartilhar algum meio de armazenamento comum a partir do qual cada um seja capaz de ler e escrever. Seja este espaço compartilhado a memória RAM ou um arquivo no disco, isto não altera a natureza da comunicação e os problemas que daí insurgem.

Imagine a situação hipotética onde há um servidor de impressão o qual possui um buffer de arquivos aguardando para serem impressos. Vários processos podem adicionar arquivos neste buffer e apenas um processo (chamaremos de processo X) fica responsável por mandar estes arquivos para a impressão. Ao buffer de impressão estão associadas duas variáveis, uma que aponta para a próxima posição livre para adicionar um arquivo (próxima_posição_livre) e outra que aponta para o próximo arquivo a ser impresso (próximo_arquivo_impressão). Assim que o processo X manda um arquivo para a impressão, ele o retira do buffer imediatamente.

Então, em um dado momento, os processos A e B tentam enviar arquivos para o servidor de impressão. Se Murphy permitir, tudo dará errado e pode acontecer o seguinte: o processo A lê o valor 5 na variável próxima_posição_livre e, logo em seguida, ocorre uma interrupção do timer e o escalonador da CPU chaveia para o processo B. O processo B, por sua vez, também lê o valor 5 na variável próxima_posição_livre. A partir deste momento, ambos os processos pensam que a próxima posição livre é a 5. Veja a figura 1.

Condição de Corrida - Exemplo

Figura 1. Dois processos desejam acessar o mesmo espaço de memória compartilhada ao mesmo tempo.

Então, o processo B segue sua execução e coloca um arquivo no buffer de impressão na posição 5. Logo após, incrementa a variável próxima_posição_livre para o valor 6.

Num outro momento, o processo A executa novamente a partir de onde havia parado. Pega o valor que havia lido anteriormente na variável próxima_posição_livre e coloca lá um arquivo para a impressão, sobrescrevendo o arquivo que o processo B havia colocado lá. Então, ele também incrementa a variável para o valor 6 e segue adiante. A partir disso, o servidor segue normalmente sua operação, porém o processo B nunca terá uma saída de impressão.

Situações como essa, onde dois ou mais processos executam ações de leitura ou escrita em uma região compartilhada da memória e cujo resultado final dependa da ordem de quem e quando executa as operações, são conhecidas como condições de corrida. Essas falhas são mais comuns do que se imagina e são muito complicadas de depurar. Normalmente, os testes não acusam nenhum tipo de problema, contudo, num momento inesperado, algo inexplicável acontece.

Embora o exemplo anterior tenha sido dado com processos, o mesmo se aplica às threads, pois elas também compartilham recursos de memória dentro do contexto do processo as quais pertencem.

Mutexes

A palavra mutex é uma abreviação de exclusão mútua (do inglês mutual exclusion). O princípio da exclusão mútua baseia-se no fato de evitar que mais de um processo leia ou escreva ao mesmo tempo na memória compartilhada. Assim, no exemplo dado anteriormente, poderíamos resolver o problema criando um mecanismo de exclusão mútua para que os processos tivessem domínio completo sobre o buffer de impressão e a variável próxima_posição_livre enquanto necessitassem. Desta forma, o acesso àquela região de memória não seria permitido para nenhum outro processo até que ela fosse liberada.

O mutex é uma  variável que pode estar em um dos dois estados seguintes: impedido ou desimpedido. Outros termos também podem ser utilizados, tais como bloqueado ou desbloqueado, trancado ou destrancado, etc. Sempre que um recurso global for acessado por mais de uma thread, este recurso deve ter um mutex associado a ele. Pode-se, inclusive, aplicar um mutex a um determinado segmento de memória (uma região crítica, por exemplo) para protegê-la de outras threads.

-> Utilização de mutex

Uma sequência típica no uso de um mutex pode ser ilustrada como a seguir:

  1. O mutex é criado e inicializado;
  2. Várias threads tentam se apoderar do mutex (impedi-lo ou travá-lo);
  3. Apenas uma única thread terá sucesso em travá-lo;
  4. A thread que está em poder do mutex executa um conjunto de operações sobre a região da memória protegida pelo respectivo mutex;
  5. A thread libera o mutex;
  6. Outra thread se apodera do mutex e repete o processo;
  7. Finalmente, o mutex é destruído.

Quando uma thread tenta se apoderar o mutex e não consegue, ela fica bloqueada no ponto da chamada até que o mutex seja liberado e ela possa fazer uma nova tentativa. E quando se está protegendo recursos compartilhados, é responsabilidade do programador ter a certeza de que todas as threads que acessam aquele recurso fazem uso do mutex. Se, por exemplo, quatro threads leem e escrevem numa mesma variável e apenas uma delas utiliza mutex, a informação pode se corromper.

-> Rotinas da API Pthread

Abaixo, são listadas algumas rotinas da API Pthread para trabalho com mutex. Lembrando que é necessário adicionar a biblioteca pthread.h para fazer uso destas funções:

Primeiro de tudo, é necessário criar a variável mutex para o recurso compartilhado o qual deseja-se proteger ou sincronizar as operações de leitura e escrita. Uma variável mutex deve ser declarada com o tipo pthread_mutex_t e deve estar acessível a todas as threads que irão utilizá-la. Para isso, pode-se declará-la num contexto global ou passar o seu ponteiro no momento da criação da thread.

A função pthread_mutex_init() é utilizada para inicializar dinamicamente a variável mutex em qualquer ponto do código e também permite estabelecer um objeto de atributos através do argumento attr. Se attr deve ser declarado com o tipo pthread_mutexattr_t e, se for passado NULL, serão utilizados os atributos padrão. Em caso de sucesso, o mutex passa para o estado de inicializado e desimpedido (destravado). Tentar inicializar um mutex já inicializado anteriormente, resulta em um comportamento indefinido. Em caso de sucesso, esta função retorna zero.

É possível também inicializar um mutex de forma estática, no momento da sua declaração:

A função pthread_mutex_destroy() é utilizada para destruir um mutex que foi inicializado e não será mais necessário. O mutex, na verdade, passa para o estado não inicializado. Um mutex que foi destruído pode ser reinicializado através da chamada pthread_mutex_init(). É seguro destruir um mutex que esteja destravado. Tentar destruir um mutex o qual encontra-se travado resulta em um comportamento indefinido. Em caso de sucesso, esta função retorna zero.

A função pthread_mutex_lock() deve ser utilizada por uma thread que deseja impedir um mutex. Se o mutex já encontra-se impedido por outra thread, então a primeira thread ficara bloqueada até que o mutex seja liberado. Em caso de sucesso, esta função retorna o valor zero e causa o impedimento do mutex, colocando a thread que a chamou em posse do mutex.

A função pthread_mutex_trylock() é idêntica à função anterior, porém quando o mutex desejado já encontra-se impedido, a função retorna imediatamente com um código de erro “ocupado”. Esta rotina pode ser útil para prevenir deadlocks, casos em que o programa fica travado eternamente em um ponto do programa.

Por fim, a função pthread_mutex_unlock() irá desimpedir o mutex caso ela seja chamada pela thread que está em poder do mutex. Chamar esta rotina é extremamente necessário após a thread completar suas operações na área protegida da memória. Isto é claro, desde que hajam outras threads que também irão fazer uso do recurso compartilhado. Caso deseja-se apenas restringir o acesso a uma região da memória por outras threads, o mutex deve permanecer sempre impedido. Caso haja mais de uma thread bloqueada aguardando a liberação daquele mutex, a política de prioridade irá determinar qual thread ganhará o mutex após a sua liberação.

Problema de sincronização de threads

O programa apresentado neste exemplo mostra um típico problema de sincronização entre threads. Quando mais de uma thread acessa a mesma variável da memória compartilhada sem nenhum recurso de sincronização, é muito provável que o resultado esperado não seja alcançado. Vejamos o código a seguir:

O código acima cria 5 threads que executam um trabalho de contagem. Elas avisam o início e o fim do seu trabalho através de mensagens no terminal. Espera-se que as mensagens sejam impressas corretamente para cada trabalho, contudo a saída é a seguinte:

As mensagens de “Trabalho x Iniciado” apareceram corretamente, mas note que a mensagem “Trabalho 5 finalizado” se repetiu cinco vezes. Se você entendeu bem a teoria sobre threads, já sabe por que isso aconteceu.

A explicação é a seguinte: A thread 1 é criada primeiro, incrementa a variável counter e inicia o seu trabalho de contagem. Enquanto a thread 1 executa o seu trabalho, o escalonador dá espaço para a thread 2 ocupar a CPU. Nisso, a thread 2 também incrementa a variável counter e inicia o seu trabalho. E enquanto a thread 2 executa o seu trabalho, o escalonador seleciona a thread 3 para que ela possa ocupar a CPU e assim sucessivamente até a thread 5.

Acontece que, quando a thread 1 termina o seu trabalho, a variável counter já foi incrementada outras quatro vezes e o seu valor já não condiz mais com o contexto daquela thread. Quando todas as threads finalizam o trabalho, a variável counter contém o valor 5. É por este motivo que a mensagem se repetiu todas as cinco vezes.

Então, o problema consistiu no uso da variável counter por outras threads enquanto uma thread específica estava processando um trabalho que dependia do valor dessa variável. A seguir, vejamos as modificações no código necessárias para utilizar mutex e resolver o problema.

Pode-se observar que a variável mutex é declarada no escopo global. Na função main(), o mutex é inicializado logo no início e ao final é destruído. Na função da thread, o mutex é travado logo no início, necessariamente antes de atualizar conteúdo da variável counter. O mesmo deve ser liberado ao final da execução do trabalho. Para isso, teremos a seguinte saída:

Agora observamos a impressão correta das mensagens conforme o esperado. Cada thread executa seu trabalho conforme o mutex fica disponível e acaba que a execução se torna sequencial. Obviamente, este é um exemplo muito simples apenas para demonstrar o funcionamento do recurso.

Conclusão

Vimos que sempre que duas ou mais threads necessitam acessar uma mesma região de memória, deve-se fazer uso de algum mecanismo de coordenação para que as threads não sobrescrevam informações umas das outras, tornando assim o resultado inconsistente. Conclui-se, pois, que o sincronismo entre threads pode ser alcançado facilmente através do uso de mutexes. Este recurso tão importante possui uma implementação robusta já na biblioteca de Pthreads, o que facilita muito o seu uso.

Posteriormente, ainda veremos um mecanismo auxiliar de sincronismo que são as variáveis condicionais, também descritas na literatura como variáveis de condição. Este recurso possibilita colocar uma thread em estado adormecido até que um sinal específico seja recebido.

Não se esqueça de curtir e compartilhar este artigo. Dúvidas? Deixe um comentário. Será muito bem-vindo.
Abraços e até a próxima!

Sobre

Jair Junior é Bacharel em Engenharia Eletrônica pela Universidade de Brasília [2014] com ênfase em microeletrônica. Suas especialidades na área são microcontroladores, sistemas embarcados e projeto de hardware. Também possui conhecimentos aprofundados em aplicações web e processamento digital de imagens. Atualmente, é aluno de pós-graduação lato sensu da PUC Minas no curso de Desenvolvimento de Aplicações Web. Ademais, tem como hobbies viajar, praticar esportes na natureza, apreciar cervejas artesanais e escutar um bom e velho rock 'n roll. Para mais detalhes, acesso a página sobre o autor.

Ver todos os posts de

Deixe uma resposta

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *