Variáveis Condicionais e o Problema Produtor-Consumidor

As variáveis condicionais (ou variáveis de condição) podem ser utilizadas de forma complementar aos mutexes para sincronizar threads. Enquanto mutexes possibilitam o sincronismo das threads controlando o acesso a determinados dados, as variáveis condicionais implementam o sincronismo das threads baseado no valor atual de um dado. Este artigo abordará as variáveis condicionais de forma breve, mostrando sua utilização através de uma possível solução para o clássico problema Produtor-Consumidor.

Variáveis Condicionais

As variáveis condicionais possibilitam o sincronismo de threads baseado no valor atual de um dado (valor de uma variável). Sem as variáveis condicionais, o programador necessitaria testar continuamente uma determinada variável até que determinada condição fosse atingida. A variável sendo testada poderia, inclusive, estar numa região crítica de memória. Essa é uma abordagem bastante dispendiosa, pois a thread ficaria continuamente ocupada nesta atividade de testes. O uso das variáveis condicionais é um meio de atingir o mesmo objetivo sem executar sucessivos testes.

As variáveis condicionais são complementares ao uso de mutexes. Ou seja, elas são sempre utilizadas em conjunto com um mutex para efetuar o sincronismo.

-> Rotinas da API PThread

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

A função pthread_cond_init() inicializa uma variável condicional referenciada por cond que contém os atributos referenciados por attr. Se attr for NULL, serão utilizadas as condições padrão para a variável. Em caso de sucesso na inicialização, o estado da variável condicional passa a ser ‘inicializada’. É possível também inicializar uma variável condicional de forma estática no momento da sua declaração. Neste caso, os valores padrão serão utilizados:

A função pthread_cond_destroy() destrói a variável condicional referenciada por cond. Em efeito, o objeto passa o estado ‘não-inicializado’. Uma variável destruída pode ser reinicializada chamando a função pthread_cond_init(). É seguro destruir uma variável condicional na qual não há threads atualmente bloqueadas. Tentar destruir uma variável condicional na qual há threads bloqueadas resulta em comportamento indefinido.

A função pthread_cond_wait() bloqueia uma thread sob a condição da variável cond. Esta função deve ser chamada com um mutex que já esteja travado pela thread que a invoca, caso contrário irá resultar em um comportamento indefinido. Essa função libera o mutex automática e atomicamente, fazendo com que a thread que a invoca fique bloqueada na variável condicional cond. Atomicamente, aqui, significa que a operação será vista como instantânea por outras threads. E se uma segunda thread se apoderar do mutex após o bloqueio da primeira, a segunda thread deverá, posteriormente, chamar a função pthread_cond_signal() ou pthread_cond_broadcast() para acordar uma ou todas as threads bloqueadas em determinada variável. Em caso de sucesso no retorno da função, o mutex deverá estar travado e sob controle da thread que a invocou.

A função pthread_cond_signal() deverá desbloquear ao menos uma thread (se houver) que esteja bloqueada na variável condicional cond. Se mais de uma thread está bloqueada na variável condicional, a política de escalonamento deverá determinar a ordem na qual as threads serão desbloqueadas. Em caso de sucesso, esta função retorna o valor zero, caso contrário um número de erro é retornado.

O Problema Produtor-Consumidor

Para exemplificar, será mostrada uma solução para o clássico problema produtor-consumidor (também conhecido como problema do buffer limitado) utilizando mutex e variáveis condicionais. O problema produtor-consumidor consiste em duas threads (ou dois processos) que compartilham um buffer de tamanho finito (tamanho fixo e limitado). A primeira thread, o produtor, gera e coloca informação no buffer. A segunda thread, o consumidor, lê as informações do buffer e os imprime na tela.

O problema surge quando o produtor quer colocar uma nova informação no buffer, mas este já encontra-se cheio. A solução seria colocar o produtor para dormir e somente despertá-lo quando o consumidor remover um ou mais itens do buffer. Da mesma maneira, se o consumidor tentar retirar um item do buffer e este estiver vazio, ele deverá dormir até que o produtor coloque alguma informação lá e o desperte.

Suponhamos que o buffer tenha tamanho N, então, para manter o controle de itens dentro dele, deve haver uma variável de controle buffer_size. O produtor verifica primeiro se o valor de buffer_size é N. Se for, o produtor dormirá; caso contrário, o produtor adicionará uma nova informação no buffer e incrementará a variável de controle. O código do consumidor é similar: se o valor de buffer_size é zero, então o consumidor deverá dormir. Se o valor da variável não for zero, então o consumidor deverá retirar um item do buffer e decrementar a variável de controle.

-> Solução

Para resolver este problema, utilizaremos os dois recursos da API Pthreads em conjunto: mutex e variáveis condicionais. O mutex fará o controle de acesso ao buffer e as variáveis condicionais servirá para bloquear a thread do produtor e do consumidor quando for conveniente. Haverá uma variável condicional específica para o produtor e outra para o consumidor, isto porque cada thread executa uma função diferente e deve ser adormecida ou despertada em momentos diferentes e por motivos diferentes. A seguir o código proposto:

Observe que a a construção do buffer é no formato de pilha (LIFO), ou seja, o último dado a ser produzido será o primeiro a ser consumido. Há também a possibilidade de se criar um buffer em formato FIFO, o que não irá alterar em nada o sincronismo das threads, mas requer um pouco mais de esforço de programação. Outro ponto a ser observado é que o mesmo objeto buffer_t é passado para ambas as threads. Isso acontece porque a região de memória do buffer deve ser a mesma para consumidor e produtor, além de que o mutex deve ser comum às duas threads e também a variável de controle do buffer.

Compile o código acrescentando a flag -lpthread na linha de comando e rode o programa. A execução nunca irá terminar, então, em algum momento, você precisará pressionar as teclas Ctrl+C para interromper a execução. Após parar a execução do programa, dê uma olhada nos dados gerados e confirme o seu correto funcionamento. Note que o consumidor sempre irá pegar o último dado colocado no buffer e que o produtor nunca irá adicionar mais que cinco itens no buffer. A recíproca também é verdadeira.

Para simular uma situação que causaria overflow do buffer, deve-se recompilar o código adicionando a flag -D OVERFLOW na linha de comando. Isso irá habilitar a flag OVERFLOW existente no código com a diretiva de pré-processador. Assim, a porção de código que está entre #ifdef … #endif será compilada. Essa porção de código fará o consumidor ser mais lento que o produtor e, durante o funcionamento do código, observe que o produtor sempre aguarda um dado ser retirado do buffer para então produzir um novo. Ou seja, a condição de overflow não acontece devido ao sincronismo das threads.

O mesmo pode ser feito para simular um situação que causaria underflow, na qual o produtor é mais lento que o consumidor. Por sua vez, o consumidor sempre aguarda um dado ser produzido para então retirá-lo do buffer.

Conclusão

Conclui-se, pois, que o uso complementar de mutexes e variáveis condicionais torna o código da aplicação mais enxuto e mais eficiente, menos propício a falhas. Isso se torna possível porque as variáveis condicionais desempenham a importante função de adormecer e acordar as threads vinculadas para que elas somente sejam executadas no momento correto. Com isso, deixa de ser necessário o teste contínuo de uma determinada condição para então executar uma ação. Isso facilita muito a programação e garante robustez ao programa, já que a tarefa de sincronização das threads fica a encargo da API.

Então, relembrando: o mutex garante a uma thread o acesso exclusivo a uma determinada região da memória para que não haja condições de corrida; a variável condicional sincroniza a execução de duas ou mais threads com base num determinado valor e sempre estará associada a um mutex específico.

Gostou do artigo? Deixe um comentário, curta e compartilhe com os amigos!
Caso haja dúvidas, fique à vontade para perguntar.
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 *