27.10
2020
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.