Pular para o conteúdo

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 éUseMedido
rede, disco, banco, sleepasync / awaitsobrepõe de verdade
várias tarefas de I/O, esperando todasparallelespera todas e propaga o erro
disparar e não esperarthread:não espera — e o erro sai na hora
CPU, em vários núcleosP.map_processos3,45× em 10 núcleos
CPU, com threadnão faça0,97×: perdeu da série
milhares de conexõesArcane.Laco2000 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.

dataforge
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çãoMedidoAvisa?
append de 4 threads, 5 mil vezes20.000 de 20.000não — o GIL protege a operação inteira
v["n"] := v["n"] + 133.740 de 40.000sim
remove, pop, insert, sortlê para decidir o que escreversim

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#

dataforge
// 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#

dataforge
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õesLaçoThread por conexão
100073 ms · 1 thread · +1 MB83 ms · 1000 threads · +36 MB
2000151 ms · 1 thread · +0 MB161 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.