Concorrência: qual das quatro
thread, parallel, async/await e processos — o que cada um resolve, o que nenhum resolve, e as medidas.
A pergunta chega sempre na mesma forma: "quero que isto rode junto". Há quatro respostas na linguagem, e escolher a errada não dá erro — dá um programa que fica mais lento, ou que perde dado em silêncio.
A tabela de decisão#
| O trabalho é | Use | Medido |
|---|---|---|
rede, disco, banco, sleep | async / await | sobrepõe de verdade |
| várias tarefas de I/O, esperando todas | parallel | espera todas e propaga o erro |
| disparar e não esperar | thread: | não espera — e o erro sai na hora |
| CPU, em vários núcleos | P.map_processos | 3,45× em 10 núcleos |
CPU, com thread | — não faça | 0,97×: perdeu da série |
| milhares de conexões | Arcane.Laco | 2000 conexões em 1 thread, +0 MB |
A linguagem não sincroniza sozinha#
Duas threads escrevendo na mesma variável perdem atualizações. Medido: 40.425 de 80.000. Sem erro, sem aviso do runtime.
adopt Arcane.Concurrent as C
trava := C.mutex()
total := 0
action somar(quanto):
action juntar():
total := total + quanto
// 'com_trava' toma, roda e SOLTA — inclusive quando a acao falha.
C.com_trava(trava, juntar)
parallel:
somar(10)
somar(20)
somar(30)
assert total is 60
// E para o caso mais comum — um numero que so cresce — nem precisa de
// trava: o contador ja e indivisivel.
c := C.contador()
parallel:
c.somar(1)
c.somar(1)
c.somar(1)
assert c.valor() is 3
out $"total {total}, contador {c.valor()}"O que o `check` pega, e o que ele não pega#
O aviso dispara quando um thread, um parallel ou uma `route` escreve num nome que vem de fora — inclusive na forma v["n"] := …, que é a que mais engana. A lista de métodos que disparam foi medida, não presumida:
| Operação | Medido | Avisa? |
|---|---|---|
append de 4 threads, 5 mil vezes | 20.000 de 20.000 | não — o GIL protege a operação inteira |
v["n"] := v["n"] + 1 | 33.740 de 40.000 | sim |
remove, pop, insert, sort | lê para decidir o que escrever | sim |
A análise para na fronteira da ação: seguir chamada exigiria um grafo, e um aviso que depende disso seria impreciso nos dois sentidos. E é aviso, não erro — um acumulador protegido por mutex passa por aqui igual, e recusá-lo proibiria o uso correto.
`parallel` espera; `thread` não#
// parallel: espera TODAS, e levanta na linha do bloco
monitor:
parallel:
out "a"
out "b"
handle Error as e:
out "alguma falhou:", e.message
out "aqui so chega depois das duas"Processos: o único caminho para mais de um núcleo#
adopt Arcane.Concurrent as P
action pesado(n):
total := 0
cycle i from 1 to n:
total := total + (i * i)
yield total
resultados := P.map_processos(pesado, [200000, 200000, 200000, 200000])
assert len(resultados) is 4
out $"{len(resultados)} blocos, cada um num nucleo"A ação atravessa por declaração, e não por fechamento: a árvore do corpo, os parâmetros e o que ela lê sem criar. Do outro lado, um interpretador novo remonta tudo. É o que faz um record devolvido de lá ser o mesmo tipo daqui — se fosse cópia, o with recusaria o próprio resultado.
O laço de eventos, e a fibra#
Arcane.Laco é uma thread dormindo no selectors do sistema. Medido, servidor de linha:
| Conexões | Laço | Thread por conexão |
|---|---|---|
| 1000 | 73 ms · 1 thread · +1 MB | 83 ms · 1000 threads · +36 MB |
| 2000 | 151 ms · 1 thread · +0 MB | 161 ms · 2000 threads · +36 MB |
O tempo quase empata, e esse é o número honesto. O que muda é a forma da conta: plano contra linear. E a fibra é sem pilha — um emit dentro de uma ação chamada não suspende. É por isso que a documentação diz fibra, e não green thread.