Utilizando tarefas ao invés de threads

Resumo

As tarefas são alternativas mais leves às threads que proporcionam tempos de inicialização e encerramento mais rápidos, melhor distribuição de carga, utilização eficiente dos recursos disponíveis e um nível mais elevado de abstração. Intel® Threading Building Blocks (Intel® TBB) e OpenMP* são modelos de programação que baseados em tarefas . Este artigo apresenta uma visão geral da programação baseada em tarefas e algumas diretrizes importantes para decidir quando utilizar threads e quando utilizar tarefas.

Este artigo faz parte da série mais ampla,"Manual da Intel para o desenvolvimento de aplicativos com múltiplas threads", que oferece diretrizes para o desenvolvimento de aplicativos eficientes com múltiplas threads para plataformas Intel®.

Histórico

Programar diretamente com um pacote nativo de gestão de threads frequentemente representa uma má escolha para a programação com múltiplas threads. As threads criadas com esses pacotes são threads lógicas que são mapeadas pelo sistema operacional em threads físicas do hardware. A criação de poucas threads lógicas solicitará pouco o sistema, desperdiçando alguns dos recursos de hardware disponíveis. A criação excessiva de threads lógicas solicitará demasiadamente o sistema, fazendo com que o sistema operacional incorra em uma considerável sobrecarga por precisar compartilhar o tempo de acesso aos recursos de hardware. Utilizando threads nativas diretamente, o desenvolvedor se torna responsável por adequar o paralelismo disponível no aplictivo com os recursos disponíveis em hardware.

Uma maneira comum de realizar esse difícil equilíbrio é criar um pool de threads que são utilizadas durante o ciclo de vida de um aplicativo. Normalmente é criada uma thread lógica por thread física. O aplicativo então aloca dinamicamente as computações nas threads pertencentes ao pool de threads. A utilização de um pool de threads não só ajuda a adequar o paralelismo aos recursos de hardware, mas também evita a sobrecarga resultante ds repetidas criações e encerramentos de threads.

Alguns modelos de programação paralela, como Intel TBB e a API OpenMP oferecem aos desenvolvedores os benefícios de pools de threads sem o ônus de gerenciar explicitamente os pools. Utilizando esses modelos, os desenvolvedores expressam o paralelismo lógico em seus aplicativos com tarefas e a biblioteca de execução aloca essas tarefas em seu pool interno de threads de trabalho. Utilizando tarefas os desenvolvedores podem focar o paralelismo lógico em seu aplicativo sem se preocupar em gerenciá-lo. Além disso, como as tarefas são muito mais leves do que as threads, é possível expressar o paralelismo com maior granularidade.

Um exemplo simples da utilização de tarefas é mostrado abaixo. A função fibTBB calcula o número de Fibonacci utilizando a TBB task_group. Em cada chamada onde n >= 10, um grupo de tarefas é criado e duas tarefas são executadas. Nesse exemplo, uma expressão lambda (um recurso no padrão C++0x proposto) que descreve cada tarefa é passada para a função run. Essas chamadas distribuem as tarefas, tornando-as disponíveis para threads no pool de threads para execução. A chamada subsequente para a função wait fica bloqueada até que todas as tarefas do grupo de tarefas tenham sido executadas até o fim.

int fibTBB(int n) {
 if( n<10 ) {
 return fibSerial(n);
 } else {
 int x, y;
 tbb::task_group g;
 g.run([&]{x=Fib(n-1);}); // spawn a task
 g.run([&]{y=Fib(n-2);}); // spawn another task
 g.wait(); // wait for both tasks to complete
 return x+y;
 }
}

A rotina fibSerial é uma variante sequencial. Apesar de as tarefas permitirem maior granularidade no parelismo do que as threads, elas ainda têm uma sobrecarga significativa em comparação a uma chamada de subrotina. Portanto, geralmente vale à pena solucionar pequenos subproblemas de forma sequencial.

Outra biblioteca que suporta tarefas é a API OpenMP. Diferentemente da Intel TBB, ambos os modelos utilizam o suporte do compilador, tonrnando suas interfaces mais simples, mas menos portáveis. Por exemplo, o mesmo exemplo de Fibonacci mostrado acima com a utilização de tarefas TBB é implementado como fibOpenMP abaixo, utilizando tarefas OpenMP. Como a OpenMP requer suporte do compilador, pragmas mais simples podem ser utilizados para denotar tarefas. Entretanto, somente compiladores que suportam a API OpenMP interpretam esses pragmas.

int fibOpenMP( int n ) {
 int i, j;
 if( n < 10 ) {
 return fibSerial(n);
 } else {
 // spawn a task
 #pragma omp task shared( i ), untied
 i = fib( n - 1 ); 
 // spawn another task
 #pragma omp task shared( j ), untied
 j = fib( n - 2 );
 // wait for both tasks to complete
 #pragma omp taskwait
 return i + j;
 }
}

A Intel TBB e a API OpenMP gerenciam a programação de tarefas através de roubo de trabalho. No roubo de trabalho, cada thread no pool de threads mantém um pool local de tarefas que é organizado como uma lista duplamente ligada. A thread utiliza seu próprio pool de tarefas como uma pilha, inserindo as novas tarefas que ela distribui no topo dessa pilha. Quando uma thread termina de executar uma tarefa, ela primeiro tenta retirar a tarefa do topo de sua pilha local. A tarefa no topo da pilha é a mais recente e portanto a que tem maior probabilidade de acessar os dados que estão recentes em seu cache de dados. Se não houver tarefas em seu conjunto de tarefas local, entretanto, ela tenta roubar trabalho de outra thread (a vítima). Ao roubar o trabalho, a thread utiliza a lista duplamente ligada da vítima como uma fila, de forma a roubar a tarefa mais antiga da lista da vítima. Para algoritmos recursivos, essas tarefas mais antigas são nós que estão no alto da árvove de tarefas e assim são grandes blocos de trabalho, normalmente trabalhos que não são recentes no cache de dados da vítima. Dessa forma, o roubo de trabalho é um mecanismo eficaz para o balanceamento de carga, ao mesmo tempo que mantém a localidade do cache.

O pool de threads e o agendador do roubo de trabalho que distribui o trabalho pelas threads ficam transparentes para os desenvolvedores quando uma biblioteca de gestão de tarefas é utilizada. Desta forma, as tarefas proporcionam um alto nível de abstração que permite aos usuários pensar no paralelismo lógico em seus aplicativos sem se preocupar com o gerenciamento do paralelismo. A distribuição de carga proporcionada pelo roubo de trabalho e os baixos custos de criação e encerramento de tarefas tornam o paralelismo baseado em tarefas uma solução eficaz para a maioria dos aplicativos.

Diretrizes de utilização

Embora a utilização de tarefas seja normalmente a melhor abordagem para adicionar threading para melhorar o desempenho, há casos em que a utilização de tarefas não é apropriada. Os agendadores de tarefas utilizados pela Intel TBB e a API OpenMP são não-preemptivos. As tarefas são então destinadas a algoritmos de alto desempenho que não sofrem bloqueio. Eles ainda funcionam bem se as tarefas forem bloqueadas raramente. Entretanto, se as tarefas são bloqueadas frequentemente, há uma perda de desempenho porque quando a tarefa é bloqueada, a thread que lhe foi atribuída não pode trabalhar em nenhuma outra tarefa. O bloqueio normalmente acontece durante a espera por I/O ou mutexes por longos períodos. Se as threads mantiverem mutexes por longos períodos, o código provavelmente não terá um bom desempenho, independentemente de quantas threads tiver. Para tarefas que sofrem bloqueio, é melhor utilizar threads ao invés de tarefas.

Mesmo nos casos em que é melhor utilizar tarefas, às vezes não é necessário implementar o pattern de tarefas do zero. A biblioteca Intel TBB oferece não apenas uma interface para tarefas, mas também algoritmos de alto nível que implementam alguns dos mais patterns de tarefas mais comuns, como parallel_invoke, parallel_for, parallel_reduce e pipeline. A API OpenMP oferece loops paralelos. Como esses patterns foram ajustados e testados, é melhor utilizar esses algoritmos de alto nível sempre que for possível.
O exemplo abaixo mostra um loop serial simples e uma versão paralela do loop que utiliza o algoritmo tbb::parallel_for:

// serial loop
for (int i = 0; i < 10000; ++i)
 a[i] = f(i) + g(i);

// parallel loop
tbb::parallel_for( 0, 10000, [&](int i) { a[i] = f(i) + g(i); } );


No exemplo accima, a TBB parallel_for cria tarefas que aplicam o corpo do loop, neste caso a[i] = f(i) + g(i), para cada um dos elementos na faixa (0,10000). O & na expressão lambda indica que a variável a deverá ser capturada por referência. Ao utilizar um parallel_for, a biblioteca de execução TBB escolhe um número apropriado de iterações para agrupar em uma única tarefa, de forma a minimizar as sobrecargas enquanto oferece tarefas suficientes para a distribuição de carga.

Recursos adicionais

Для получения подробной информации о возможностях оптимизации компилятора обратитесь к нашему Уведомлению об оптимизации.