Threads em C++ – Parte 3: Objetos Atômicos

por Fabio A. Mazzarino

Objetos Atômicos são objetos que passam por alterações atômicas, ou seja, que não podem ser divididas, tornando-se seguras para acesso por múltiplas threads, sem risco de racing condition, nem de deadlock.

É possível implementar objetos atômicos através da utilização apropriada de mutex, mas a STL fornece dois tipos básicos de objetos atômicos, que servem para a maior parte das soluções.

Usando sleep()

Há algum tempo trabalhei com uma equipe que fazia sincronização entre threads utilizando sleep(), isso era fonte de vários problemas e algum estresse dentro da equipe.

O problema maior era que considerava-se que uma thread iria durar um determinado tempo, e que aguardar n segundos iria fazer com que ambas estivessem sincronizadas. Mas o que aconteceria caso uma das threads fosse repentinamente despriorizada no sistema? As threads ficariam dessincronizadas novamente. Não podemos confiar em um mecanismo desses.

Vamos simular a solução errada:


#include <iostream>
#include <thread>

#define WAYTRACE    std::cout << "[WAY] "
#define UPDTRACE    std::cout << "[UPD] "

void waythread() {
    WAYTRACE << "starting milestone #1" << std::endl;
    // this will take 15s
    for (int step = 0; step < 3; step++) {
        std::this_thread::sleep_for(std::chrono::seconds(5));
        WAYTRACE << "milestone #1 step #" << step + 1 << std::endl;
    }
    WAYTRACE << "finished milestone #1" << std::endl;
    std::this_thread::sleep_for(std::chrono::seconds(12));

    // this will take 15s
    WAYTRACE << "starting milestone #2" << std::endl;
    for (int step = 0; step < 3; step++) {
        std::this_thread::sleep_for(std::chrono::seconds(5));
        WAYTRACE << "milestone #2 step #" << step + 1 << std::endl;
    }
    WAYTRACE << "finished milestone #2" << std::endl;
    std::this_thread::sleep_for(std::chrono::seconds(18));
    WAYTRACE << "done" << std::endl;
}

void updthread() {
    UPDTRACE << "starting milestone #1" << std::endl;
    // this will take 12s
    for (int step = 0; step < 4; step++) {
        std::this_thread::sleep_for(std::chrono::seconds(3));
        UPDTRACE << "milestone #1 step #" << step + 1 << std::endl;
    }
    UPDTRACE << "finished milestone #1" << std::endl;
    std::this_thread::sleep_for(std::chrono::seconds(15));

    // this will take 18s
    UPDTRACE << "starting milestone #2" << std::endl;
    for (int step = 0; step < 6; step++) {
        std::this_thread::sleep_for(std::chrono::seconds(5));
        UPDTRACE << "milestone #2 step #" << step + 1 << std::endl;
    }
    UPDTRACE << "finished milestone #2" << std::endl;
    std::this_thread::sleep_for(std::chrono::seconds(15));
    UPDTRACE << "done" << std::endl;
}

int main() {
    std::thread way(waythread);
    std::thread upd(updthread);
    way.join();
    upd.join();
}

Começamos o código criando duas macros para fazer trace das threads, facilitando a visualização da saída. Em seguida criamos duas threads, uma chamada way, e outra chamada upd. Cada uma tem dois loops, indicando um milestones com um conjunto de passos. E após cada milestone vamos “sincronizar” as threads utilizando sleep() com a expectativa de tempo de execução de cada loop da outra thread.

Qualquer atraso na execução de uma das duas threads vai fazer com que a “sincronização” entre as threads acabe, e uma thread pode continuar antes da outra terminar seu milestone.

Essa é a forma errada: nunca utilize sleep() para sincronizar threads.

Nunca Utilize sleep() para Sincronizar Threads

Para evitar o problema encontrado no código anterior vamos utilizar objetos atômicos que irão armazenar em qual milestone cada thread está.

Note que vamos continuar a usar sleep dentro do loop, mas não para fazer sincronização, e sim para simular determinado tempo de processamento.


#include <atomic>
#include <iostream>
#include <thread>

#define WAYTRACE    std::cout << "[WAY] "
#define UPDTRACE    std::cout << "[UPD] "

std::atomic<unsigned> waymilestone;
std::atomic<unsigned> updmilestone;

void waythread() {
    waymilestone = 0;
    WAYTRACE << "starting milestone #" << waymilestone + 1 << std::endl;
    // this will take 15s
    for (int step = 0; step < 3; step++) {
        std::this_thread::sleep_for(std::chrono::seconds(5));
        WAYTRACE << "milestone #" << waymilestone + 1 << " step #" << step + 1 << std::endl;
    }
    WAYTRACE << "finished milestone #" << waymilestone + 1 << std::endl;
    waymilestone++;

    WAYTRACE << "waiting for upd milestone #1..." << std::endl;
    while (updmilestone != 1)
        std::this_thread::yield();
    WAYTRACE << "upd milestone #1 reached" << std::endl;

    // this will take 15s
    WAYTRACE << "starting milestone #" << waymilestone + 1 << std::endl;
    for (int step = 0; step < 3; step++) {
        std::this_thread::sleep_for(std::chrono::seconds(5));
        WAYTRACE << "milestone #" << waymilestone + 1 << " step #" << step + 1 << std::endl;
    }
    WAYTRACE << "finished milestone #" << waymilestone + 1 << std::endl;
    waymilestone++;

    WAYTRACE << "waiting for upd milestone #2..." << std::endl;
    while (updmilestone != 2)
        std::this_thread::yield();
    WAYTRACE << "upd milestone #2 reached" << std::endl;

    WAYTRACE << "done" << std::endl;
}

void updthread() {
    updmilestone = 0;
    UPDTRACE << "starting milestone #" << updmilestone + 1 << std::endl;
    // this will take 12s
    for (int step = 0; step < 4; step++) {
        std::this_thread::sleep_for(std::chrono::seconds(3));
        UPDTRACE << "milestone #" << updmilestone + 1 << " step #" << step + 1 << std::endl;
    }
    UPDTRACE << "finished milestone #" << updmilestone + 1 << std::endl;
    updmilestone++;

    UPDTRACE << "waiting for way milestone #1..." << std::endl;
    while (waymilestone != 1)
        std::this_thread::yield();
    UPDTRACE << "way milestone #1 reached" << std::endl;

    // this will take 18s
    UPDTRACE << "starting milestone #" << updmilestone + 1 << std::endl;
    for (int step = 0; step < 6; step++) {
        std::this_thread::sleep_for(std::chrono::seconds(5));
        UPDTRACE << "milestone #" << updmilestone + 1 << " step #" << step + 1 << std::endl;
    }
    UPDTRACE << "finished milestone #" << updmilestone + 1 << std::endl;
    updmilestone++;

    UPDTRACE << "waiting for way milestone #2..." << std::endl;
    while (waymilestone != 2)
        std::this_thread::yield();
    UPDTRACE << "way milestone #2 reached" << std::endl;

    UPDTRACE << "done" << std::endl;
}

int main() {
    std::thread way(waythread);
    std::thread upd(updthread);

    way.join();
    upd.join();
}

A correção começa com a criação de dois objetos atômicos que vão controlar em qual milestone cada processamento se encontra. E dentro do código de cada thread é preciso colocar um controle de sincronização, basicamente um loop, esperando que o milestone da outra thread seja alcançado.

Note que usamos std::this_thread::yeld para liberar o processamento para a outra thread. Um sleep() poderia fazer a mesma função, mas adicionaria uma latência desnecessária no loop.

Usar STL ou Desenvolver um Próprio?

A opção de utilizar o std::atomic tem que ser da equipe. Algumas equipes preferem utilizar classes próprias para fazer a sincronização entre threads, outras permitem a utilização de std::atomic. Mas tudo depende dos padrões de desenvolvimento de cada equipe.