ÍNDICE DE CONTEÚDO
Introdução
Par quem está iniciando no desenvolvimento de sistemas embarcados, um exemplo do quotidiano pode ajudar a entender o conceito de condição de corrida. Imagine um casal com uma conta-corrente conjunta cujo saldo é R$1000,00. No final do expediente de trabalho, entram em lojas próximas de seus escritórios para comprar algo que estava com uma oferta tentadora. Um dos produtos custa R$700,00 e o outro, R$800,00. Antes de fazer a compra, consultam o saldo em sua conta-corrente e, ao saber que há fundos suficientes (os tais R$1000,00), realizam a compra. Ao chegarem em casa, descobrem que gastaram um total de R$1500,00, deixando a conta-corrente negativa, para alegria do banco, que cobrará juros e taxas sobre o empréstimo de R$500,00 que involuntariamente acabaram de fazer.
Este artigo é introdutório e não pretende esgotar o assunto: seu objetivo é chamar a atenção para o conceito e para os problemas associados. Será apresentado um exemplo de como evitar o problema em um sistema com arquitetura bare-metal.
Conceito de condição de corrida (race condition)
A Wikipedia contém a seguinte definição, em tradução livre: “uma condição de corrida é a condição de um circuito eletrônico, software ou outro sistema onde o comportamento do sistema é afetado de forma significativa, dependente da sequência ou timing ou outros eventos que não podem ser controlados. Torna-se um bug quando um ou mais dos possíveis comportamentos são indesejáveis.[1]” E, na versão em português: “apesar de ser conhecido em português por ‘condição de corrida’ uma tradução melhor seria ‘condição de concorrência’ pois o problema está relacionado justamente ao gerenciamento da concorrência entre processos teoricamente simultâneos. O fenômeno pode ocorrer em sistemas eletrônicos, especialmente em circuitos lógicos, e em programas de computador, especialmente no uso de multitarefa ou computação distribuída.”[2]
O termo “condição de corrida” é uma tradução literal do inglês “race condition” e define a situação em que ocorre conflito no acesso algum recurso (e.g., memória, dispositivo de E/S etc.) compartilhado entre duas ou mais tarefas ou threads de um sistema de software. Frequentemente é fonte de bugs difíceis de identificar, portanto, difíceis de corrigir.
Em sistemas bare-metal, a concorrência ocorre entre o super loop que executa o código principal do software e interrupções geradas assincronamente por periféricos ou dispositivos externos ao processador (timers, conversores A/D, sensores etc.).
Quando um sistema operacional ou um RTOS é usado, a concorrência ocorre entre várias tafefas/threads/processos que rodam em paralelo (em caso de multiprocessadores) ou em aparente paralelismo (no caso de threads/tarefas rodando sobre ou RTOS) além de interrupções causadas por dispositivos ou periféricos.
Exemplo de código com condição de corrida
Analisemos o código abaixo, vulnerável à ocorrência de condição de corrida.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
typedef enum { EVT_NONE = 0, EVT_ISR1 = 1<<0, EVT_ISR2 = 1<<1 } event_t; event_t event; void interrupt isr1 (void) { event |= EVT_ISR1; } void interrupt isr2 (void) { event |= EVT_ISR2; } int main (void) { do_stuff_initialization(); while (1) { if (event & EVT_ISR1) { handle_isr1_event(); } if (event & EVT_ISR2) { handle_isr2_event(); } event = 0 } return 0; } |
Duas funções, isr1() e isr2() que são chamadas quando algum evento associado a elas ocorre (e.g., um conversor A/D terminou a conversão, um sensor detectou alguma alteração no ambiente, uma tecla foi pressionada, etc.), e elas sinalizam o evento setando um bit em uma variável global.
A função main() executa a inicialização do sistema e então fica em um loop infinito (while(1){}), aguardando a ocorrência de eventos e executando as funções que processam esses eventos quando eles ocorrem.
O loop while() verifica se os bits da variável event correspondentes estão setados e executa as funções handle_isr1_event() e handle_isr1_event() se afirmativo. No final, zera a variável event, de modo que as funções sejam executadas somente executadas quando um novo evento causar a execução da função de interrupção para setar o bit novamente. Antes de prosseguir, o leitor percebe uma falha óbvia nesse software?
Suponha que no momento em que o primeiro teste (if (event & EVT_ISR1)) seja executado, o bit correspondente da variável event seja 0: a função handle_isr1_event() não será executada. Suponha que após a variável event ter sido lida para esse primeiro teste, ocorra um evento que dispara a execução da função isr1(), setando o bit correspondente. No final do loop while, a variável event zerada, e o evento será perdido.
O código abaixo tenta corrigir o problema descrito acima.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
typedef enum { EVT_NONE = 0, EVT_ISR1 = 1<<0, EVT_ISR2 = 1<<1 } event_t; event_t event; void interrupt isr1 (void) { event |= EVT_ISR1; } void interrupt isr2 (void) { event |= EVT_ISR2; } int main (void) { do_stuff_initialization(); while (1) { evt = event; event = 0; if (event & EVT_ISR1) { handle_isr1_event(); } if (event & EVT_ISR2) { handle_isr2_event(); } } return 0; } |
Uma variável automática evt é carregada com o valor de event, e event é zerada imediatamente a seguir. Muito melhor que o primeiro exemplo, mas infelizmente sofre do mesmo problema: entre a atribuição de event a evt e o zeramento de event, pode ocorrer um evento que gera uma interrupção e a setagem do bit correspondente, mas esse evento também será perdido pelo zeramento de event. Embora a probabilidade seja reduzida porque o intervalo de tempo entre a atribuição e o zeramento é pequena, ainda ocorre a condição de corrida e o evento é perdido.
Para isso não ocorrer, não deveria ser possível interromper a execução do código de máquina gerado pelo compilador para as linhas 24 e 25 do código acima. Em outras palavras, as operações que leem e alteram a variável event precisariam ser executadas atomicamente. Uma operação atômica é uma operação que será sempre completada sem que qualquer outro processo (ou tarefa ou thread) seja capaz de ler ou alterar o estado que é lido e/ou alterado durante a operação[3].
O código abaixo apresenta uma forma de se evitar a condição de corrida no acesso da variável event.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
typedef enum { EVT_NONE = 0, EVT_ISR1 = 1<<0, EVT_ISR2 = 1<<1 } event_t; event_t event; void set_event (event_t evt) { critical_section_lock(); event |= evt; critical_section_unlock(); } event_t get_event (void) { critical_section_lock(); ret = event; event = 0; critical_section_unlock(); return ret; } void interrupt isr1 (void) { set_event(EVT_ISR1); } void interrupt isr2 (void) { set_event(EVT_ISR2); } int main (void) { do_stuff_initialization(); while (1) { evt = get_event(); if (event & EVT_ISR1) { handle_isr1_event(); } if (event & EVT_ISR2) { handle_isr2_event(); } } return 0; } |
Duas funções, set_event() e get_event() são introduzidas nesse código, e a alteração da variável event é feita dentro de uma seção de código que não pode ser interrompida sem ser completada; a função critical_section_lock() protege a região crítica de código que faz o acesso a event, e a função critical_section_unlock() a desprotege, quando o acesso volta a ser seguro.
Há diferentes formas de implementar as funções critical_section_lock() e critical_section_unlock(), dependendo da arquitetura do software (RTOS, bare-metal etc.).
Implementação em arquitetura bare-metal
Numa arquitetura bare-metal, essas funções podem simplesmente desabilitar as interrupções durante a execução da seção crítica do código.
O código abaixo mostra a implementação genérica (pseudocódigo) dessas funções. A implementação real dependerá da arquitetura do processador e do compilador C.
1 2 3 4 5 6 7 8 9 10 11 |
void critical_section_lock(int_mask_t *masked) { *masked = get_current_interrupt_state_and_disable_global_irq(); } void critical_section_unlock(int_mask_t masked) { if (!masked) { enable_global_irq(); } } |
No caso do código acima, as funções pedem argumentos. A função critical_section_lock() exige um ponteiro para uma variável do tipo irq_mask_t (que pode ser um typedef para int, uint32_t etc., dependendo da arquitetura). A variável masked salva o estado atual da máscara global de interrupção e critical_section_lock() desabilita as interrupções globalmente. A variável masked é usada por critical_section_unlock() para restaurar o estado prévio das interrupções globais. A desabilitação das interrupções globalmente garante que a variável event seja lida/alterada atomicamente.
Algumas observações importantes são necessárias. Em primeiro lugar, e isto é MUITO, repito, MUITO importante, tanto que mereceria um artigo por si só: note que o estado atual da máscara de interrupção global é preservado na variável masked. Isso ocorre porque, ao terminar o acesso à seção crítica (variável event, no caso) queremos deixar o sistema no mesmo estado em que o encontramos. Se simplesmente desabilitássemos as interrupções, fizéssemos a leitura/alteração de event, e habilitássemos as interrupções novamente, criaríamos um novo bug, que poderia ser difícil de diagnosticar, caso as interrupções estivessem desabilitadas antes do acesso à seção crítica.
Em segundo lugar, note que a seção crítica, em nosso exemplo, é pequena (uma ou duas linhas de código C). O recurso compartilhado pode ser constituído por estruturas de dados mais complexas, exigindo que a seção crítica do código seja mais extensa, mas isso significa que as interrupções poderão ficar desabilitadas por mais tempo, aumentando a latência de interrupção, ou seja, o tempo que demora entre a ocorrência de algum evento externo que requer atenção do processador e este efetivamente executar as instruções que vão processar tal evento. Portanto, o desenvolvedor deve tentar reduzir o número e extensão dos recursos compartilhados e das seções críticas.
Em terceiro lugar, procure manter o acesso em um único bloco de código. Em outras palavras, evite entrar na seção crítica (executando critical_section_lock()) dentro uma cláusula if() e sair (executando critical_section_unlock()) em uma cláusula else. Se não for possível evitar, mantenha o código o mais simples possível e tenha certeza de que todas os else sejam tratados corretamente, especialmente aqueles que podem ocorrer raramente.É necessário consultar a documentação do processador e do compilador para determinar como implementar as funções critical_section_lock() e critical_section_unlock(). Por exemplo, o código abaixo mostra como essas funções podem ser implementadas para um ARM Cortex M[4].
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
inline uint32_t critical_section_lock(void) { masked = get_PRIMASK(); __disable_irq(); return lock_state; } inline void critical_section_unlock(const uint32_t masked) { if (!masked) { __enable_irq() } } inline static uint32_t get_events (void) { uint32_t masked = critical_section_lock (); uint32_t ret = events; events = 0; critical_section_unlock(masked); return ret; } |
Implementação com RTOS
Não vou me estender muito sobre essa alternativa porque os RTOS já oferecem serviços próprios para acessar seções críticas (mutex, semáforos, eventos etc.). Esses serviços já cuidam dos detalhes de baixo nível e tornam desnecessária a preocupação com latências de interrupção. Vários RTOS oferecem serviços mais sofisticados (e.g., queues, mensagens, memória partilhada etc.) que podem eliminar a necessidade de recursos compartilhados ou tornar seu uso mais seguro. Entretanto, pode haver casos em que os serviços não sejam oferecidos pelo RTOS escolhido para o projeto, ou algum requisito do projeto exige a implementação de algum serviço específico, e que requer o uso de recursos compartilhados; a implementação precisará usar serviços básicos como semáforos ou mutexes para proteger esses recursos durante sua leitura e/ou alteração pelos processos/threads/tarefas. Nesses casos, serão necessários alguns cuidados similares aos descritos na seção anterior, além de atenção para evitar o mau uso dos serviços (mutex etc.) que levem, por exemplo, ao travamento de duas ou mais tarefas acessando os recursos protegidos por esses serviços ou à inversão de prioridades que fará uma tarefa/thread/processo mais prioritário esperar que um de menor prioridade libere o recurso protegido, com latências indesejáveis.
Conclusão
Este artigo apresentou o conceito de condição de corrida em sistemas em que existe concorrência por recursos compartilhados. O principal objetivo do artigo foi chamar a atenção para o conceito e para os problemas que podem ser causados quando a corrida ocorre, e como evita-los, seja em sistemas bare-metal, seja em sistemas baseados em RTOS.
Saiba mais
RTOS: Um ambiente multi-tarefas para Sistemas Embarcados
ESP32 – Lidando com Multiprocessamento – Parte I