Em C++11, o suporte a operações atômicas é fornecido principalmente pelo cabeçalho <atomic>. Contudo, para dominar operações atômicas, é essencial compreender o modelo de memória e os princípios do memory_order. Operações atômicas garantem que uma ação seja indivisível em ambientes multi-thread, prevenindo interrupções. Mas qual é o papel do modelo de memória e do memory_order? Como asseguram a ordem e a visibilidade? Este artigo explora esses conceitos detalhadamente.
Fundamentos do Modelo de Memória
Arquiteturas modernas utilizam caches por núcleo de CPU, com memória principal compartilhada entre threads. Isso pode causar:
- Problemas de coerência de cache: lieturas e escritas em endereços de memória podem ficar dessincronizadas entre caches.
- Reordenação de instruções: compiladores ou CPUs podem reordenar operações para otimizar desempenho, alterando a sequência lógica do código.
O modelo de memória define regras para esses comportamentos, garantindo ordem e visibilidade adequadas para código multi-thread correto.
Papel do memory_order
O parâmetro memory_order especifica restrições de ordem de memória para operações atômicas, controlando:
- Visibilidade: quando uma escrita em uma thread se torna visível para outras.
- Ordem: como operações entre diferentes threads são sequenciadas.
C++11 oferece seis opções de memory_order:
enum memory_order {
memory_order_relaxed,
memory_order_consume,
memory_order_acquire,
memory_order_release,
memory_order_acq_rel,
memory_order_seq_cst
};
Detalhes de Cada memory_order
memory_order_relaxed
É a restrição mais flexível:
- Atomicidade: garante que a operação seja atômica.
- Ordem: não há garantia de ordem; reordenação é permitida.
- Visibilidade: não assegura que outras threads vejam o resultado imediatamente.
Útil para contadores ou estatísticas onde a ordem estrita não é necessária.
std::atomic<int> contador{0};
void incrementar() {
contador.fetch_add(1, std::memory_order_relaxed);
}
memory_order_acquire e memory_order_release
Utilizados em par para sincronização, como em locks:
- Release (operação de escrita): todas as escritas anteriores tornam-se visíveis a outras threads após esta operação.
- Acquire (operação de leitura): após esta operação, leituras subsequentes veem todas as escritas anteriores ao Release correspondente.
Exemplo de aplicação em mecanismos de sincronização:
int valor = 0;
std::atomic<bool> sinal{false};
std::thread produtor([&]() {
valor = 100;
sinal.store(true, std::memory_order_release);
});
std::thread consumidor([&]() {
sinal.load(std::memory_order_acquire);
std::cout << "Valor: " << valor << std::endl;
});
memory_order_seq_cst
Fornece consistência sequencial, onde a ordem de execução é idêntica à ordem do código. É a opção padrão quando memory_order não é especificado. Embora segura, impõe custo de desempenho.
std::atomic<int> a{0}, b{0};
void thread1() {
a.store(1, std::memory_order_seq_cst);
b.store(1, std::memory_order_seq_cst);
}
void thread2() {
if (b.load(std::memory_order_seq_cst) == 1) {
assert(a.load(std::memory_order_seq_cst) == 1); // Sempre verdadeiro
}
}
memory_order_acq_rel
Combina semânticas de aquisição e liberação, usado em operações atômicas que atuam tanto como cnosumidor quanto produtor. Aplica-se a operações como compare_exchange_weak.
std::atomic<int> compartilhado;
int esperado = compartilhado.load(std::memory_order_acquire);
while (compartilhado.compare_exchange_weak(esperado, novo_valor, std::memory_order_acq_rel)) {
// lógica de atualização
}
memory_order_consume
Otimiza a sincronização focando apenas em dados diretamente dependentes da operação atômica, como ponteiros. Pode ser combinado com memory_order_release para modelos produtor-consumidor, reduzindo sincronização global.
// Produtor
std::atomic<Objeto*> ptr;
Objeto* obj = new Objeto;
obj->dados = 42;
ptr.store(obj, std::memory_order_release);
// Consumidor
Objeto* obtido = ptr.load(std::memory_order_consume);
// Acesso a obtido->dados é seguro
Na prática, compiladores podem otimizar consume para acquire; dependências de dados estritas (ex.: via ponteiros) são necessárias.
Análise de Exemplos do Modelo de Memória
Considere o seguinte código:
std::atomic<int> x{0}, y{0};
// Thread 1
x.store(1, std::memory_order_relaxed);
y.store(1, std::memory_order_relaxed);
// Thread 2
if (y.load(std::memory_order_relaxed) == 1) {
// x pode ainda ser 0 aqui!
std::cout << x.load(std::memory_order_relaxed);
}
Devido à flexibilidade do relaxed, a ordem das escritas em x e y pode ser invertida, fazendo com que a thread 2 observe y=1 mas x=0. Usar memory_order_seq_cst seria mais seguro, porém com custo de desempenho. Para otimização, é recomendável utilizar modelos mais relaxados ou acquire-release somente quando a contenção de dados for controlada.
Operações atômicas não resolvem todos os problemas; cenários complexos podem necessitar de locks ou outros mecanismos de sincronização. A seleção adequada de memory_order baseada nas necessidades de visibilidade e ordem é crucial para código concorrente eficiente e correto.