Enquanto eu trabalhava em um tutorial em vídeo para download on-line para desenvolvimento de gráficos 3D e mecanismo de jogo trabalhando com OpenGL moderno. Nós usamos volatile
em uma de nossas classes. O site do tutorial pode ser encontrado aqui e o vídeo que trabalha com a volatile
palavra-chave é encontrado no Shader Engine
vídeo da série 98. Esses trabalhos não são meus, mas são credenciados Marek A. Krzeminski, MASc
e este é um trecho da página de download do vídeo.
E se você estiver inscrito no site dele e tiver acesso aos vídeos dele, ele faz referência a este artigo sobre o uso de Volatile
commultithreading
programação.
volatile: o melhor amigo do programador multithreaded
Por Andrei Alexandrescu, 01 de fevereiro de 2001
A palavra-chave volatile foi criada para evitar otimizações do compilador que podem tornar o código incorreto na presença de certos eventos assíncronos.
Não quero estragar seu humor, mas esta coluna aborda o temido tópico da programação multithread. Se - como diz o capítulo anterior do Generic - a programação segura de exceções é difícil, é brincadeira de criança comparada à programação multithread.
Programas que usam vários threads são notoriamente difíceis de escrever, provar que estão corretos, depurar, manter e domar em geral. Programas multithread incorretos podem ser executados por anos sem uma falha, apenas para executar inesperadamente descontroladamente porque alguma condição de tempo crítica foi atendida.
Desnecessário dizer que um programador que escreve código multithread precisa de toda a ajuda que puder obter. Esta coluna se concentra em condições de corrida - uma fonte comum de problemas em programas multithread - e fornece insights e ferramentas sobre como evitá-los e, surpreendentemente, fazer com que o compilador trabalhe duro para ajudá-lo com isso.
Apenas uma pequena palavra-chave
Embora os padrões C e C ++ sejam notavelmente silenciosos quando se trata de threads, eles fazem uma pequena concessão ao multithreading, na forma da palavra-chave volatile.
Assim como sua contraparte mais conhecida const, volatile é um modificador de tipo. Ele deve ser usado em conjunto com variáveis que são acessadas e modificadas em diferentes threads. Basicamente, sem voláteis, escrever programas multithread se torna impossível ou o compilador desperdiça grandes oportunidades de otimização. Uma explicação está em ordem.
Considere o seguinte código:
class Gadget {
public:
void Wait() {
while (!flag_) {
Sleep(1000);
}
}
void Wakeup() {
flag_ = true;
}
...
private:
bool flag_;
};
O objetivo de Gadget :: Wait acima é verificar a variável flag_ member a cada segundo e retornar quando essa variável foi definida como true por outro thread. Pelo menos é isso que seu programador pretendia, mas, infelizmente, Wait está incorreto.
Suponha que o compilador descubra que Sleep (1000) é uma chamada para uma biblioteca externa que não pode modificar a variável de membro flag_. Em seguida, o compilador conclui que pode armazenar flag_ em um registro e usar esse registro em vez de acessar a memória on-board mais lenta. Esta é uma excelente otimização para código de thread único, mas, neste caso, prejudica a correção: depois de chamar Wait for algum objeto Gadget, embora outro thread chame Wakeup, Wait fará um loop para sempre. Isso ocorre porque a mudança de flag_ não será refletida no registro que armazena flag_. A otimização é muito ... otimista.
Armazenar variáveis em cache em registradores é uma otimização muito valiosa que se aplica na maior parte do tempo, então seria uma pena desperdiçá-la. C e C ++ oferecem a oportunidade de desabilitar explicitamente esse armazenamento em cache. Se você usar o modificador volátil em uma variável, o compilador não armazenará em cache essa variável em registradores - cada acesso atingirá a localização real da memória dessa variável. Portanto, tudo o que você precisa fazer para que a combinação de espera / despertar do gadget funcione é qualificar flag_ apropriadamente:
class Gadget {
public:
... as above ...
private:
volatile bool flag_;
};
A maioria das explicações sobre a lógica e o uso de volatile param aqui e aconselham você a qualificar de forma volátil os tipos primitivos que você usa em vários threads. No entanto, há muito mais que você pode fazer com o volatile, porque ele faz parte do maravilhoso sistema de tipos do C ++.
Usando volátil com tipos definidos pelo usuário
Você pode qualificar por volatilidade não apenas os tipos primitivos, mas também os tipos definidos pelo usuário. Nesse caso, volatile modifica o tipo de maneira semelhante a const. (Você também pode aplicar const e volatile ao mesmo tipo simultaneamente.)
Ao contrário de const, volatile discrimina entre tipos primitivos e tipos definidos pelo usuário. Ou seja, ao contrário das classes, os tipos primitivos ainda suportam todas as suas operações (adição, multiplicação, atribuição, etc.) quando qualificados por volatilidade. Por exemplo, você pode atribuir um int não volátil a um int volátil, mas não pode atribuir um objeto não volátil a um objeto volátil.
Vamos ilustrar como funciona o volatile em tipos definidos pelo usuário em um exemplo.
class Gadget {
public:
void Foo() volatile;
void Bar();
...
private:
String name_;
int state_;
};
...
Gadget regularGadget;
volatile Gadget volatileGadget;
Se você acha que volátil não é tão útil com objetos, prepare-se para alguma surpresa.
volatileGadget.Foo();
regularGadget.Foo();
volatileGadget.Bar();
A conversão de um tipo não qualificado em sua contraparte volátil é trivial. No entanto, assim como com const, você não pode fazer a viagem de volta de volátil para não qualificado. Você deve usar um elenco:
Gadget& ref = const_cast<Gadget&>(volatileGadget);
ref.Bar();
Uma classe qualificada por volátil dá acesso apenas a um subconjunto de sua interface, um subconjunto que está sob o controle do implementador da classe. Os usuários podem obter acesso total à interface desse tipo apenas usando um const_cast. Além disso, assim como a constância, a volatilidade se propaga da classe para seus membros (por exemplo, volatileGadget.name_ e volatileGadget.state_ são variáveis voláteis).
volátil, seções críticas e condições de corrida
O dispositivo de sincronização mais simples e usado com mais frequência em programas multithread é o mutex. Um mutex expõe as primitivas Acquire e Release. Depois de chamar Acquire em algum encadeamento, qualquer outro encadeamento chamando Acquire será bloqueado. Posteriormente, quando esse thread chamar Release, exatamente um thread bloqueado em uma chamada Acquire será liberado. Em outras palavras, para um determinado mutex, apenas um thread pode obter o tempo do processador entre uma chamada para Acquire e uma chamada para Release. O código de execução entre uma chamada para Acquire e uma chamada para Release é chamado de seção crítica. (A terminologia do Windows é um pouco confusa porque chama o mutex de seção crítica, enquanto "mutex" é, na verdade, um mutex entre processos. Seria bom se eles fossem chamados de mutex de thread e mutex de processo.)
Mutexes são usados para proteger dados contra condições de corrida. Por definição, uma condição de corrida ocorre quando o efeito de mais threads nos dados depende de como os threads estão agendados. As condições da corrida aparecem quando dois ou mais tópicos competem para usar os mesmos dados. Como os threads podem interromper uns aos outros em momentos arbitrários no tempo, os dados podem ser corrompidos ou mal interpretados. Conseqüentemente, as alterações e, às vezes, os acessos aos dados devem ser protegidos cuidadosamente com seções críticas. Na programação orientada a objetos, isso geralmente significa que você armazena um mutex em uma classe como uma variável de membro e o usa sempre que acessar o estado dessa classe.
Programadores experientes em multithread podem ter bocejado ao ler os dois parágrafos acima, mas seu propósito é fornecer um treino intelectual, porque agora faremos o link com a conexão volátil. Fazemos isso traçando um paralelo entre o mundo dos tipos C ++ e o mundo da semântica de threading.
- Fora de uma seção crítica, qualquer thread pode interromper qualquer outro a qualquer momento; não há controle, portanto, as variáveis acessíveis a partir de vários threads são voláteis. Isso está de acordo com a intenção original de volatile - impedir que o compilador armazene inadvertidamente valores usados por vários threads de uma vez.
- Dentro de uma seção crítica definida por um mutex, apenas um thread tem acesso. Consequentemente, dentro de uma seção crítica, o código em execução tem semântica de thread único. A variável controlada não é mais volátil - você pode remover o qualificador volátil.
Em suma, os dados compartilhados entre os threads são conceitualmente voláteis fora de uma seção crítica e não voláteis dentro de uma seção crítica.
Você entra em uma seção crítica bloqueando um mutex. Você remove o qualificador volátil de um tipo aplicando um const_cast. Se conseguirmos colocar essas duas operações juntas, criamos uma conexão entre o sistema de tipos do C ++ e a semântica de threading de um aplicativo. Podemos fazer o compilador verificar as condições de corrida para nós.
LockingPtr
Precisamos de uma ferramenta que coleta uma aquisição mutex e um const_cast. Vamos desenvolver um template de classe LockingPtr que você inicializa com um objeto volátil obj e um mutex mtx. Durante sua vida útil, um LockingPtr mantém o mtx adquirido. Além disso, LockingPtr oferece acesso ao objeto volatile-stripped. O acesso é oferecido na forma de ponteiro inteligente, por meio de operador-> e operador *. O const_cast é executado dentro de LockingPtr. O elenco é semanticamente válido porque LockingPtr mantém o mutex adquirido por toda a sua vida.
Primeiro, vamos definir o esqueleto de uma classe Mutex com a qual LockingPtr funcionará:
class Mutex {
public:
void Acquire();
void Release();
...
};
Para usar LockingPtr, você implementa Mutex usando as estruturas de dados nativas e funções primitivas do seu sistema operacional.
LockingPtr é modelado com o tipo da variável controlada. Por exemplo, se você deseja controlar um widget, use um LockingPtr que inicializa com uma variável do tipo widget volátil.
A definição de LockingPtr é muito simples. LockingPtr implementa um ponteiro inteligente não sofisticado. Ele se concentra exclusivamente na coleta de um const_cast e uma seção crítica.
template <typename T>
class LockingPtr {
public:
LockingPtr(volatile T& obj, Mutex& mtx)
: pObj_(const_cast<T*>(&obj)), pMtx_(&mtx) {
mtx.Lock();
}
~LockingPtr() {
pMtx_->Unlock();
}
T& operator*() {
return *pObj_;
}
T* operator->() {
return pObj_;
}
private:
T* pObj_;
Mutex* pMtx_;
LockingPtr(const LockingPtr&);
LockingPtr& operator=(const LockingPtr&);
};
Apesar de sua simplicidade, LockingPtr é uma ajuda muito útil na escrita de código multithread correto. Você deve definir objetos que são compartilhados entre threads como voláteis e nunca usar const_cast com eles - sempre use objetos automáticos LockingPtr. Vamos ilustrar isso com um exemplo.
Digamos que você tenha dois threads que compartilham um objeto vetorial:
class SyncBuf {
public:
void Thread1();
void Thread2();
private:
typedef vector<char> BufT;
volatile BufT buffer_;
Mutex mtx_;
};
Dentro de uma função de thread, você simplesmente usa um LockingPtr para obter acesso controlado à variável de membro buffer_:
void SyncBuf::Thread1() {
LockingPtr<BufT> lpBuf(buffer_, mtx_);
BufT::iterator i = lpBuf->begin();
for (; i != lpBuf->end(); ++i) {
... use *i ...
}
}
O código é muito fácil de escrever e entender - sempre que você precisar usar buffer_, você deve criar um LockingPtr apontando para ele. Depois de fazer isso, você terá acesso a toda a interface do vetor.
A parte boa é que se você cometer um erro, o compilador irá apontá-lo:
void SyncBuf::Thread2() {
BufT::iterator i = buffer_.begin();
for ( ; i != lpBuf->end(); ++i ) {
... use *i ...
}
}
Você não pode acessar nenhuma função de buffer_ até que você aplique um const_cast ou use LockingPtr. A diferença é que LockingPtr oferece uma maneira ordenada de aplicar const_cast a variáveis voláteis.
LockingPtr é extremamente expressivo. Se você só precisa chamar uma função, pode criar um objeto LockingPtr temporário sem nome e usá-lo diretamente:
unsigned int SyncBuf::Size() {
return LockingPtr<BufT>(buffer_, mtx_)->size();
}
Voltar para tipos primitivos
Vimos como a volatilidade protege objetos contra o acesso não controlado e como o LockingPtr fornece uma maneira simples e eficaz de escrever código thread-safe. Vamos agora retornar aos tipos primitivos, que são tratados de forma diferente por voláteis.
Vamos considerar um exemplo em que vários threads compartilham uma variável do tipo int.
class Counter {
public:
...
void Increment() { ++ctr_; }
void Decrement() { —ctr_; }
private:
int ctr_;
};
Se Increment e Decrement forem chamados de threads diferentes, o fragmento acima está cheio de erros. Primeiro, ctr_ deve ser volátil. Em segundo lugar, mesmo uma operação aparentemente atômica como ++ ctr_ é na verdade uma operação de três estágios. A memória em si não tem recursos aritméticos. Ao incrementar uma variável, o processador:
- Lê essa variável em um registro
- Incrementa o valor no registro
- Grava o resultado de volta na memória
Esta operação de três etapas é chamada de RMW (Read-Modify-Write). Durante a parte Modify de uma operação RMW, a maioria dos processadores libera o barramento de memória para permitir que outros processadores acessem a memória.
Se naquele momento outro processador realizar uma operação RMW na mesma variável, temos uma condição de corrida: a segunda gravação sobrescreve o efeito da primeira.
Para evitar isso, você pode confiar, novamente, no LockingPtr:
class Counter {
public:
...
void Increment() { ++*LockingPtr<int>(ctr_, mtx_); }
void Decrement() { —*LockingPtr<int>(ctr_, mtx_); }
private:
volatile int ctr_;
Mutex mtx_;
};
Agora o código está correto, mas sua qualidade é inferior quando comparado ao código do SyncBuf. Por quê? Porque com Counter, o compilador não irá avisá-lo se você acessar ctr_ por engano diretamente (sem bloqueá-lo). O compilador compila ++ ctr_ se ctr_ for volátil, embora o código gerado seja simplesmente incorreto. O compilador não é mais seu aliado, e apenas sua atenção pode ajudá-lo a evitar condições de corrida.
O que você deve fazer então? Simplesmente encapsule os dados primitivos que você usa em estruturas de nível superior e use voláteis com essas estruturas. Paradoxalmente, é pior usar volatile diretamente com os embutidos, apesar do fato de que inicialmente essa era a intenção de uso de volatile!
Funções de membro voláteis
Até agora, tivemos classes que agregam membros de dados voláteis; agora, vamos pensar em projetar classes que, por sua vez, farão parte de objetos maiores e serão compartilhados entre threads. É aqui que as funções de membro voláteis podem ser de grande ajuda.
Ao projetar sua classe, você qualifica de forma volátil apenas as funções de membro que são thread-safe. Você deve presumir que o código externo chamará as funções voláteis de qualquer código a qualquer momento. Não se esqueça: volátil é igual a código multithreaded grátis e nenhuma seção crítica; não volátil é igual a cenário de thread único ou dentro de uma seção crítica.
Por exemplo, você define uma classe Widget que implementa uma operação em duas variantes - uma thread-safe e outra rápida e desprotegida.
class Widget {
public:
void Operation() volatile;
void Operation();
...
private:
Mutex mtx_;
};
Observe o uso de sobrecarga. Agora o usuário de Widget pode invocar Operation usando uma sintaxe uniforme para objetos voláteis e obter segurança de thread ou para objetos regulares e obter velocidade. O usuário deve ter cuidado ao definir os objetos Widget compartilhados como voláteis.
Ao implementar uma função de membro volátil, a primeira operação geralmente é bloqueá-la com um LockingPtr. Em seguida, o trabalho é feito usando o irmão não volátil:
void Widget::Operation() volatile {
LockingPtr<Widget> lpThis(*this, mtx_);
lpThis->Operation();
}
Resumo
Ao escrever programas multithread, você pode usar o volátil a seu favor. Você deve seguir as seguintes regras:
- Defina todos os objetos compartilhados como voláteis.
- Não use volátil diretamente com tipos primitivos.
- Ao definir classes compartilhadas, use funções de membro volátil para expressar segurança de encadeamento.
Se você fizer isso e usar o componente genérico simples LockingPtr, poderá escrever código thread-safe e se preocupar muito menos com as condições de corrida, porque o compilador se preocupará com você e apontará diligentemente os pontos em que você está errado.
Alguns projetos em que estive envolvido usam volatile e LockingPtr com grande efeito. O código é limpo e compreensível. Lembro-me de alguns deadlocks, mas prefiro deadlocks a condições de corrida porque são muito mais fáceis de depurar. Praticamente não houve problemas relacionados às condições de corrida. Mas então você nunca sabe.
Reconhecimentos
Muito obrigado a James Kanze e Sorin Jianu que ajudaram com ideias perspicazes.
Andrei Alexandrescu é gerente de desenvolvimento da RealNetworks Inc. (www.realnetworks.com), com sede em Seattle, WA, e autor do livro aclamado Modern C ++ Design. Ele pode ser contatado em www.moderncppdesign.com. Andrei também é um dos instrutores destacados do Seminário C ++ (www.gotw.ca/cpp_seminar).
Este artigo pode estar um pouco desatualizado, mas dá uma boa ideia para um excelente uso do uso do modificador volátil com no uso de programação multithread para ajudar a manter os eventos assíncronos enquanto o compilador verifica as condições de corrida para nós. Isso pode não responder diretamente à pergunta original do OP sobre a criação de um limite de memória, mas escolho postar isso como uma resposta para os outros, como uma excelente referência para um bom uso de volátil ao trabalhar com aplicativos multithread.