C# Threads
Uma thread ("fio", "linha de execução") é um caminho de execução dentro do programa: uma sequência de instruções sendo executada, uma depois da outra. Todo programa que você escreveu até aqui tinha uma thread só. Com várias, o programa faz várias coisas ao mesmo tempo, usando os vários núcleos do processador. Nesta aula você vai criar threads, esperar por elas, ver a saída delas se misturar e conhecer o erro mais traiçoeiro da programação com threads: duas threads mexendo na mesma variável.
- O que é a thread principal e como ver as informações dela (
Thread.CurrentThread). - Criar uma thread com
new Thread, começá-la comStarte esperá-la comJoin. - Pausar uma thread com
Thread.Sleep. - A diferença entre threads de primeiro e de segundo plano (
IsBackground). - A condição de corrida do
contador++e o conserto comInterlocked.Increment. - O que acontece com uma exceção dentro de uma thread, e por que hoje se prefere
Task.Run.
Esta aula usa lambdas e funções locais. Os programas rodam com dotnet run na pasta ~/cs do notebook da Ana, que tem 8 núcleos. Com threads, a ordem de algumas linhas da saída e alguns números mudam a cada execução: os exemplos mostram execuções reais, e as suas podem sair diferentes.
O .NET do editor roda em WebAssembly, dentro do navegador, com uma thread só. Lá, new Thread(…).Start() não funciona (veja "Quando dá errado"). Por isso, os exemplos com new Thread aparecem com a saída do terminal da Ana, e as caixas "Experimente" usam a thread principal e o Task.Run, que funciona nos dois lugares.
A ideia antes do código: cozinheiros numa cozinha
Pense no programa como uma cozinha e nas threads como cozinheiros. Com um cozinheiro só, as receitas saem uma depois da outra. Com dois, uma panela fica com cada um, e o jantar sai mais rápido. Os núcleos do processador são as bocas do fogão: com 8 bocas, até 8 cozinheiros trabalham de verdade ao mesmo tempo.
Mas cozinheiros na mesma cozinha dividem as coisas. Se dois forem pegar o último ovo da mesma caixa ao mesmo tempo, os dois podem achar que pegaram. Variáveis compartilhadas entre threads têm exatamente esse problema (seção 4).
1. Você sempre esteve numa thread: a principal
Thread atual = Thread.CurrentThread;
atual.Name = "principal";
Console.WriteLine($"Nome: {atual.Name}");
Console.WriteLine($"Número: {atual.ManagedThreadId}");
Console.WriteLine($"Em segundo plano? {atual.IsBackground}");
Console.WriteLine($"Do pool? {atual.IsThreadPoolThread}");
Console.WriteLine($"Núcleos: {Environment.ProcessorCount}");
for (int i = 3; i >= 1; i--)
{
Console.WriteLine(i);
Thread.Sleep(500);
}
Console.WriteLine("Já!");
$ dotnet run principal.cs Nome: principal Número: 1 Em segundo plano? False Do pool? False Núcleos: 8 3 2 1 Já!
- Thread.CurrentThread
- "A thread atual": um objeto da classe
Thread(namespaceSystem.Threading, já incluído) que representa quem está rodando esta linha. Aqui, a thread principal, criada pelo .NET quando o programa começa. - atual.Name = "principal"
- Threads podem ter um nome, que ajuda a descobrir "quem fez o quê" nas mensagens e no depurador. O nome só pode ser dado uma vez.
- ManagedThreadId
- O número da thread no .NET. A principal é a 1. (
Environment.CurrentManagedThreadIddá o mesmo número, sem precisar do objeto.) - IsBackground, IsThreadPoolThread
- Se ela é de segundo plano (seção 3) e se é do pool de threads (seção 5).
- Environment.ProcessorCount
- Quantos núcleos o computador tem: quantas threads conseguem rodar de verdade ao mesmo tempo.
- Thread.Sleep(500)
- "Dormir": pausa a thread atual por 500 milissegundos (meio segundo). Durante a pausa, ela não faz nada.
Missão: o programa mostra Estou na thread sem nome nenhum. Dê o nome "principal" à thread atual antes da mensagem e, depois dela, faça uma contagem regressiva de 3 a 1, com Thread.Sleep(500) entre os números, terminando com Já!.
Console.WriteLine($"Estou na thread {Thread.CurrentThread.Name}");
// a contagem regressiva vem aqui
Thread.CurrentThread.Name = "principal";
Console.WriteLine($"Estou na thread {Thread.CurrentThread.Name}");
for (int i = 3; i >= 1; i--)
{
Console.WriteLine(i);
Thread.Sleep(500);
}
Console.WriteLine("Já!");
2. Criando outras threads: Start e Join
A Ana e o Bruno vão contar até 3, cada um no seu ritmo, cada um na sua thread:
Thread ana = new Thread(() => Contar("Ana", 300));
Thread bruno = new Thread(() => Contar("Bruno", 500));
ana.Name = "thread da Ana";
ana.Start();
bruno.Start();
Console.WriteLine("Principal: comecei as duas");
ana.Join();
bruno.Join();
Console.WriteLine("Principal: as duas terminaram");
void Contar(string quem, int pausa)
{
for (int i = 1; i <= 3; i++)
{
Console.WriteLine($"{quem}: {i} (thread {Environment.CurrentManagedThreadId}, {Thread.CurrentThread.Name ?? "sem nome"})");
Thread.Sleep(pausa);
}
}
$ dotnet run duas.cs Principal: comecei as duas Bruno: 1 (thread 5, sem nome) Ana: 1 (thread 4, thread da Ana) Ana: 2 (thread 4, thread da Ana) Bruno: 2 (thread 5, sem nome) Ana: 3 (thread 4, thread da Ana) Bruno: 3 (thread 5, sem nome) Principal: as duas terminaram $ dotnet run duas.cs « de novo: as primeiras linhas trocaram de ordem Principal: comecei as duas Ana: 1 (thread 4, thread da Ana) Bruno: 1 (thread 5, sem nome) Ana: 2 (thread 4, thread da Ana) Bruno: 2 (thread 5, sem nome) Ana: 3 (thread 4, thread da Ana) Bruno: 3 (thread 5, sem nome) Principal: as duas terminaram
- new Thread(() => Contar("Ana", 300))
- Cria uma thread e diz o que ela vai fazer: a lambda, que chama
Contar. Criar não começa nada. - ana.Start()
- "Começar": a partir daqui, a thread da Ana roda o
Contarao mesmo tempo que a principal continua para a linha seguinte. - Principal: comecei as duas
- Saiu primeiro, porque a principal não esperou ninguém: começar uma thread leva um tempinho, e a principal já estava na linha seguinte.
- ana.Join()
- "Juntar-se": a principal espera a thread da Ana terminar. Depois, espera a do Bruno. Só então mostra a última mensagem.
- a ordem das linhas
- As linhas das duas threads se misturam, e a ordem muda de uma execução para outra: quem decide qual thread roda em cada instante é o sistema operacional. Nunca conte com uma ordem entre threads, a não ser que você a garanta (com
Join, por exemplo).
3. Primeiro e segundo plano: IsBackground
O que acontece se a thread principal terminar antes das outras? Depende do tipo da thread:
Thread t = new Thread(() =>
{
for (int i = 1; i <= 5; i++)
{
Console.WriteLine($"Trabalhando... {i}");
Thread.Sleep(300);
}
Console.WriteLine("Trabalho terminado");
});
t.IsBackground = true;
t.Start();
Thread.Sleep(700);
Console.WriteLine("Principal: fim");
$ dotnet run fundo.cs Trabalhando... 1 Trabalhando... 2 Trabalhando... 3 Principal: fim $ sed -i 's/IsBackground = true/IsBackground = false/' fundo.cs $ dotnet run fundo.cs Trabalhando... 1 Trabalhando... 2 Trabalhando... 3 Principal: fim Trabalhando... 4 Trabalhando... 5 Trabalho terminado
- t.IsBackground = true
- Uma thread de segundo plano (background): quando a principal termina, o programa acaba, e ela é cortada no meio. O trabalho parou no 3.
- IsBackground = false
- O padrão do
new Thread: uma thread de primeiro plano. O programa só termina quando todas as threads de primeiro plano terminarem, mesmo depois de a principal acabar. (Oseddo terminal trocou otrueporfalseno arquivo.)
Use segundo plano para trabalhos que podem morrer junto com o programa (verificar atualizações, por exemplo), e primeiro plano, ou melhor, um Join, para trabalhos que precisam terminar (gravar um arquivo). As threads do pool (seção 5) são todas de segundo plano.
4. O perigo: duas threads, uma variável
Duas threads somam 1 a um contador, um milhão de vezes cada. O resultado deveria ser dois milhões:
int contador = 0;
Thread a = new Thread(Somar);
Thread b = new Thread(Somar);
a.Start();
b.Start();
a.Join();
b.Join();
Console.WriteLine($"Contador: {contador} (esperado: 2000000)");
void Somar()
{
for (int i = 0; i < 1_000_000; i++)
{
contador++;
}
}
$ dotnet run corrida.cs Contador: 1098163 (esperado: 2000000) $ dotnet run corrida.cs Contador: 1256102 (esperado: 2000000) $ dotnet run corrida.cs Contador: 1935612 (esperado: 2000000)
Três execuções, três resultados diferentes, todos errados, sem nenhum erro ou aviso. Isso se chama condição de corrida (race condition): o resultado depende de quem "chega primeiro".
O motivo é que contador++ parece uma coisa só, mas são três passos: ler o valor, somar 1, gravar o valor. Quando as duas threads fazem isso ao mesmo tempo:
| Passo | Thread A | Thread B | contador |
|---|---|---|---|
| 1 | lê 41 | 41 | |
| 2 | lê 41 | 41 | |
| 3 | soma: 42, grava 42 | 42 | |
| 4 | soma: 42, grava 42 | 42 |
Duas somas, mas o contador só andou uma casa: uma das somas se perdeu. Num milhão de voltas, isso acontece centenas de milhares de vezes. É o caso do último ovo da caixa.
O conserto: Interlocked
A classe Interlocked ("travado entre si") faz operações simples numa variável de um jeito atômico: os três passos acontecem como um só, sem outra thread no meio.
$ sed -i 's/ contador++;/ Interlocked.Increment(ref contador);/' corrida.cs
$ grep Interlocked corrida.cs
Interlocked.Increment(ref contador);
$ dotnet run corrida.cs
Contador: 2000000 (esperado: 2000000)
$ dotnet run corrida.cs
Contador: 2000000 (esperado: 2000000)
- Interlocked.Increment(ref contador)
- Soma 1 ao contador de forma atômica. O
ref(aula ref, out e in) entrega a própria variável, e não uma cópia do valor. Também existemInterlocked.Decrement(tira 1),Interlocked.Add(ref x, n)(soman) eInterlocked.Exchange(troca o valor).
O Interlocked serve para uma variável e uma conta simples. Para proteger vários passos de uma vez (atualizar o saldo e o extrato, por exemplo), use o lock, da aula Sincronização (lock). E o melhor é não precisar de nenhum dos dois: cada thread com as suas próprias variáveis, juntando os resultados só no fim (como o Task.WhenAll da aula Task e o Parallel da aula Parallel e PLINQ).
5. O pool de threads e o Task.Run
Criar uma thread custa caro: o sistema reserva memória para ela (cerca de 1 MB para a pilha) e precisa prepará-la. Para trabalhos curtos, o .NET mantém um pool de threads, um grupo de threads já prontas que pegam trabalhos de uma fila e, ao terminar, voltam a esperar o próximo. Você entrega trabalho para o pool com o Task.Run (aula Task):
new Thread(…).Start() | Task.Run(…) | |
|---|---|---|
| Quem roda | Uma thread nova, só sua | Uma thread do pool, reaproveitada |
| Custo para começar | Alto | Baixo |
| Resultado | Não devolve (use uma variável) | Task<T>, com await |
| Esperar | Join(), que trava a thread | await, que não trava |
| Exceção | Derruba o programa (veja abaixo) | Fica guardada na tarefa e volta no await |
| Plano | Primeiro plano, por padrão | Segundo plano |
| Use para | Trabalhos muito longos e especiais (uma thread que vive o programa inteiro) | Quase tudo |
Por isso, em código novo, quase não se escreve new Thread. Mas tudo o que esta aula mostrou continua valendo com tarefas: elas também rodam em threads, a ordem também muda, e a condição de corrida é a mesma. A missão final mostra isso.
Quando dá errado
Thread t = new Thread(() =>
{
Thread.Sleep(200);
throw new InvalidOperationException("Erro dentro da thread");
});
t.Start();
try
{
t.Join();
Console.WriteLine("Principal: terminei");
}
catch (Exception e)
{
Console.WriteLine($"Peguei: {e.Message}");
}
$ dotnet run explode.cs; echo "código: $?" Unhandled exception. System.InvalidOperationException: Erro dentro da thread at Program.<>c.<<Main>$>b__0_0() in /home/ana/cs/explode.cs:line 4 at System.Threading.Thread.StartCallback() código: 134
O que aconteceu: o try da principal não pega exceções de outra thread. Cada thread tem a sua própria pilha de chamadas, e a exceção não tratada na thread nova derrubou o programa todo, sem passar pelo catch nem mostrar Principal: terminei.
Como resolver: ponha o try/catch dentro da lambda da thread. Ou use Task.Run e await: com tarefas, a exceção volta para quem fez o await, dentro do try dele.
ThreadStateException: começar a mesma thread duas vezes
Thread t = new Thread(() => Console.WriteLine("oi"));
t.Start();
t.Join();
t.Start();
$ dotnet run duasvezes.cs oi Unhandled exception. System.Threading.ThreadStateException: Thread is running or terminated; it cannot restart. at System.Threading.Thread.StartCore() at Program.<Main>$(String[] args) in /home/ana/cs/duasvezes.cs:line 4
O que aconteceu: "A thread está rodando ou terminou; ela não pode recomeçar". Um objeto Thread roda uma vez só.
Como resolver: para rodar de novo, crie outra: new Thread(…). (Com Task.Run, cada chamada já é uma tarefa nova.)
PlatformNotSupportedException no editor do guia
Unhandled exception. System.PlatformNotSupportedException: Operation is not supported on this platform.
O que aconteceu: é o que o new Thread(…).Start() lança no editor do guia, cujo .NET roda dentro do navegador com uma thread só. No seu computador, o mesmo programa funciona.
Como resolver: rode os exemplos com new Thread no seu computador, com dotnet run. No editor, use Task.Run: ele funciona (as tarefas rodam uma depois da outra, na thread 1).
O que aconteceu: como no primeiro fundo.cs, uma thread de segundo plano (ou uma tarefa do Task.Run, que também é) foi cortada quando a principal terminou.
Como resolver: espere o trabalho antes do fim: t.Join() para uma thread, await para uma tarefa.
Referência rápida
| Código | O que faz |
|---|---|
Thread.CurrentThread | A thread atual (Name, ManagedThreadId, IsBackground, IsThreadPoolThread) |
Environment.CurrentManagedThreadId | O número da thread atual |
Environment.ProcessorCount | Quantos núcleos o computador tem |
new Thread(() => …) | Cria uma thread (ainda parada) |
t.Start() | Começa a thread (uma vez só) |
t.Join() | Espera a thread terminar |
t.IsBackground = true | Segundo plano: morre quando o programa acaba |
Thread.Sleep(ms) | Pausa a thread atual |
Interlocked.Increment(ref x), Decrement, Add(ref x, n) | Contas atômicas, seguras entre threads |
lock (objeto) { … } | Protege vários passos (aula lock) |
Task.Run(() => …) | Trabalho numa thread do pool (aula Task) |
Exercícios
1. Começar
Complete para a thread começar a rodar:
2. Esperar
Complete para a thread principal esperar a t terminar:
3. Somar sem corrida
Complete para somar 1 ao total de forma segura entre threads:
4. Duas threads fazem contador++ 1000 vezes cada, sem proteção. O que é certo dizer do resultado?
5. A thread principal termina enquanto uma thread com IsBackground = true ainda trabalha. O que acontece?
6. Por que, em código novo, se prefere Task.Run a new Thread?
Missão final: os dois caixas do cinema
Missão: o cinema tem dois caixas, cada um numa tarefa do Task.Run, vendendo 500 mil ingressos (é a estreia de um filme muito esperado). Os dois somam no mesmo contador vendidos, com vendidos++: no computador da Ana, com threads de verdade, o total sai errado e diferente a cada vez (numa execução real, Ingressos vendidos: 504546). Troque o vendidos++ por Interlocked.Increment(ref vendidos); para o total ser sempre 1000000.
No editor, que tem uma thread só, as duas tarefas rodam uma depois da outra e o total já sai certo antes do conserto: a corrida só aparece com threads de verdade. A missão confere o resultado e o Interlocked.
int vendidos = 0;
Task caixa1 = Task.Run(() => Vender(500_000));
Task caixa2 = Task.Run(() => Vender(500_000));
await Task.WhenAll(caixa1, caixa2);
Console.WriteLine($"Ingressos vendidos: {vendidos}");
void Vender(int quantidade)
{
for (int i = 0; i < quantidade; i++)
{
vendidos++;
}
}
int vendidos = 0;
Task caixa1 = Task.Run(() => Vender(500_000));
Task caixa2 = Task.Run(() => Vender(500_000));
await Task.WhenAll(caixa1, caixa2);
Console.WriteLine($"Ingressos vendidos: {vendidos}");
void Vender(int quantidade)
{
for (int i = 0; i < quantidade; i++)
{
Interlocked.Increment(ref vendidos);
}
}
$ dotnet run cinema.cs « o código de antes do conserto, no notebook da Ana Ingressos vendidos: 504546 $ sed -i 's/ vendidos++;/ Interlocked.Increment(ref vendidos);/' cinema.cs $ dotnet run cinema.cs Ingressos vendidos: 1000000
Se quiser ir além: em vez de somar no mesmo contador, faça cada caixa contar numa variável própria e devolver o total (Task<int>), e some os dois resultados do Task.WhenAll. Sem variável compartilhada, não há corrida, e nem é preciso Interlocked.
Resumo
- Uma thread é um caminho de execução; todo programa começa com a thread principal (número 1). Várias threads rodam ao mesmo tempo em vários núcleos.
new Thread(…)cria,Start()começa (uma vez só),Join()espera;Thread.Sleeppausa a thread atual.- A ordem entre threads não é garantida e muda a cada execução.
- Threads de segundo plano morrem com o programa; as de primeiro plano o mantêm vivo.
- Duas threads mexendo na mesma variável causam condição de corrida:
contador++perde somas. UseInterlocked(oulock), ou evite variáveis compartilhadas. - Uma exceção não tratada numa thread derruba o programa. Em código novo, prefira
Task.Runeawait. No editor do guia, que tem uma thread só,new Threadnão funciona.