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.

Você vai aprender
  • O que é a thread principal e como ver as informações dela (Thread.CurrentThread).
  • Criar uma thread com new Thread, começá-la com Start e esperá-la com Join.
  • 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 com Interlocked.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.

No editor do guia, uma thread só

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 (namespace System.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.CurrentManagedThreadId dá 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 Contar ao 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. (O sed do terminal trocou o true por false no 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:

PassoThread AThread Bcontador
1lê 4141
2lê 4141
3soma: 42, grava 4242
4soma: 42, grava 4242

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 existem Interlocked.Decrement (tira 1), Interlocked.Add(ref x, n) (soma n) e Interlocked.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 rodaUma thread nova, só suaUma thread do pool, reaproveitada
Custo para começarAltoBaixo
ResultadoNão devolve (use uma variável)Task<T>, com await
EsperarJoin(), que trava a threadawait, que não trava
ExceçãoDerruba o programa (veja abaixo)Fica guardada na tarefa e volta no await
PlanoPrimeiro plano, por padrãoSegundo plano
Use paraTrabalhos 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

Uma exceção numa thread derruba o programa inteiro
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 programa terminou e o trabalho ficou pela metade

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ódigoO que faz
Thread.CurrentThreadA thread atual (Name, ManagedThreadId, IsBackground, IsThreadPoolThread)
Environment.CurrentManagedThreadIdO número da thread atual
Environment.ProcessorCountQuantos 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 = trueSegundo 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:

Thread t = new Thread(() => Trabalhar()); t.;

2. Esperar

Complete para a thread principal esperar a t terminar:

t.;

3. Somar sem corrida

Complete para somar 1 ao total de forma segura entre threads:

Interlocked.(ref total);

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.Sleep pausa 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. Use Interlocked (ou lock), ou evite variáveis compartilhadas.
  • Uma exceção não tratada numa thread derruba o programa. Em código novo, prefira Task.Run e await. No editor do guia, que tem uma thread só, new Thread não funciona.