Introdução
Seja bem-vindo a Programação Paralela e Concorrente, um livro a ser usado na disciplina de mesmo nome. Essa disciplina busca ensinar aos alunos os principais conceitos e técnicas que são úteis ao trabalhar com sistemas concorrentes e paralelos.
Desde o final do século XX, a importância de aprender esses conceitos e técnicas vem crescendo, pois a indústria computacional passou a convergir para arquiteturas, tanto físicas quanto lógicas, que exibem propriedades concorrentes, paralelas e/ou distribuídas. A construção de aplicações e sistemas que satisfaçam os requisistos, cada vez mais exigentes, de performance, responsividade e tolerância a falhas depende do correto entendimento e aproveitamento dessas arquiteturas.
Hoje, qualquer que seja a área de atuação de um profissional de computação — frontend, backend, dados, segurança — ele precisará, no mínimo, estar ciente dos conceitos e técnicas vistos nesse livro, caso queira produzir software da mais alta qualidade.
No entanto, muitos profissionais hoje escolhem fazer vista grossa para essa área da computação, devido ao fato de que os bugs encontrados durante o desenvolvimento de sistemas concorrentes, paralelos e distribuidos estão entre os mais difíceis de entender, capturar e resolver. Enquanto num programa sequencial as mesmas entradas geram as mesmas saídas, em programas concorrentes e paralelos essa garantia não existe, pois a ordem que as tarefas são executadas é definida a cada execução.
Portanto, recomendo que o leitor não faça essa vista grossa, pois os profissionais capacitados para lidar com os problemas mais difíceis são justamente os mais valiosos.
Como este livro é organizado
O livro é dividido em Partes, cada uma contendo um conjunto de capítulos tratando de tópicos próximos entre si.
A Parte I, Conceitos Fundamentais, introduz o vocabulário básico de todo o livro. As partes II à V são focadas, cada uma, em 1 ou mais modelos de concorrência específicos.
A Parte II, Threads e Locks, cobre o modelo de concorrência mais comum e implementado na maioria das linguagens mainstream, e é o modelo que a maioria das pessoas tem em mente quando se fala de concorrência. Consiste em threads do SO se comunicando através do compartilhamento de memória e sincronizando entre si por meio de locks.
A Parte III, Concorrência via Mensagens, cobre dois modelos de concorrência que pertencem a família de modelos baseados em troca de mensagens (message-passing): Communicating Sequential Processes (CSP) e o modelo Actor.
A Parte IV, Programação Assíncrona, cobre um modelo de concorrência baseado numa única thread de execução, mas que extremamente eficiente para programas que ficam a maior parte do seu tempo esperando por I/O, como servidores web e interfaces de usuário.
A Parte V, Atomics e Concorrência Lock-Free, cobre um modelo de concorrência similar ao da Parte II, por também ser baseado em threads do SO, mas trocando locks por operações atômicas implementadas no hardware.
A Parte VII, Memória Transacional, cobre dois modelos de concorrência baseados na ideia de memória transacional, só que um é implementado a nível de hardware (HTM) e outro a nível de software (STM).
A Parte VIII, Paralelismo via Memória Distribuída, cobre uma abordagem de paralelismo muito utilizada no contexto de High-Performance Computing, baseada no padrão MPI.
A Part IX, Tópicos Avançados, contém uma variedade de capítulos cobrindo assuntos mais nichados, como a história da concorrência, modelos de concorrência não cobertos previamente, implementação de runtimes e bibliotecas concorrentes, aplicações de concorrência em domínios específicos, análise de arquiteturas concorrentes no mundo real, entre vários outros.
O Anexo A, Kotlin para o Programador Apressado, introduz a linguagem de programação Kotlin sem perder tempo explicando conceitos que são óbvios para pessoas que já programam, mas focando nos conceitos mais únicos da linguagem e relevantes para nosso contexto. Os anexos B e C fazem a mesma coisa para as linguagens Go e Elixir.
Dentro das Partes do livro, alguns capítulos ou seções são marcados como “Em Detalhe”. Esses apresentam um nível de aprofundamento maior sobre algum assunto, e geralmente assumem um nível de conhecimento maior nos pré-requisitos. Geralmente esses capítulos explicam mecanismos, algoritmos e provas matemáticas. Esse aprofundamento não é essencial para o entendimento do conteúdo, porém é incluído para complementar o conhecimento e dar um entendimento mais profundo para o leitor.
Outros capítulos e seções são marcados como “Interlúdio”. Esses apresentam um conteúdo que distoa daquele que é tratado na Parte e que não foi possível encaixar numa Parte específica.
O que este livro assume
Este livro assume que as Partes I (Conceitos Fundamentais) e II (Threads e Locks) serão lidas de maneira sequencial. Os capítulos dessas partes constroem o modelo mental e a terminologia básica de todo o livro. As partes restantes podem ser lidas na ordem que o leitor desejar.
Este livro assume que o leitor já possui:
- Conhecimento considerável em Programação Imperativa e Orientada a Objetos
- Conhecimento considerável em Arquitetura de Computadores
- Conhecimento básico em Programação Funcional.
Também assume-se que o leitor está fazendo ou já fez um curso de sistemas operacionais. Sistemas operacionais são a aplicação canônica para os conceitos de concorrência, e ambas as áreas se retro-alimentam. Apesar dos conceitos e técnicas de concorrência e paralelismo serem aplicáveis em outros domínios (sistemas de banco de dados, servidores de rede, High-Performance Computing, grid computing, simulação científica, computação gráfica, machine learning, etc), a concorrência como disciplina nasceu diretamente do estudo de sistemas operacionais. Além disso, todos os primitivos que serão estudados, como threads e locks, são implementados pelo kernel do sistema operacional.
Por padrão, os exemplos assumem que serão executados em algum sistema do tipo Unix (Linux, macOS, WSL, etc). Caso seja necessário que um exemplo seja executado num ambiente Windows nativo, será notado em específico.
Finalmente, o livro assume que o usuário sabe ler a documentação técnica de linguagens, bibliotecas, etc. Esse tipo de documentação, inclusive, costuma existir somente em inglês.
Como usar este livro
Este livro busca ensinar os conceitos e técnicas de maneira genérica e tecnologicamente agnóstica. Ou seja, queremos que o conhecimento seja transferível para qualquer tipo de aplicação, linguagem ou framework que o aluno venha a trabalhar.
Isso não significa que não usaremos uma linguagem ou biblioteca(s) específicas ao longo do livro. A maioria dos exemplos está escrito em C/C++ e Kotlin. Alguns capítulos usam Go, Elixir, JavaScript e Clojure, pois essas são linguagens que demonstram melhor certos modelos de concorrência.
Como rodar os exemplos de código
Os exemplos de código desse livro possuem duas funcionalidades relevantes: eles são executáveis e podem omitir partes do código. No Código P-1, temos um bloco de código em C que calcula a sequência de Fibonacci para um dado índice \( n \):
#include <stdio.h>
/*
* Retorna o elemento de índice n da sequência de
* Fibonacci (0, 1, 1, 2, 3, 5, ...).
*/
int fibonacci(int n) {
int a = 0, b = 1;
for (int i = 0; i < n; i++) {
int tmp = a + b;
a = b, b = tmp;
}
return a;
}
int main() {
int n = 4;
printf("%d\n", fibonacci(n));
}
Código P-1: Calculando a sequência de Fibonacci em C.
Passe o seu mouse (ou dedo) por cima do bloco de código, e verá que dois butões aparecerão no canto superior direito: um para Copiar e outro para Executar o código (com ícone de Play). Clique no botão de executar e veja o que o programa faz.
Ao longo do livro, veremos exemplos de código que serão gradualmente modificados. Fazemos isso para corrigir um bug ou adicionar mais funcionalidades. Geralmente, essas mudanças afetam poucas linhas. Seria desperdício de espaço se repetíssemos o código completo toda vez que fizermos uma mudança pequena. Para evitar isso, alguns exemplos de código possuem partes omitidas, que só são mostradas ao clicar num botão de olhinho para Revelar o código.
Por exemplo, suponha que queremos mudar o Código P-1 para
mostrar uma saída mais detalhada. Trata-se de uma mudança apenas na main. O
restante do código não é modificado, permanecendo igual ao que era antes.
Portanto, omitimos o restante no Código P-2.
#include <stdio.h>
/*
* Retorna o elemento de índice n da sequência de
* Fibonacci (0, 1, 1, 2, 3, 5, ...).
*/
int fibonacci(int n) {
int a = 0, b = 1;
for (int i = 0; i < n; i++) {
int tmp = a + b;
a = b, b = tmp;
}
return a;
}
// --restante omitido--
int main() {
int n = 14;
printf("Elemento de índice %d da sequência de Fibonacci: %d\n", n, fibonacci(n));
}
Código P-2: Calculando a sequência de Fibonacci em C com uma saída mais amigável.
Tente passar o mouse ou dedo em cima do bloco de código e clique no ícone de olho. O restante do código será revelado.
Sempre que um código tiver omitido algum trecho, iremos usar --restante omitido-- para indicar.
Apesar da funcionalidade de executar código no livro ser bastante útil, o ambiente que os programas são executados é bem restrito. A concorrência é mais limitada, e não podemos executar programas que leem da entrada. Portanto, eu sempre mostrarei o resultado da execução como se estivesse compilando e executando o programa localmente, numa máquina pessoal.
Concorrência e Paralelismo
Concurrency is about dealing with lots of things at once. Parallelism is about doing lots of things at once.
— Rob Pike, co-criador da linguagem Go
Essas duas palavrinhas são geralmente confundidas, até mesmo por profissionais experientes. Espero que alguns exemplos ajudem na distinção entre elas.
Estrutura x Execução
Concorrência é uma propriedade da estrutura de um programa. Um programa é concorrente quando ele é decomposto em múltiplas tarefas, e o tempo de vida (lifetime) dessas tarefas é intercalado.
A imagem abaixo mostra três tarefas que compõem um programa concorrente. Cada tarefa executa durante um tempo, e dá espaço para outra. A tarefa A é a primeiro a começar, mas só termina após outras duas terem iniciado.
Paralelismo é uma propridade da execução de um programa. Um programa é executado em paralelo quando duas ou mais tarefas que o compõem são executadas no mesmo instante de tempo.
Um programa só pode ser paralelo (isto é, executado em paralelo) se tiver um hardware que dê suporte a isso, ou seja, se o programa for executado num computador com múltiplos cores. Por exemplo, um computador com quatro cores permite executar 4 tarefas no mesmo instante de tempo.
Dessa forma, todo programa paralelo é concorrente: Não é possível executar duas “tarefas” de um programa no mesmo instante se o programa já não está estruturado em múltiplas tarefas pra começo de conversa.
Porém, o inverso não é verdade. Nem todo programa concorrente é paralelo. Um programa decomposto em múltiplas tarefas, mas executado num hardware single-core, verá que cada tarefa que o compõem irá executar na CPU por um certo intervalo de tempo, para em seguida ser trocado por outra (Um processo chamado de escalonamento). Os sistemas operaciionais mais antigos, executados em hardware single-core, davam a ilusão de muitas tarefas sendo executadas ao mesmo, sendo que eles apenas escalonavam múltiplas tarefas num único processador de maneira muito rápida, imperceptível ao olho humano.
Um programa concorrente, e talvez paralelo
Considere o seguinte programa em pseudocódigo:
Seja L uma lista de items
Seja Ts uma lista vazia
para cada item X em L:
crie uma tarefa T
programe T para processar o item X
insira T em Ts
para cada tarefa T em Ts:
inicie T
aguarde todas as tarefas em Ts finalizarem
Não vamos nos preocupar nesse momento com o que exatamente seria essa “tarefa”1. Vamos focar na seguinte pergunta: Esse programa é concorrente, paralelo ou ambos?
Primeiro, vejamos se é concorrente:
- O programa é estruturado em várias tarefas.
- Cada tarefa é iniciada sequencialmente, mas o pseudocódigo não especifica se que elas serão necessariamente executadas ou finalizadas sequencialmente2.
Portanto, sim, o programa é concorrente.
Agora, veja que não temos informações suficientes para determinar se o programa é paralelo ou não, pois o pseudocódigo só nos informa a estrutura do programa, não trazendo nenhuma informação sobre o computador em que ele será executado. Como não sabemos se o programa será executado num hardware single-core ou multi-core, não podemos afirmar se ele é paralelo ou não.
Por que concorrência e paralelismo?
Como veremos ao longo do livro, programação concorrente e paralela leva a bugs que são mais difíceis de lidar do que aqueles vistos na programação sequencial. Portanto, uma pergunta legítima é: “Por que eu iria escrever programas concorrentes e paralelos em primeiro lugar?”.
A resposta é que programas concorrentes e paralelos melhoram métricas que são importantes para uma aplicação ou sistema. Dentre essas métricas, destacamos as mais relevantes: latência, vazão e responsividade.
Latência (Latency)
Latência é o tempo passado entre uma requisição e o ínicio da resposta correspondente. Formalmente, para uma requisição no tempo \( t_0 \) cuja resposta começa a chegar no tempo \( t_1 \), a latência \( L \) é:
\[ L = t_1 - t_0 \]
Para ver como a concorrência pode melhorar a latência, considere um servidor HTTP sem uso de concorrência:
- No tempo \( t_0 = 0\ \text{ms} \), uma requisição \( R_1 \) chega ao servidor HTTP. Para respondê-la, o servidor precisa consultar informações no banco de dados.
- A consulta ao banco de dados leva 8 ms.
- No instante \( t_1 = 3\ \text{ms} \), uma outra requisição \( R_2 \) é recebida. Esta, no entanto, não requer consulta ao banco de dados. Apesar disso, o servidor está bloqueado aguardando a conclusão da consulta para \( R_1 \). A CPU está ociosa.
- Em \( t_2 = 8\ \text{ms} \), a consulta ao banco de \( R_1 \) é concluída e o servidor emite a resposta.
- O servidor analisa \( R_2 \), monta a resposta e a envia em \( t_3 = 10
\text{ms} \).
Seja \( L(R) \) a latência de uma requisição \( R \). Temos que \( L(R_1) = 8\ \text{ms} \) e \( L(R_2) = 7\ \text{ms} \).
Agora, considere um servidor HTTP com uso de concorrência:
- No tempo \( t_0 = 0\ \text{ms} \), uma requisição \( R_1 \) chega ao servidor HTTP. Para respondê-la, o servidor precisa consultar informações no banco de dados.
- A consulta ao banco de dados leva 8 ms. O servidor cria uma tarefa que fica aguardando até receber uma resposta do banco, ficando livre para atender outras requisições.
- No instante \( t_1 = 3\ \text{ms} \), uma outra requisição \( R_2 \) é recebida. Esta, no entanto, não requer consulta ao banco de dados. Como o processo principal do servidor está livre, ele recebe a nova requisição e cria uma nova tarefa para atendê-la.
- Em \( t_2 = 5\ \text{ms} \), a tarefa para \( R_2 \) finaliza e o servidor envia uma resposta.
- Em \( t_2 = 8\ \text{ms} \), a consulta ao banco de \( R_1 \) é concluída e o servidor emite a resposta.
Agora temos que \( L(R_1) = 8\ \text{ms} \) e \( L(R_2) = 5 - 3 = 2
\text{ms} \). Portanto, houve melhoria na latência de \( R_2 \), ainda que só
tenhamos introduzido concorrência e não necessariamente paralelismo.
Vazão (Throughput)
Vazão é a taxa que um sistema completa trabalho, num dado intervalo de tempo. É a quantidade de operações (ou dados) processados por unidade de tempo.
Para \( N \) operações completadas num intervalo de tempo \( \Delta t \), a vazão \( V \) é:
\[ V = \frac{N}{\Delta t} \]
Considere um programa que deve redimensionar 100 imagens, onde cada redimensionamento leva 10 ms de computação na CPU, seguidos de 40 ms de I/O de disco para escrever o resultado.
Versão sequencial/serial: cada image leva 50 ms fim-a-fim e a próxima imagem não pode começar a ser processada até que a anterior seja completamente escrita no disco. Portanto, a vazão é de:
\[ V_{serial} = \frac{100}{100 \cdot 50\ \text{ms}} = \frac{100}{5000\ \text{ms}} = 20\ \text{imagens/s} \]
Note que durante 80% do tempo de relógio decorrido, a CPU fica ociosa.
Versão concorrente: Enquanto a imagem \( n \) está sendo escrita no disco (I/O, portanto a CPU não é utilizada), a CPU pode começar a redimensionar a imagem \( n + 1 \):
Nesse caso, a vazão é de: \[ V_{concorrente} = \frac{100}{10 + 100 * 40\ \text{ms}} = \frac{100}{4010\ \text{ms}} \approx 24.9\ \text{imagens/s} \]
representando um aumento de quase 25% sobre o inicial.
Responsividade
Responsividade é a propriedade de um sistema pela qual ele reconhece e reage a interações e mudanças, provendo feedback num intervalo de tempo adequado. Não é medida matematicamente, mas sim perceptualmente.
Considere uma aplicação desktop, como um editor de texto (Word, LibreOffice Writer, etc). A funcionalidade de “Exportar PDF” em um documento grande envolve uma série de etapas: inclusão de fontes, compressão de imagens, resolução de layout, etc. Isso custa vários segundos de computação.
Versão sequencial: A exportação roda na mesma tarefa (na mesma thread, como veremos no próximo capítulo) que processa os eventos de interação do usuário e atualiza a interface gráfica. Enquanto o PDF está sendo renderizado, a aplicação não pode processar quaisquer eventos de interação, fazendo com que:
- A janela para de atualizar a interface gráfica
- Os cliques começam a se enfileirar silenciosamente
- O cursor congela
- O sistema operacional muda a cor da janela da aplicação para cinza, indicando que ela “Não está respondendo”
- O usuário não sabe se a exportação está em processamento, travou ou deu erro, fazendo com que ele clique no botão novamente ou mate forçadamente a aplicação
Versão concorrente: A aplicação delega a renderização do PDF para uma tarefa em background e imediatamente retorna o controle para a tarefa responsável pela interface de usuário (UI). Agora, enquanto o PDF está sendo renderizado:
- A tarefa de UI instantaneamente reconhece a ação do usário — um indicador de progresso (barra de progresso, spinner ou mensagem de status) é exibido.
- O usuário pode continuar interagindo com o documento e outros menus. A aplicação permanece viva, recebendo entrada.
- A tarefa em background continuamente reporta progresso, de forma que o usuário continuamente veja o resultado progredindo.
- Quando a renderização finaliza, a tarefa em background sinaliza para a tarefa de UI, que exibe uma notificação de “PDF salvo”.
Essa é uma demonstração de um padrão de concorrência muito comum: a concorrência desacopla uma computação custosa do ciclo de feedback exposto ao usuário.
Por que NÃO concorrência e paralelismo?
Concorrência e paralelismo não são ingredientes que você aplica em um software após ele já ter sido construído. Ambos são eixos de design que moldam toda a arquitetura de um sistema. Forçar concorrência num sistema que não foi projetado para tal é um dos esforços de engenharia mais custosos que podemos imaginar.
Nessa seção, listamos as principais desvantagens que acompanham concorrência e paralelismo.
Não-Determinismo
Conforme veremos mais a frente, todo programa concorrente cria unidades de execução (processos, threads, corotinas) cuja execução no hardware é escalonada. Esse processo de escalonamento faz com que um programa concorrente tenha uma certa quantidade de intercalações possíveis.
Por exemplo, veja o trecho de código abaixo, onde x é uma variável global:
x = x + 1
Apesar de se tratar de uma única linha de código, um compilador simples transforma essa linha de código em 3 instruções baixo-nível:
mov rax, [endereço de x] ; 0: lê x
add rax, 1 ; 1: incrementa
mov [endereço de x], rax ; 2: guarda o resultado em x
Assuma que criamos duas unidades de execução (threads, por exemplo), A e B, cada uma executando esse mesmo trecho de código, num computador single-core. Usamos A0, A1 e A2 para as instruções executadas por A e B0, B1 e B2 para as instruções executadas por B.
Como o escalonador (o programa que realiza o escalonamento dessas threads) pode interromper uma thread entre quaisquer duas instruções adjacentes, temos os seguintes possíveis escalonamentos:
| # | Pos 1 | Pos 2 | Pos 3 | Pos 4 | Pos 5 | Pos 6 |
|---|---|---|---|---|---|---|
| 1 | A0 | A1 | A2 | B0 | B1 | B2 |
| 2 | A0 | A1 | B0 | A2 | B1 | B2 |
| 3 | A0 | A1 | B0 | B1 | A2 | B2 |
| 4 | A0 | A1 | B0 | B1 | B2 | A2 |
| 5 | A0 | B0 | A1 | A2 | B1 | B2 |
| 6 | A0 | B0 | A1 | B1 | A2 | B2 |
| 7 | A0 | B0 | A1 | B1 | B2 | A2 |
| 8 | A0 | B0 | B1 | A1 | A2 | B2 |
| 9 | A0 | B0 | B1 | A1 | B2 | A2 |
| 10 | A0 | B0 | B1 | B2 | A1 | A2 |
| 11 | B0 | A0 | A1 | A2 | B1 | B2 |
| 12 | B0 | A0 | A1 | B1 | A2 | B2 |
| 13 | B0 | A0 | A1 | B1 | B2 | A2 |
| 14 | B0 | A0 | B1 | A1 | A2 | B2 |
| 15 | B0 | A0 | B1 | A1 | B2 | A2 |
| 16 | B0 | A0 | B1 | B2 | A1 | A2 |
| 17 | B0 | B1 | A0 | A1 | A2 | B2 |
| 18 | B0 | B1 | A0 | A1 | B2 | A2 |
| 19 | B0 | B1 | A0 | B2 | A1 | A2 |
| 20 | B0 | B1 | B2 | A0 | A1 | A2 |
Suponha que o valor inicial de x é 0. Com duas threads, cada uma incrementando
uma mesma variável, o esperado é que o resultado final de x seja 2. Uma
intercalação correta, que produz o resultado esperado, é A0, A1, A2, B0, B1, B2:
| Passo | Instrução | rax (A) | rax (B) | x |
|---|---|---|---|---|
| 1 | A0 | 0 | — | 0 |
| 2 | A1 | 1 | — | 0 |
| 3 | A2 | 1 | — | 1 |
| 4 | B0 | — | 1 | 1 |
| 5 | B1 | — | 2 | 1 |
| 6 | B2 | — | 2 | 2 ✓ |
Uma outra intercalação possível, mas que produz um resultado diferente do
esperado, é A0, B0, A1, B1, A2, B2:
| Passo | Instrução | rax (A) | rax (B) | x |
|---|---|---|---|---|
| 1 | A0 | 0 | — | 0 |
| 2 | B0 | — | 0 | 0 |
| 3 | A1 | 1 | — | 0 |
| 4 | B1 | — | 1 | 0 |
| 5 | A2 | 1 | — | 1 |
| 6 | B2 | — | 1 | 1 ✗ |
Essa intercalação ilustra um bug de concorrência conhecido como lost update:
Uma thread lê o valor antigo de x, e um incremento (aquele realizado por A) é
perdido.
Esse exemplo ilustra talvez a maior dificuldade de sistemas concorrentes: Temos, para um mesmo código-fonte, um conjunto considerável de intercalações possível, e um subconjunto dessas intercalações causa erros na lógica do nosso programa. E, pior ainda, essa intercalação é não-determinística: o bug só se manifesta quando o escalonador produz certas intercalações — o que pode ocorrer uma vez em um milhão de execuções, ou apenas numa máquina com 96 cores, ou somente num certo padrão de acesso à memória.
Para controlar o impacto que esse não-determinismo pode ter sobre a corretude do programa, várias técnicas podem ser adotadas, que vão desde mecanismos de sincronização até utilizar um modelo de concorrência baseado em troca de mensagens.
Complexidade
A intercalação de um programa concorrente não é apenas não-determinística. A quantidade de intercalações possíveis escala exponencialmente conforme seu programa aumenta em tamanho ou concorrência.
Vimos que para um trecho tão simples como x = x + 1, o compilador gera 3
instruções de CPU. Com duas threads e 3 instruções, temos 20 intercalações
possíveis. Dessas, apenas duas produzem o resultado esperado.
De maneira geral, se tivermos \( m \) threads, cada uma executando uma quantidade \( n_i \) de instruções, teremos
\[ \frac{(\sum_{i = 1}^{m} n_i)!}{\prod_{i = 1}^{m} n_i!} \]
intercalações possíveis.
Vamos ver alguns valores para ter uma noção de como isso explode. Aqui, todas as \( m \) threads executam a mesma quantidade de instruções \( n \).
| \( m \) | \( n \) | Intercalações |
|---|---|---|
| 2 | 3 | 20 |
| 3 | 3 | 1.680 |
| 4 | 3 | 369.600 |
| 2 | 5 | 252 |
| 3 | 5 | 756.756 |
| 2 | 10 | 184.756 |
| 3 | 10 | \( \approx 5.6 \times 10^{12} \) |
Para qualquer código concorrente não-trivial, é impossível raciocinar sobre o código tentando enumerar as intercalações possíveis. Felizmente, já foram desenvolvidas outras métodos, tanto intuitivos quanto formais, de raciocinar sobre programas concorrentes de maneira eficiente.
Heisenbugs
O não-determinismo provoca o surgimento de Heisenbugs: Bugs que desaparecem ao tentar observá-los.
Inserir mensagens de logs afeta o tempo de execução e o escalonamento. Executar num debugger introduz pausas, que afetam o escalonamento. O próprio ato de instrumentar o sistema pode alterar o escalonamento que revelou o bug.
Na verdade, o programador nem precisa alterar ou instrumentar o programa para que o escalonamento possa ser mudado. Duas execuções seguidas, do mesmo código, sem depuração, podem seguir escalonamentos diferentes, simplesmente porque o sistema operacional assim o fez.
Overhead
Enquanto as outras seções detalharam dificuldades relacionadas à corretude e depuração, é importante lembrar que até mesmo programas concorrentes corretamente implementados trazem um custo. Criar, manter e coordenar diferentes tarefas sempre vem com algum custo envolvido:
- Context switches (operação realizada pelo sistema operacional ao trocar a tarefa em execução) custam milhares de ciclos de CPU.
- Mecanismos de sincronização. A utilização de locks, como mutex, envolvem o uso de operações atômicas, execução de algoritmos complexos no kernel do SO e invalidação de linhas de cache dos cores.
- Contenção e serialização. Quando duas ou mais tarefas disputam por um mesmo recurso (um mutex, um canal, um arquivo, um socket, etc), todas as tarefas envolvidas na contenção ficam paradas até que a detentora atual do recurso o libere. Na prática, a disputa por locks e outros recursos faz com que uma fração da execução do programa seja inerentemente sequencial, limitando o ganho máximo de velocidade.3
- Cache thrashing e false sharing. Quando muitas threads disputam pelo mesmo cache, aumenta a probabilidade delas causarem o descarte de dados muito utilizados por threads irmãs.
- Consumo de memória. Threads são implementadas no kernel do sistema operacional, e toda a estrutura necessária (stack, estruturas de dados, file descriptors, sockets, etc) coloca uma pressão no uso de memória por parte do sistema operacional. Quando o quantitativo de threads é muito grande, essa pressão pode acabar forçando o SO a realizar swap, prejudicando imensamente a performance.
Conclusão
Devido a limitações físicas descobertas a mais de 20 anos4, caso um programa precise ficar mais rápido, ele só pode fazer isso se aperfeiçoar o uso de pelo menos um dos 2 pilares das arquiteturas de hardware modernas: a hierarquia de memória e os múltiplos núcleos do processador.
Portanto, apesar da concorrência ter seus prós e contras, já se foi o tempo em que programas sequenciais garantidamente ficavam mais rápidos com a chegada da próxima geração de CPUs. Hoje, a evolução da performance e escalabilidade exige pensar em projetar sistemas cada vez mais concorrentes. Nessa disciplina, veremos justamente como fazer isso.
-
Posteriormente veremos que uma “tarefa” pode ser implementada como um de vários tipos de unidades de execução: processos, threads, green threads, corotinas, etc. ↩
-
Veremos mais na frente como pode ser feito o escalonamento dessas tarefas. ↩
-
No Capítulo 2 veremos como a Lei de Ahmdal formaliza isso. ↩
-
A principal dessas descobertas foi o fim da escala de Dennard, que é explicada em detalhes no capítulo História da Concorrência e Paralelismo. ↩
Suporte do SO à Concorrência
Como dito na Introdução, os estudos de concorrência e sistemas operacionais se retro-alimentam:
- As primeiras descobertas sobre concorrência surgiram durante o desenvolvimento dos primeiros sistemas operacionais.
- Um sistema operacional é o tipo de sistema mais complexo e concorrente que alguém pode chegar a trabalhar.
- Os conceitos e técnicas desenvolvidos nos estudos de concorrência têm como principal domínio de aplicação justamente os sistemas operacionais.
Além disso, todo e qualquer programa concorrente eventualmente acaba desembocando no sistema operacional. É o SO que decide qual código roda em qual núcleo e em que momento; que media o acesso aos dispositivos de I/O e que fornece os primitivos fundamentais (processos, threads, escalonamento, sincronização, interfaces de I/O) a partir dos quais qualquer código concorrente é montado.
Portanto, para começarmos falar de concorrência precisamos estabelecer conceitos que surgem justamente na área de sistemas operacionais. Nesse capítulo, vamos entender, num nível suficiente para esse livro, os seguintes conceitos:
- Processos
- Threads
- Escalonamento
Unidades Escalonáveis: Processos e Threads
Tanto processos quanto threads atuam como tarefas: construtos do sistema operacional que podem ser escalonados. Isto é, na presença de vários processos e threads, apenas uma parte deles é selecionada para executar no hardware. Ao longo do tempo, uma tarefa dá lugar (voluntariamente ou não) para outra ser executada.
A diferença entre esses construtos é no nível de isolamento entre eles, o que determina o poder e os riscos que cada um possui.
Processos
Processos são tarefas independentes cujo contexto de execução é altamente isolado.
Dois processos não podem acessar uma mesma região de memória. Cada processo tem seu próprio:
- Espaço de endereçamento virtual
- Tabela de file descriptors (arquivos, pipes, sockets, etc)
- Conjunto de signal dispositions
- Conjunto de mapeamentos de memória
- Heap e segmentos estáticos de memória (
.text,.data,.bss) - ID (Process ID ou PID)
Todos esses componentes são isolados entre dois processos distintos. Isto
significa que quando um processo A escreve algo no endereço 0x7fff, ele está
escrevendo numa página de memória física que um outro processo B não pode ver
(imposto pela Memory Management Unit, MMU, da CPU).
Esse isolamento tem prós e contras.
- O principal pró é que se um processo crashar — ao dereferenciar um ponteiro inválido ou corromper seu heap — ele não irá afetar outros processos, devido ao isolamento.
- O principal contra é que a comunicação entre dois processos (conhecida como IPC, Inter-Process Communication) é mais trabalhosa de ser feita, tanto em termos de codificação quanto de performance.
Threads
Threads (também chamadas de threads nativas, de kernel ou do SO, para distinguir de threads de usuário) são tarefas que vivem dentro de um processo e cujo contexto de execução é pouco isolado.
Diferentemente de processos, duas threads de um mesmo processo podem acessar uma mesma região de memória. De maneira geral, todas threads em um mesmo processo compartilham:
- O mesmo espaço de endereçamento virtual
- O mesmo heap e segmentos estáticos de memória
- A mesma tabela de file descriptors
- O mesmo conjunto de signal dispositions
Isso não quer dizer que as threads compartilham tudo. Cada thread tem um estado próprio, separado das outras threads no mesmo processo. Esse estado inclui:
- Uma pilha. Permite que cada thread execute funções de maneira independente.
- Um conjunto de registradores, incluindo os registradores
PC(Program Counter) eSP(Stack Pointer). Permite que cada thread execute uma sequência de instruções individual. - Contexto de escalonamento do kernel. Permite que cada thread seja escalonada independentemente pelo SO.
- Um ID (Thread ID ou TID).
Todo processo tem pelo menos uma thread, que é a thread principal.
Prós e Contras
Esse isolamento menor entre threads apresenta prós e contras.
- O principal pró é que a comunicação entre threads é muito mais fácil de ser feita do que entre processos. Como as threads compartilham o mesmo heap e variáveis globais, a escrita de uma thread numa dessas localizações é visível para todas as outras threads do mesmo processo.
- Além disso, outro pró é que, como a criação de uma thread re-aproveita a maior parte das estruturas de dados usadas pelo processo, esse processo é consideravelmente mais barato (em termos de ciclos de CPU e memória).
No entanto, esse “poder” é justamente o que permite alguns dos maiores riscos quando falamos de threads:
- A memória compartilhada entre threads significa que duas escritas na mesma localização podem gerar bugs de concorrência, que levam o programa a ter um comportamento fora do esperado, como veremos mais pra frente.
- Para evitar esses bugs de concorrência, são introduzidos mecanismos de sincronização, que restringem o acesso a regiões de memória específicas, o que pode gerar os custos de overhead mencionados no Capítulo 1.
- A memória compartilhada também significa que se um thread corromper o heap do processo, todas as threads serão afetadas.
Simultaneous Multithreading (SMT)
Geralmente, cada thread ocupa um único núcleo do processador num dado instante. No entanto, uma tecnologia conhecida simultaneous multithreading (SMT) ou hardware multithreading permite que um único núcleo da CPU exiba para o sistema operacional duas ou mais “threads”. É por isso que em, algumas máquinas, quando o sistema operacional lista o número de threads que ele pode rodar ao mesmo, esse número acaba sendo maior do que a quantidade de núcleos no processador.
Por exemplo, abaixo está o resultado do lscpu (um programa de linha de comando
do Linux que retorna informações sobre as CPUs do computador) na minha máquina
pessoal:
$ lscpu
Architecture: x86_64
CPU op-mode(s): 32-bit, 64-bit
Address sizes: 39 bits physical, 48 bits virtual
Byte Order: Little Endian
CPU(s): 16 # <-- o SO vê 16 threads/"CPUs"
On-line CPU(s) list: 0-15
Vendor ID: GenuineIntel
Model name: 13th Gen Intel(R) Core(TM) i7-13620H
CPU family: 6
Model: 186
Thread(s) per core: 2 # <-- temos duas threads por núcleo
Core(s) per socket: 8 # <-- temos 8 núcleos físicos
Socket(s): 1
Escalonamento
Vimos que processos e threads são as unidades básicas que um sistema operacional utiliza para organizar as tarefas que ele manda para o hardware executar. No entanto, a maioria dos computadores para usuários comuns atualmente têm de 4 a 8 núcleos. Mesmo com SMT, isso limita a quantidade de threads em paralelo nesses computadores a até 16 threads.
Se você abrir o Gerenciador de Tarefas do seu computador, você vai ver que existem muito mais processos e threads em execução do que 16. Portanto, além de criar as abstrações para representar tarefas, o sistema operacional também precisa responder a seguinte pergunta: quais tarefas serão executadas pelo hardware, num dado momento?
A resposta para essa pergunta é o escalonamento: um dos processos mais importantes que o sistema operacional realiza, e que determina a execução de todos os programas do computador.
O escalonamento é realizado por um escalonador: um processo que roda no kernel do sistema operacional e segue algum algoritmo para decidir qual será a próxima tarefa a ser executada.
O escalonamento pode ser feito de maneira preemptiva, não-preemptiva/cooperativa ou mista. Esse tipo de escalonamento tem impacto direto em como o programador escreve código.
Escalonamento Preemptivo
No escalonamento preemptivo, o escalonador pode interromper a tarefa em execução a qualquer momento para trocá-la por outra tarefa.
Vamos ver o funcionamento desse tipo de escalonamento. Para simplificar, vamos supor que estamos numa máquina com um único núcleo de CPU, sem SMT.
Um escalonador preemptivo inicialmente cria um temporizador que “apita” a cada período de tempo (por exemplo, 1 ms)1. Esse temporizador define uma fatia/quantum de tempo, durante a qual cada tarefa irá executar na CPU.
Sempre que o temporizador apita, o escalonador força a interrupção da tarefa em execução — independente se ela quer parar ou não — salvando seu estado e trocando para a próxima tarefa.
Esse processo em que o escalonador salva o estado da tarefa interrompida e troca para a próxima tarefa é conhecido como troca de contexto (context switch), que tem um custo considerável de ciclos de CPU e de memória. Um context switch pode acontecer entre quaisquer duas instruções de uma tarefa.
Em suma, no escalonamento preemptivo o escalonador está no controle. Isso permite que ele garanta uma distribuição justa de tempo de CPU e robustez contra processos bugados ou maliciosos que tentem consumir a CPU sem parar.
Ilustração do escalonamento preemptivo com 3 tarefas. “int.” = interrupção
A maior parte dos sistemas operacionais adota o escalonamento preemptivo para processos que não são do kernel, isto é, processos a nível de usuário, para evitar que um processo mal-comportado não ceda tempo de CPU para outras tarefas.
Escalonamento Cooperativo/Não-preemptivo
No escalonamento cooperativo, é a própria tarefa que escolhe quando vai interromper sua execução e liberar o controle para o escalonador.
Para indicar ao escalonador que interrompa a execução, as tarefas usam uma função (ou keyword) geralmente chamada de yield ou await. Essa função é inserida no código que o programador escreve. Veja exemplos em Python e Lua:
async def tarefa(url: str) -> str:
print(f"Consultando {url}...")
res = await client.get(url) # <-- yield aqui
return res.text
function tarefa(url)
print("Consultando " .. url .. "...")
local res = coroutine.yield(http_get(url)) -- yield aqui
return res.body
end
Um padrão muito comum é que as tarefas façam o yield enquanto esperam algum
resultado de I/O ficar pronto, como ao ler algo do disco, enviar uma mensagem
num socket, etc.
Nesse modelo, o escalonador confia que as tarefas não irão se comportar mal. Ou
seja, que elas não irão “monopolizar” a CPU ao não chamar o yield em momentos
oportunos. Uma tarefa que não libera o controle irá deixar todas as outras
tarefas em inanição (starvation).
Após um yield, o escalonador ainda faz um context switch, porém este é muito
menos custoso de se realizar, pois como o yield funciona como uma chamada de
função, ele só precisa salvar um subconjunto dos registradores do
programa.2
Comparação
| Cooperativo | Preemptivo | |
|---|---|---|
| Quem decide a troca? | A própria tarefa em execução | O escalonador |
| Mecanismo | Tarefa chama yield() ou bloqueia em I/O | Interrupção de timer, sinalização, ou inserção de pontos de preempção |
| Starvation possível? | Sim — uma tarefa mal-comportada bloqueia todas | Não — fatiamento de tempo forçado |
| Overhead de context switch | Menor (troca só em pontos naturais de cessão) | Maior (salvamento forçado em pontos arbitrários) |
| Complexidade de implementação | Simples | Requer mecanismo de interrupção e save/restore de estado |
Escalonamento híbrido
A maioria dos sistemas operacionais e runtimes não utilizam um escalonamento que é 100% preemptivo ou 100% cooperativo, mas sim alguma forma híbrida.
Sistemas operacionais geralmente adotam o modelo preemptivo para processos e thread no user-space, mas internamente no kernel utilizam o modelo cooperativo em certos cotextos.
Alguns runtimes, como os de Go e Erlang, usam um modelo cooperativo, mas com um limite de tempo ou de chamadas de função que, quando atingido, força a preempção.
Escalonamento no user-space
Apesar da maior parte desse capítulo ter focado em conceitos do sistema operacional, que são implementados no kernel-space, o escalonamento é um processo que pode ser implementado no user-space.
Quando feito dessa forma, o escalonador é geralmente implementado nativamente em algum runtime (é o caso de Go e Erlang) ou numa biblioteca (Python, C++). A unidade escalonada não são processos ou threads do sistema operacional, mas sim objetos no user-space que possuem um contexto de execução, lembrando threads e processos.
Algumas linguagens e bibliotecas implementam um event loop, um programa no user-space responsável por gerenciar eventos e tarefas que reagem a esses eventos. Todo event loop contém um escalonador, que obviamente é implementado no user-space.
A principal motivação para implementar escalonamento no user-space é que o custo computacional e de memória é consideravelmente menor se comparado ao escalonamento no kernel-space. Com escalonamento no user-space, um programa pode chegar a milhões de tarefas concorrentes. A razão por trás disso está na seção Comparando context switches.
Veremos ao longo do livro que escalonamento no user-space é a base do modelo de concorrência de muitos runtimes.
-
No caso do escalonador do sistema operacional, esse temporizador é implementado no hardware do computador. ↩
-
O motivo pelo qual um context switch após um
yieldé mais eficiente do que um preemptivo é explicado na seção Comparando context switches e também aqui. ↩
Unidades e Modelos de Concorrência
Os próximos capítulos desse livro são organizados ao redor de vários modelos de concorrência. Esses modelos definem uma unidade de concorrência, utilizada para representar uma “tarefa”, e as regras sobre como essas unidades interagem e são escalonadas. Esse capítulo explica o que são unidades e modelos de concorrência, e dá exemplos de cada um, que serão estudados ao longo do livro.
Unidade de Concorrência
Uma unidade de concorrência é a abstração usada para representar as tarefas concorrentes de um programa e que serão escalonadas. Essa abstração costuma ser implementada como um tipo que pode ser instanciado pelo programador. Certas unidades não se parecem com um tipo, podendo ser uma função, mas por trás dos panos elas ainda são implementadas como um objeto.
Alguns exemplos que iremos explorar nos próximos capítulos:
-
Uma thread do SO é a unidade de concorrência mais primitiva de todas, escalonada pelo próprio sistema operacional.
Implementações:
threadingem Python;java.lang.Threadna JVM;System.Threading.Threadem .NET;std::threadem C++;<pthread.h>/pthread_te<threads.h>/thrd_tem C/C++; etc. -
Uma green thread (também chamada de user-space thread ou thread leve) é uma unidade de concorrência implementada e escalonada no user-space, apresentando uma semântica similar a de uma thread (memória compartilhada).
Uma green thread pode ser executada em uma de \( N \) threads do SO possíveis, e uma thread do SO pode executar uma de \( M \) green threads possível, definindo assim um mapeamento \( N:M \) entre ambas as unidades de concorrência.
Implementações: goroutines em Go; virtual threads em Java;
ThreadIdem Haskell;greenletem Python; etc. -
Um processo do SO é uma unidade de concorrência escalonada pelo próprio sistema operacional, com isolamento de memória entre dois ou mais processos. A comunicação entre processos é feita usando técnicas de Inter-Process Communication (IPC), como signals, pipes e filas.
Implementações:
multiprocessingem Python,std::processeipc-channelem Rust;<unistd.h>e<spawn.h>em C/C++; Boost.Process e Boost.Interprocess em C++;java.lang.Processna JVM;oseos/execem Go;System.Diagnostics.ProcesseSystem.IO.Pipesem .NET;child_processeclusterem Node.js;pcntl_*,posix_*,proc_*emsg_*em PHP;Foundation.ProcesseFoundation.Pipeem Swift; etc. -
Um green process (também chamado de user-space process, ator ou actor)1 é uma unidade de concorrência implementada e escalonada no user-space, apresentando uma semântica similar a de um processo do SO (memória isolada).
Implementações: processos da BEAM,
lunatic::processem WebAssembly,dart:isolateem Dart, Cloudflare Workers, etc. -
Uma corotina é uma unidade de concorrência implementada e escalonada no user-space, e sempre escalonada de maneira cooperativa (daí o nome).
Uma corotina é uma generalização de uma rotina/função: enquanto uma rotina/função tradicional tem um ponto de entrada e um ponto de saída, uma corotina pode ter vários. Como são escalonadas cooperativamente, o próprio programador decide quando suspender uma corotina, usando uma função ou keyword geralmente chamada
yield/await.Podemos dividir corotinas entre stackful e stackless.
-
Uma corotina stackful é uma corotina que possui uma pilha de chamadas (call stack) completa. O runtime aloca memória (entre 4 KB-1MB) para construir a pilha, e gerencia ela ao longo da vida da corotina. Isso permite que a corotina seja suspensa em qualquer profundidade na cadeia de chamadas de funções, e não apenas a partir da função mais externa. Uma corotina stackful é equivalente a uma green thread cooperativa.
Implementações:
coroutine.*em Lua (a implementação mais famosa), goroutines em Go (quando cooperativas), virtual threads em Java,<ucontext.h>em C/C++. -
Uma corotina stackless é uma corotina que não carrega sua própria pilha de chamadas. Invés disso, o compilador transforma a corotina num struct, que funciona como uma máquina de estados. Cada chamada de
yield/awaité transformada numa transição de estado.Apenas as variáveis locais que persistem entre pontos de suspensão (isto é, que são usadas em ambos os lados de um
yield/await) são salvas em memória, mais especificamente num pequeno frame do heap (entre 512 e 1024 bytes).Por não possuir uma pilha de chamadas, uma corotina stackless só pode ser suspensa no nível mais alto do seu próprio corpo, nunca de dentro de uma chamada de função aninhada. Para mais detalhes de como corotinas stackless funcionam por baixo dos panos, veja Em Detalhe: Corotinas.
Corotinas stackless são a principal forma de implementar a sintaxe
async/awaitno modelo de programação assíncrono.Implementações:
asyncio/coroutineem Python, corotinas em C++20,async/awaitem várias linguagens (C#, Rust, JavaScript, Dart, Swift, Python, Zig), generators em várias linguagens (Python, JavaScript, PHP),AmpeReactPHPem PHP.
-
-
Uma fiber é uma unidade de concorrência, geralmente equivalente a uma corotina stackful.
Implementações:
Swoole\Coroutinee\Fiberem PHP,Fiberem Ruby,Boost.Fiberem C++, funções de fibers (ConvertThreadToFiber,CreateFiber,SwitchToFiber, etc) expostas na API Win32 em C/C++ (<windows.h>),node-fibersem Node.js, etc.
Modelos de Concorrência
Um modelo de concorrência é uma combinação de três coisas:
- Uma unidade de concorrência (representando tarefas)
- A forma como essas tarefas serão escalonadas
- As regras de como duas ou mais tarefas podem coordenar entre si
Uma linguagem de programação pode tranquilamente oferecer mais de um modelo para o programador, que poderá escolher um ou mais modelos para resolver algum problema.
Vamos ver dois modelos de concorrência para ilustrar essa definição. Essas descricões estão longe de serem detalhadas, pois o detalhamento nos capítulos dos respectivos modelos.
Threads e Locks
Esse modelo é composto de:
- Unidade de Concorrência: Threads do SO
- Escalonamento: Preemptivo e realizado no kernel-space.
- Coordenação: Memória compartilhada + Locks
As threads se comunicam através de memória compartilhada e, para controlar o acesso a essa memória, realizam sincronização por meio de locks (Mutex, por exemplo).
Concorrência Lock-Free
Esse modelo é composto de:
- Unidade de Concorrência: Threads do SO
- Escalonamento: Preemptivo e realizado no kernel-space.
- Coordenação: Memória compartilhada + Atomics
Nesse caso, é praticamente o mesmo modelo que o anterior. No entanto, ao invés de sincronizarem o acesso à memória por meio de locks, são utilizadas operações atômicas (também chamadas de atomics).
As próximas Partes do livro irão focar em cada modelo de concorrência relevante, aprendendo o jeito correto de raciocinar e programar em cada modelo, e vendo quando usar (ou não) cada um deles.
-
O nome “Ator” deriva do modelo de concorrência chamado “modelo de Ator”, que é muito associado com green processes, em especial por causa da implementação mais famosa desse, presente na BEAM. No entanto, nem todas implementações do modelo de Ator implementam Atores com green processes. Dois exemplos disso são Ractors (implementação da linguagem Ruby) e Akka/Pekka (uma biblioteca e runtime que implementa o modelo de Ator na JVM), pois os Atores de ambas as implementações não tem um isolamento de memória no heap, nem possuem um garbage-collector por ator. O isolamento é feito por convenção ou por restrição da API pública, mas ele não existe a nível de memória. ↩
Criando Threads
Vamos estudar o modelo de concorrência mais fundamental de todos: Threads do SO comunicando-se entre si via memória compartilhada e sincronizando através de locks.
Nesse capítulo, vamos ver como criar threads nas principais linguagens de programação. Apesar de threads serem um construto do sistema operacional (portanto, vivem no kernel-space), as linguagens de programação precisam oferecer um tipo que represente essas threads no user-space.
Pthreads (POSIX Threads)
O padrão POSIX (Portable Operating System Interface) é uma coleção de interfaces de programação (APIs) que um sistema operacional deve implementar para ser considerado POSIX-compliant.
Esse padrão foi criado para que quando um programador escrevesse código que utilizasse as funcionalidades comuns de um sistema operacional (system calls, gerenciamento de processos e threads, utilitários de sistema, shells, etc), esse código funcionasse em qualquer sistema operacional POSIX-compliant.
Um dos componentes da POSIX é a API para criar e gerenciar threads do SO,
chamada de Pthreads. Essa API é acessível a partir da linguagem de
programação C, por meio do header <pthread.h>.
Criando uma única thread com pthread_t e pthread_create
Criar uma thread é simples:
#include <pthread.h>
#include <stdio.h>
void *greet(void *arg) {
char *name = (char *)arg;
printf("Hello from thread %s!\n", name);
return NULL;
}
int main() {
pthread_t my_thread;
pthread_create(&my_thread, NULL, greet, "worker");
}
Código 4-1: Criando uma thread com a Pthreads.
Vamos entender o código por partes:
-
Primeiro, para podermos usar as funções da Pthread, incluímos o header da biblioteca fazendo
#include <pthread.h>. -
Toda thread precisa executar algum código, e isso é feito por meio de uma função. Quando a thread inicia, ela executa uma função que o programador especifica. Nesse caso, a função
greetserá executada pela nossa thread.Nós chamaremos essas funções que são executadas pelas threads de “funções de thread”.
O primeiro e único argumento de
greetéarg, cujo tipo évoid *. Esse tipo é usado em C para representar um “ponteiro genérico”. Isto é, um ponteiro que pode estar apontando para um valor de qualquer tipo. Porém, não podemos usar um ponteiro desse tipo, apenas passar ele entre funções. O motivo pelo qual não podemos usar umvoid *(ponteiro paravoid) é porquevoidé um tipo que não possui valores. Portanto, não faz sentido tentar acessar/dereferenciar o valor apontado.Para podermos usar esse ponteiro, precisamos converter ele para um ponteiro para algum tipo válido. É o que fazemos em
char *name = (char *)arg;. Nós convertemos um ponteiro paravoidnum ponteiro parachar, e salvamos ele numa variávelname. Depois, imprimosnamena saída padrão e retornamosNULLpara indicar que essa função não retorna nada de útil.Toda função de thread tem a mesma assinatura: recebe um
void *e retorna umvoid *. -
Começamos o código da
maindeclarando uma variávelmy_threadcujo tipopthread_t. Esse tipo representa uma thread, mas por enquanto ela ainda não foi criada pelo sistema operacional.Para pedir que o SO crie a thread, chamamos
pthread_create. Precisamos passar:- um ponteiro para uma
pthread_t - um ponteiro para uma
pthread_attr_t, um tipo que armazena os atributos da nossa thread. Quando passamosNULL, a thread receberá os atributos padrões. - um ponteiro para a função de thread que será executada. Nesse caso,
passamos a função que criamos,
greet. Note que em C,&greeté equivalente agreet, pois o nome de uma função automaticamente decai para um ponteiro para ela mesma. - Um valor que será convertido num
void *e passado como o argumentoargdegreet. Note que passamos a string literal"worker", que é do tipochar[7]. Arrays em C decaem para um ponteiro do mesmo tipo, então o tipo decai dechar[7]parachar *, que é implicitamente convertido para umvoid *.
- um ponteiro para uma
Vamos executar o programa e vê-lo em ação:
$ cc main.c -o main -pthread -Wall
$ ./main
$
Epa! O que aconteceu?! O esperado era que o programa printasse Hello from thread worker! na saída padrão.
Aguardando uma thread concluir com pthread_join
O motivo de nada ter sido printado é que o nosso programa principal criou uma nova thread e chegou ao seu fim. O programa principal não ficou esperando até a thread ser de fato executada e encerrada.
Felizmente, podemos forçar que nosso programa principal (ou thread principal,
pois lembre-se que todo programa inicial com pelo menos uma thread, a principal)
fique aguardando a conclusão da sua irmã, usando a função pthread_join. É o
que fazemos no Código 4-2.
#include <pthread.h>
#include <stdio.h>
void *greet(void *arg) {
char *name = (char *)arg;
printf("Hello from thread %s!\n", name);
return NULL;
}
// --restante omitido--
int main() {
pthread_t my_thread;
pthread_create(&my_thread, NULL, greet, "worker");
pthread_join(my_thread, NULL);
}
Código 4-2: Usando pthread_join para aguardar a thread terminar de rodar.
Agora sim! pthread_join trabalha com dois parâmetros:
-
O primeiro é a variável que representa a thread que você deseja esperar.
-
O segundo é um
void **(um ponteiro para um ponteiro para void) que, após a thread sendo esperada finalizar, é atualizado para apontar para o valor retornado pela função de thread que acabou de ser executada (Lembre-se que o tipo do valor retornado por funções de thread é semprevoid *).Como passamos
NULL, estamos indicando que não queremos salvar esse valor. Depois veremos exemplos onde é útil para o programa obter o resultado retornado pela thread.
Criando múltiplas threads
Esse não seria um livro de concorrência se criássemos apenas uma thread! O
Código 4-3 cria 10 threads num loop, cada uma executando greet
com um argumento diferente.
#include <pthread.h>
#include <stdio.h>
void *greet(void *arg) {
char *name = (char *)arg;
printf("Hello from thread %s!\n", name);
return NULL;
}
// --restante omitido--
int main() {
int n = 10;
pthread_t threads[n];
char names[n][32];
for (int i = 0; i < n; i++) {
snprintf(names[i], sizeof(names[i]), "worker %d", i);
pthread_create(&threads[i], NULL, greet, names[i]);
}
for (int i = 0; i < n; i++) {
pthread_join(threads[i], NULL);
}
}
Código 4-3: Criando múltiplas threads.
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#define N_THREADS 5
#define N_ELEMS 1000
typedef struct {
const int *data;
size_t len;
} Slice;
void *partial_sum(void *arg) {
Slice *s = arg;
long sum = 0;
for (size_t i = 0; i < s->len; i++)
sum += s->data[i];
printf("Thread sum: %ld\n", sum);
return (void *)sum;
}
int main(void) {
int numbers[N_ELEMS];
for (int i = 0; i < N_ELEMS; i++)
numbers[i] = i + 1; // 1..1000
size_t chunk = N_ELEMS / N_THREADS; // 200 each
pthread_t tids[N_THREADS];
Slice slices[N_THREADS];
for (int t = 0; t < N_THREADS; t++) {
slices[t].data = numbers + t * chunk;
slices[t].len = (t == N_THREADS - 1) ? N_ELEMS - t * chunk : chunk;
pthread_create(&tids[t], NULL, partial_sum, &slices[t]);
}
long total = 0;
for (int t = 0; t < N_THREADS; t++) {
void *res;
pthread_join(tids[t], &res);
total += (long)res;
}
printf("total = %ld\n", total); // 500500
return 0;
}
História da Concorrência e Paralelismo
Eu considero que, para qualquer assunto que você deseja entender melhor, é essencial conhecer o contexto e a história que o cercam. Com os temas desse livro — concorrência e paralelismo — não seria diferente.
Dentre os destaques desse capítulo, veremos:
- Como as descobertas sobre concorrência surgiram durantes os estudos sobre sistemas operacionais
- Como durante décadas os programadores se beneficiaram do “almoço grátis proporcionado pelo aumento da velocidade de clock das CPUs, e como uma limitação física fundamental acabou com isso.
- Como a indústria reagiu ao fim do almoço grátis, construindo arquiteturas multi-core