Este artigo foi traduzido por máquina.

C++

Novos recursos de simultaneidade no Visual C++ 11

Diego Dagum

Baixar o exemplo de código

A mais recente iteração de C++, conhecida como C + + 11 e aprovadas pela organização internacional para padronização (ISO) no ano passado, formaliza um novo conjunto de bibliotecas e algumas palavras reservadas para lidar com simultaneidade. Muitos desenvolvedores têm usado simultaneidade em C++ antes, mas sempre por meio de uma biblioteca de terceiros — muitas vezes directamente expondo OS APIs.

Herb Sutter anunciou em Dezembro de 2004 que o almoço de desempenho "livre" foi mais no sentido de que os fabricantes de CPU foram impedidos de CPUs mais rápidas de envio pelo consumo de energia física e aumentando a razões de calor. Isto levou a atual, principais núcleos era, uma nova realidade que c++ — um padrão — apenas deu um salto importante adaptar.

O restante deste artigo está organizado em duas seções principais e menores subseções. A primeira seção principal, começando com a execução em paralelo, aborda tecnologias que permitem que aplicativos sejam executados inde­atividades pendentes ou semi-independente em paralelo. A segunda seção principal, começando com sincronização até execução simultânea, explora os mecanismos de sincronização a forma como essas atividades manipulam dados, evitando assim as condições de corrida.

Este artigo baseia-se em funcionalidades incluídas na próxima versão do Visual C++ (por agora, chamado Visual C++ 11). Alguns deles já estão disponíveis na versão atual, o Visual C++ 2010. Embora não um guia para modelar algoritmos paralelos, nem uma documentação exaustiva sobre todas as opções disponíveis, este artigo é uma introdução sólida à nova C + + 11 características de simultaneidade.

Execução paralela

Quando você modelar processos e algoritmos de concepção sobre os dados, há uma tendência natural para especificá-los em uma seqüência de etapas. Enquanto desempenho está dentro dos limites aceitáveis, este é o esquema mais recomendável porque é geralmente mais fácil de entender — um requisito para bases de código passível de manutenção.

Quando o desempenho se torna um fator preocupante, uma tentativa inicial clássica para ultrapassar a situação é otimizar o algoritmo seqüencial para reduzir os ciclos de CPU consumidos. Isso pode ser feito até chegar a um ponto onde não otimizações adicionais estão disponíveis — ou eles são difíceis de alcançar. Então chegou a hora de dividir a série seqüencial de etapas em atividades de ocorrência simultânea.

Na primeira seção, você aprenderá sobre o seguinte:

  • Tarefas assíncronas: essas partes menores do algoritmo original relacionado apenas pelos dados eles produzem ou consomem.
  • Tópicos: unidades de execução administrado pelo ambiente de tempo de execução. Dizem respeito às tarefas no sentido de que as tarefas são executadas em segmentos.
  • Thread internals: thread-ligado variáveis, exceções propagadas de threads e assim por diante.

Tarefas assíncronas

No código correspondente a este artigo, você encontrará um projeto chamado caso seqüencial, como mostrado na Figura 1.

Figura 1 código processo seqüencial

int a, b, c;
int calculateA()
{
  return a+a*b;
}
int calculateB()
{
  return a*(a+a*(a+1));
}
int calculateC()
{
  return b*(b+1)-b;
}
int main(int argc, char *argv[])
{
  getUserData(); // initializes a and b
  c = calculateA() * (calculateB() + calculateC());
  showResult();
}

A principal função solicita que o usuário para alguns dados e, em seguida, envia esses dados para três funções: calculateA, calculateB e calculateC. Mais tarde, os resultados são combinados para produzir algumas informações de saída para o usuário.

As funções de cálculo no material complementar são codificadas de forma tal que um atraso aleatório entre um e três segundos é introduzido em cada um. Considerando que essas etapas são executadas seqüencialmente, isto leva a um tempo de execução geral — após a introdução de dados de entrada — de nove segundos no pior cenário. Você pode experimentar esse código pressionando F5 e executando o exemplo.

Então eu preciso revisar a seqüência de execução e localizar etapas a serem executadas simultaneamente. Como essas funções são independentes, eu pode executá-los em paralelo, usando a função async:

int main (int argc, char *argv[])

{

getUserData();

futuro <int> F1 = async(calculateB), f2 = async(calculateC);

c = (calculateA() + f1.get()) * f2.get();

showResult();

}

Eu tenho introduziu dois conceitos aqui: Async e futuro, ambos definidos no <future> cabeçalho e o namespace std. O primeiro recebe uma função, um lambda ou um objeto de função (functor) e retorna um futuro. Você pode entender o conceito de um futuro como o espaço reservado para um eventual resultado. Qual resultado? Um retornado pela função chamado de forma assíncrona.

Em algum momento, vou precisar os resultados dessas funções de execução paralela. Chamar o método get em cada futura blocos a execução até que o valor esteja disponível.

Você pode testar e comparar o código revisado com caso seqüencial, executando o projeto de AsyncTasks na amostra complementar. O atraso pior caso desta modificação é cerca de três segundos versus nove segundos para que a versão seqüencial.

Este é um modelo de programação leve que libera o desenvolvedor do direito de criação de segmentos. No entanto, você pode especificar políticas de segmentação, mas eu não vai cobrir aqueles aqui.

Tópicos

O modelo de tarefa assíncrona apresentado na seção anterior pode ser suficiente em alguns cenários de determinado, mas se você precisar de uma mais profunda manipulação e controle da execução de threads, C + + 11 vem com a classe de thread, declarada em <thread> cabeçalho e localizadas no namespace std.

Apesar de ser um modelo de programação mais complexo, segmentos oferecem melhores métodos para sincronização e coordenação, permitindo-lhes a ceder a execução para outro thread e esperar por um determinado período de tempo ou até que outro thread seja concluído antes de continuar.

No exemplo a seguir (disponível no projeto de segmentos de código complementar), eu tenho uma função lambda, que, dado um número inteiro argumento, imprime seus múltiplos de menos de 100.000 para o console:

auto multiple_finder = [] (int n) {

for (int i = 0; Eu < 100000; i + +)

se (i % n = = 0)

Cout << Eu << " é um múltiplo de" << n << Endl;

};

int main (int argc, char *argv[])

{

thread th (multiple_finder, 23456);

multiple_finder(34567);

th.Join();

}

Como você verá nos exemplos posteriores, o fato de que eu passei um lambda para o thread é circunstancial; um function ou functor iria ter bastado também.

A função main eu executar essa função em dois segmentos com parâmetros diferentes. Dê uma olhada no meu resultado (que pode variar entre diferentes runs devido a intervalos):

0 is a multiple of 23456
0 is a multiple of 34567
23456 is a multiple of 23456
34567 is a multiple of 34567
46912 is a multiple of 23456
69134 is a multiple of 34567
70368 is a multiple of 23456
93824 is a multiple of 23456

Pode implementar o exemplo sobre tarefas assíncronas na seção anterior com threads. Para isso, eu preciso introduzir o conceito de uma promessa. Uma promessa pode ser entendida como um coletor através do qual um resultado será Descartado quando disponível. Onde esse resultado sairá uma vez caiu? Cada promessa tem um futuro associado.

O código mostrado na Figura 2, disponível no projeto de código de exemplo, sócios, três segmentos (em vez de tarefas) com promete e faz com que cada thread chamar uma função de calcular promessas. Compare esses detalhes com a versão mais leve do AsyncTasks.

Figura 2 associando futuros com promessas

typedef int (*calculate)(void);
void func2promise(calculate f, promise<int> &p)
{
  p.set_value(f());
}
int main(int argc, char *argv[])
{
  getUserData();
  promise<int> p1, p2;
  future<int> f1 = p1.get_future(), f2 = p2.get_future();
  thread t1(&func2promise, calculateB, std::ref(p1)),
    t2(&func2promise, calculateC, std::ref(p2));
  c = (calculateA() + f1.get()) * f2.get();
  t1.join(); t2.join();
  showResult();
}

Thread-ligado variáveis e exceções

No C++, você pode definir variáveis globais cujo escopo é vinculado ao aplicativo inteiro, incluindo segmentos. Mas relativo para segmentos, agora há uma maneira de definir essas variáveis global que cada thread mantém sua própria cópia. Este conceito é conhecido como armazenamento local de thread e é declarada da seguinte forma:

thread_local int subtotal = 0;

Se a declaração é feita no escopo de uma função, a visibilidade da variável irá ser restringida para que função mas cada thread irá manter mantém sua própria cópia estática. Ou seja, os valores da variável por segmento estão sendo mantidos entre chamadas de função.

Embora thread_local não está disponível no Visual C++ 11, pode ser simulada com uma extensão não-padrão da Microsoft:

#define  thread_local __declspec(thread)

O que aconteceria se uma exceção foi lançada dentro de um thread? Haverá casos em que a exceção pode ser capturada e manipulada na pilha de chamadas dentro do thread. Mas se o thread não lidar com a exceção, é necessário uma maneira de transportar a exceção para o segmento de iniciador. C + + 11 introduz tais mecanismos.

Em Figura 3, disponível no código correspondente no projeto ThreadInternals, existe uma função sum_until_element_with_threshold, que percorre um vetor até encontrar um elemento específico, somando todos os elementos ao longo do caminho. Se a soma exceder um limite, uma exceção é lançada.

Figura 3 Thread Local armazenamento e exceções de Thread

thread_local unsigned sum_total = 0;
void sum_until_element_with_threshold(unsigned element,
  unsigned threshold, exception_ptr& pExc)
{
  try{
    find_if_not(begin(v), end(v), [=](const unsigned i) -> bool {
      bool ret = (i!=element);
      sum_total+= i;
      if (sum_total>threshold)
        throw runtime_error("Sum exceeded threshold.");
      return ret;
    });
    cout << "(Thread #" << this_thread::get_id() << ") " <<
      "Sum of elements until " << element << " is found: " << sum_total << endl;
  } catch (...) {
    pExc = current_exception();
  }
}

Se isso acontecer, a exceção é capturada por meio de current_exception em um exception_ptr.

A função principal aciona um thread em sum_until_element_with_threshold, ao chamar essa mesma função com um parâmetro diferente. Quando tem terminado de ambos invocações (aquele no thread principal) e outra no segmento disparado partir dele, serão analisados seus respectivos exception_ptrs:

const unsigned THRESHOLD = 100000;
vector<unsigned> v;
int main(int argc, char *argv[])
{
  exception_ptr pExc1, pExc2;
  scramble_vector(1000);
  thread th(sum_until_element_with_threshold, 0, THRESHOLD, ref(pExc1));
  sum_until_element_with_threshold(100, THRESHOLD, ref(pExc2));
  th.join();
  dealWithExceptionIfAny(pExc1);
  dealWithExceptionIfAny(pExc2);
}

Se qualquer um destes exception_ptrs vêm inicializado — um sinal de que alguma exceção aconteceu — suas exceções são acionadas volta com rethrow_exception:

void dealWithExceptionIfAny(exception_ptr pExc)
{
  try
  {
    if (!(pExc==exception_ptr()))
      rethrow_exception(pExc);
    } catch (const exception& exc) {
      cout << "(Main thread) Exception received from thread: " <<
        exc.what() << endl;
  }
}

Este é o resultado de nossa execução, como a soma nos fios segundo excedeu seu limite:

(Thread #10164) Sum of elements until 0 is found: 94574
(Main thread) Exception received from thread: Sum exceeded threshold.

Sincronização de execução simultânea

Seria desejável que todos os aplicativos poderiam ser divididos em um conjunto independente de 100 por cento de tarefas assíncronas. Na prática isso quase nunca é possível, pois há pelo menos dependências nos dados que todas as partes simultaneamente manipular. Esta seção apresenta nova C + + 11 tecnologias para evitar condições de corrida.

Você aprenderá sobre:

  • Tipos atômicos: semelhante a tipos de dados primitivos, mas possibilitando modificação de thread-safe.
  • Semáforos e bloqueios: elementos que nos permitem definir regiões críticas de thread-safe.
  • Variáveis de condição: uma maneira de congelar threads de execução até algum critério é satisfeita.

Tipos atômicos

<atomic> cabeçalho introduz uma série de tipos primitivos — atomic_char, atomic_int e assim por diante — implementada em termos de operações interligadas. Assim, esses tipos são equivalentes aos seus homônimos sem o prefixo de atomic_, mas com a diferença que todos os seus operadores de atribuição (= =, + +, --, + =, * = e assim por diante) estão protegidos contra condições de corrida. Por isso não vai acontecer que no meio de uma atribuição para esses tipos de dados, outro segmento irrupts e altera valores antes que nós somos feitos.

No exemplo a seguir lá são dois segmentos paralelos (sendo um principal) procurando por elementos diferentes dentro do mesmo vetor:

atomic_uint total_iterations;
vector<unsigned> v;
int main(int argc, char *argv[])
{
  total_iterations = 0;
  scramble_vector(1000);
  thread th(find_element, 0);
  find_element(100);
  th.join();
  cout << total_iterations << " total iterations." << endl;
 }

Quando cada elemento é encontrado, é impressa uma mensagem de dentro do thread, informando a posição no vetor (ou iteração) onde o elemento foi encontrado:

void find_element(unsigned element)
{
  unsigned iterations = 0;
  find_if(begin(v), end(v), [=, &iterations](const unsigned i) -> bool {
    ++iterations;
    return (i==element);
  });
  total_iterations+= iterations;
  cout << "Thread #" << this_thread::get_id() << ": found after " <<
    iterations << " iterations." << endl;
}

Há também uma variável comum, total_iterations, que é atualizado com o número composto das iterações aplicadas por ambos os segmentos. Assim, total_iterations deve ser atômica para impedir que ambos segmentos de atualizá-lo ao mesmo tempo. No exemplo anterior, mesmo que você não precisa imprimir o número parcial de iterações em find_element, você teria ainda acumula iterações em que variável local em vez de total_iterations, para evitar a disputa sobre a variável atômica.

Você vai encontrar o exemplo anterior no projeto Atomics no download de código complementar. Eu corri-lo, obtendo o seguinte:

Thread #8064: found after 168 iterations.
Thread #6948: found after 395 iterations.
563 total iterations.

Bloqueios e Mut(ual) Ex(clusion)

Seção anterior retratado um caso particular de exclusão mútua para acesso de escrita em tipos primitivos. <mutex> cabeçalho define uma série de classes bloqueáveis para definir regiões críticas. Dessa forma, você pode definir um mutex para estabelecer uma região crítica em toda uma série de funções ou métodos, na medida em que apenas um thread por vez será capaz de acessar qualquer membro desta série travando com êxito sua mutex.

Um thread tentar bloquear um mutex pode permanecer bloqueado até que o mutex esteja disponível ou apenas falhar na tentativa. No meio destes dois extremos, a classe timed_mutex alternativa pode ficar bloqueada por um pequeno intervalo de tempo antes de falhar. Que permite bloquear as tentativas de desist ajuda a evitar deadlocks.

Um mutex bloqueado deve ser explicitamente desbloqueado para que outros possam bloqueá-lo. Isso não for feito pode levar a um comportamento não determinado aplicativo — que poderia ser sujeito a erros, similar ao esquecimento liberar memória dinâmica. Esquecendo-se de liberar um bloqueio é realmente muito pior, porque pode significar que o aplicativo não pode funcionar corretamente mais se outro código mantém aguardando esse bloqueio. Felizmente, C + + 11 também vem com bloqueio de classes. Um bloqueio atua em um mutex, mas seu destruidor certifica-se de liberá-lo se bloqueado.

O código a seguir (disponível no projeto do Mutex no download de código) define uma região crítica em torno de um mx de mutex:

mutex mx;
void funcA();
void funcB();
int main()
{
  thread th(funcA)
  funcB();
  th.join();
}

Este mutex é usado para garantir que duas funções, funcA e funcB, pode executar em paralelo sem unirem-se na região crítica.

A função funcA aguardará, se necessário, para vir para a região crítica. Para fazer isso, você só precisa o mecanismo de bloqueio mais simples — lock_guard:

void funcA()
{
  for (int i = 0; i<3; ++i)
  {
    this_thread::sleep_for(chrono::seconds(1));
    cout << this_thread::get_id() << ": locking with wait... "
<< endl;
    lock_guard<mutex> lg(mx);
    ...
// Do something in the critical region.
cout << this_thread::get_id() << ": releasing lock." << endl;
  }
}

O caminho que é definido, funcA deve acessar a região crítica três vezes. FuncB função, em vez disso, vai tentar bloquear, mas se o mutex é pelo então já bloqueado, funcB irá apenas aguardar um segundo antes de tentar novamente obter acesso à região crítica. O mecanismo que ela usa é unique_lock com a try_to_lock_t política, conforme mostrado na Figura 4.

Figura 4 Lock com espera

void funcB()
{
  int successful_attempts = 0;
  for (int i = 0; i<5; ++i)
  {
    unique_lock<mutex> ul(mx, try_to_lock_t());
    if (ul)
    {
      ++successful_attempts;
      cout << this_thread::get_id() << ": lock attempt successful." <<
        endl;
      ...
// Do something in the critical region
      cout << this_thread::get_id() << ": releasing lock." << endl;
    } else {
      cout << this_thread::get_id() <<
        ": lock attempt unsuccessful.
Hibernating..." << endl;
      this_thread::sleep_for(chrono::seconds(1));
    }
  }
  cout << this_thread::get_id() << ": " << successful_attempts
    << " successful attempts." << endl;
}

O caminho que é definido, funcB tentará até cinco vezes entrar na região crítica. Figura 5 mostra o resultado da execução. De cinco tentativas, funcB só poderia vir para a região crítica duas vezes.

Figura 5 o Mutex de projeto de exemplo em execução

funcB: lock attempt successful.
funcA: locking with wait ...
funcB: releasing lock.
funcA: lock secured ...
funcB: lock attempt unsuccessful.
Hibernating ...
funcA: releasing lock.
funcB: lock attempt successful.
funcA: locking with wait ...
funcB: releasing lock.
funcA: lock secured ...
funcB: lock attempt unsuccessful.
Hibernating ...
funcB: lock attempt unsuccessful.
Hibernating ...
funcA: releasing lock.
funcB: 2 successful attempts.
funcA: locking with wait ...
funcA: lock secured ...
funcA: releasing lock.

Variáveis de condição

O cabeçalho de <condition_variable> vem com a facilidade de última abordada neste artigo, fundamental para aqueles casos quando coordenação entre threads está ligada a eventos.

No exemplo a seguir, disponíveis no projeto CondVar no download do código, uma função de produtor empurra elementos em uma fila:

mutex mq;
condition_variable cv;
queue<int> q;
void producer()
{
  for (int i = 0;i<3;++i)
  {
    ...
// Produce element
    cout << "Producer: element " << i << " queued." << endl;
    mq.lock();      q.push(i);  mq.unlock();
    cv.
notify_all();
  }
}

A fila padrão não é thread-safe, assim que certifique-se de que ninguém o está usando (ou seja, o consumidor não está estalando qualquer elemento) quando enfileiramento de mensagens.

A função de consumidor tentará buscar elementos da fila quando disponível, ou ele apenas aguarda um tempo variável de condição antes de tentar novamente; Depois de duas consecutivas tentativas falharam, o consumidor termina (ver Figura 6).

Figura 6 acordando Threads através de variáveis condicionais

void consumer()
{
  unique_lock<mutex> l(m);
  int failed_attempts = 0;
  while (true)
  {
    mq.lock();
    if (q.size())
    {
      int elem = q.front();
      q.pop();
      mq.unlock();
      failed_attempts = 0;
      cout << "Consumer: fetching " << elem << " from queue." << endl;
      ...
// Consume elem
    } else {
      mq.unlock();
      if (++failed_attempts>1)
      {
        cout << "Consumer: too many failed attempts -> Exiting." << endl;
        break;
      } else {
        cout << "Consumer: queue not ready -> going to sleep." << endl;
        cv.wait_for(l, chrono::seconds(5));
      }
    }
  }
}

O consumidor deve ser despertado através de notify_all pelo produtor sempre que um novo elemento está disponível. Dessa forma, o produtor evita que o sono dos consumidores para o intervalo inteiro se elementos estão prontos.

Figura 7 mostra o resultado da minha execução.

Figura 7 sincronização com as variáveis de condição

Consumer: queue not ready -> going to sleep.
Producer: element 0 queued.
Consumer: fetching 0 from queue.
Consumer: queue not ready -> going to sleep.
Producer: element 1 queued.
Consumer: fetching 1 from queue.
Consumer: queue not ready -> going to sleep.
Producer: element 2 queued.
Producer: element 3 queued.
Consumer: fetching 2 from queue.
Producer: element 4 queued.
Consumer: fetching 3 from queue.
Consumer: fetching 4 from queue.
Consumer: queue not ready -> going to sleep.
Consumer: two consecutive failed attempts -> Exiting.

Uma visão holística

Para recapitular, este artigo mostrou um panorama conceitual dos mecanismos instituídos em C + + 11 para permitir a execução paralela em uma era onde a ambientes com vários núcleos são mainstream.

Tarefas assíncronas permitem que um modelo de programação leve paralelizar a execução. Os resultados de cada tarefa podem ser recuperados através de um futuro associado.

Segmentos oferecem maior granularidade do que tarefas — embora eles são mais pesados — juntos com mecanismos para manter separados cópias de Estático variáveis e transporte exceções entre threads.

Como threads paralelos agir sobre dados comuns, C + + 11 fornece recursos para evitar condições de corrida. Tipos atômicos permitem uma maneira confiável garantir que os dados são modificados por um thread por vez.

Exclusões mútuas nos ajudam a definir regiões críticas em todo o código — acessem simultâneo às regiões a que segmentos são impedidos. Bloqueios envoltório mutexes, amarrando o desbloqueio deste último para o ciclo de vida do antigo.

Finalmente, as variáveis de condição concedem mais eficiência para sincronização de thread, como alguns segmentos pode aguardar eventos notificados por outros threads.

Este artigo ainda não abrangidas todas as muitas maneiras de configurar e usar cada um desses recursos, mas o leitor agora tem uma visão holística dos mesmos e está pronto para cavar mais fundo.

Diego Dagum é um desenvolvedor de software com mais de 20 anos de experiência. Ele atualmente é gerente de programa de Comunidade do Visual C++ com a Microsoft.

Agradecemos aos seguintes especialistas técnicos pela revisão deste artigo: David Cravey, Alon Fliess, Fabio Galuppo e Marc Gregoire