
Cada idioma que suporta computação paralela (competitiva, assíncrona) precisa de uma maneira de executar o código em paralelo. Aqui estão exemplos de diferentes APIs:
go myfunc(); // Golang pthread_create(&thread_id, NULL, &myfunc); /* C with POSIX threads */ spawn(modulename, myfuncname, []) % Erlang threading.Thread(target=myfunc).start() # Python with threads asyncio.create_task(myfunc()) # Python with asyncio
Existem muitas opções para notação e terminologia, mas uma semântica é executar o myfunc
em paralelo com o programa principal e continuar o encadeamento pai de execução (Eng. "Fluxo de Controle")
Outra opção são os retornos de chamada :
QObject::connect(&emitter, SIGNAL(event()), // C++ with Qt &receiver, SLOT(myfunc())) g_signal_connect(emitter, "event", myfunc, NULL) /* C with GObject */ document.getElementById("myid").onclick = myfunc; // Javascript promise.then(myfunc, errorhandler) // Javascript with Promises deferred.addCallback(myfunc) # Python with Twisted future.add_done_callback(myfunc) # Python with asyncio
E, novamente, a notação muda, mas todos os exemplos fazem com que, a partir do momento atual, se e quando um determinado evento acontecer, o myfunc
iniciado. Depois que o retorno de chamada é definido, o controle retorna e a função de chamada continua. (Às vezes, os retornos de chamada são agrupados em convenientes funções de combinação ou protocolos no estilo Twisted , mas a idéia básica é inalterada.)
E ... isso é tudo. Pegue qualquer linguagem de propósito geral de concorrência popular e provavelmente descobrirá que ela se enquadra em um desses paradigmas (às vezes ambos, assíncrono).
Mas não minha nova e estranha biblioteca Trio . Ela não usa essas abordagens. Em vez disso, se quisermos executar myfunc
e anotherfunc
em paralelo, escrevemos algo como isto:
async with trio.open_nursery() as nursery: nursery.start_soon(myfunc) nursery.start_soon(anotherfunc)
viveiro - viveiro, viveiro
Pela primeira vez diante do design do "berçário", as pessoas estão perdidas. Por que existe um gerenciador de contexto (com bloco)? O que é esse berçário e por que é necessário executar uma tarefa? As pessoas entendem que o berçário interfere com as abordagens usuais em outras estruturas e ficam com raiva. Tudo parece bizarro, específico e de alto nível para ser um primitivo básico. Todas essas são reações compreensíveis! Mas aguente um pouco.
Neste artigo, quero convencê-lo de que os viveiros não são uma moda passageira, mas uma nova primitiva para controlar o fluxo de execução, tão fundamental quanto loops e chamadas de função. Além disso, as abordagens discutidas acima (criando threads e registrando retornos de chamada) precisam ser descartadas e substituídas por viveiros.
Parece muito ousado? Mas isso já aconteceu: uma vez que o goto
amplamente usado para controlar o comportamento de um programa. Agora, esta é uma ocasião para rir:

Vários idiomas ainda possuem o chamado goto
, mas suas capacidades são muito mais limitadas do que o goto
original. E na maioria dos idiomas não é de todo. O que aconteceu com ele? Esta história é surpreendentemente relevante, embora não seja familiar para a maioria por causa de sua antiguidade. Vamos lembrar a nós mesmos o que goto
e depois ver como isso pode ajudar na programação assíncrona.
Sumário
- O que é ir?
- O que é ir?
- O que aconteceu com o goto?
- goto destrói abstração
- Admirável mundo novo sem ir
- Chega de ir
- Sobre os perigos das expressões do tipo "Go"
- expressões ir quebram abstrações.
- As expressões go quebram a limpeza automática de recursos abertos.
- As expressões ir quebram o tratamento de erros.
- Chega de ir
- O berçário como um substituto estrutural para o go
- O berçário mantém a abstração de funções.
- Suporte de berçário dinâmico adicionando tarefas.
- Você ainda pode deixar o berçário.
- Você pode identificar novos tipos que charlatanham como berçário.
- Não, no entanto, os viveiros estão sempre aguardando a conclusão de todas as tarefas internas.
- Trabalha limpeza automática de recursos.
- Trabalhos de criação de bugs.
- Admirável mundo novo sem ir
- Viveiros na prática
- Conclusões
- Comentários
- Agradecimentos
- Notas de rodapé
- Sobre o autor
- Continuação
O que é goto
?
Os primeiros computadores foram programados usando assembler , ou ainda mais primitivamente. Isso não é muito conveniente. Então, na década de 1950, pessoas como John Backus, da IBM, e Grace Hopper, de Remington Rand, começaram a desenvolver idiomas como FORTRAN e FLOW-MATIC (mais conhecido por seu descendente direto COBOL ).
O FLOW-MATIC era muito ambicioso na época. Você pode pensar nisso como o tataravô do Python - foi a primeira linguagem desenvolvida principalmente para as pessoas e a segunda para os computadores. Ele ficou assim:

Observe que, diferentemente das linguagens modernas, não há condicionais if
blocos, loops ou chamadas de função - na verdade, não há blocos ou recuos. Esta é apenas uma lista sequencial de expressões. Não porque este programa seja muito curto para exigir instruções de controle (além de JUMP TO
) - apenas essa sintaxe ainda não foi inventada!

Em vez disso, o FLOW-MATIC tinha duas opções para controlar o fluxo de execução. Geralmente o fluxo era consistente - comece de cima e desça, uma expressão de cada vez. Mas se você executar a expressão JUMP TO
especial, ela poderá assumir o controle em outro lugar. Por exemplo, a expressão (13) salta para a expressão (2):

Assim como aconteceu com as primitivas do paralelismo desde o início do artigo, não havia acordo sobre o que chamar de operação "dar um salto de mão única". Na lista, isso é JUMP TO
, mas goto
historicamente goto
raízes (como "vá lá"), que eu uso aqui.
Aqui está o conjunto completo de saltos de goto
neste pequeno programa:

Isso parece confuso não apenas para você! O FLOW-MATIC herdou esse estilo de programação baseado em salto diretamente do assembler. É poderoso, bem próximo de como o hardware do computador realmente funciona, mas é muito difícil trabalhar diretamente com ele. Essa bola de flechas é a razão da invenção do termo "código de espaguete".
Mas por que goto
causar esse problema? Por que algumas declarações de controle são boas e outras não? Como escolher os bons? Naquela época, era completamente incompreensível e, se você não entende o problema, é difícil resolvê-lo.
O que é go
?
Vamos desviar da nossa história. Todo mundo sabe que foi ruim, mas o que isso tem a ver com assincronia? Veja a famosa expressão go
do Golang, usada para gerar a nova "goroutine" (fluxo leve):
// Golang go myfunc();
É possível desenhar um diagrama de seu fluxo de execução? É um pouco diferente do diagrama acima, porque aqui o fluxo é dividido. Vamos desenhar assim:

As cores aqui servem para mostrar que os dois caminhos são escolhidos. Do ponto de vista da goroutine principal (linha verde) - o fluxo de controle é executado seqüencialmente: começa de cima e depois desce imediatamente. Enquanto isso, do ponto de vista da função descendente (linha lilás), o fluxo vem de cima e depois salta para o corpo de myfunc
. Diferente de uma chamada de função regular, há um salto unidirecional - a partir do myfunc
funcionamento, myfunc
para uma pilha completamente nova e o tempo de execução esquece imediatamente de onde viemos.
aparentemente eu quero dizer a pilha de chamadas
Mas isso não se aplica apenas a Golang. Este diagrama é válido para todas as primitivas (controles) listadas no início do artigo:
- As bibliotecas de encadeamento geralmente retornam algum tipo de objeto de controle que lhes permitirá ingressar no encadeamento posteriormente - mas esta é uma operação independente sobre a qual a própria linguagem não sabe nada. A primitiva para criar um novo encadeamento tem o diagrama mostrado acima.
- O registro de retorno de chamada é semanticamente equivalente à criação de um encadeamento em segundo plano (embora seja óbvio que a implementação é diferente), que:
a) é bloqueado até que um evento ocorra e, em seguida,
b) lança uma função de retorno de chamada
Portanto, em termos de operadores de controle de alto nível, o registro de retorno de chamada é uma expressão idêntica à anterior. - Com
Futures
e Promises
a mesma coisa - quando você executa a função e ela retorna Promise
, significa que planejou trabalhar em segundo plano e retorna um objeto de controle para obter o resultado mais tarde (se você desejar). Do ponto de vista da semântica de gerenciamento, é o mesmo que criar um fluxo. Depois disso, você passa o retorno de chamada para a Promis e depois como no parágrafo anterior.
Esse mesmo padrão se mostra de várias formas - a principal semelhança é que, em todos esses casos, o fluxo de controle é dividido - é feito um salto no novo encadeamento, mas o pai retorna para quem o chamou. Sabendo o que olhar, você o verá em todos os lugares! Este é um jogo interessante (pelo menos para alguns tipos de pessoas)!
Ainda assim, me incomoda que não haja um nome padrão para esta categoria de declarações de controle. Eu uso a expressão "go" para chamá-los, assim como "goto" se tornou um termo genérico para todas goto
expressões do goto
. Porque go
Uma razão é que Golang nos dá um exemplo muito claro dessa sintaxe. E o outro é:

Observe a semelhança? É isso mesmo - go
é uma das formas de goto
.
Programas assíncronos são notórios pela dificuldade de escrever e analisar. Bem como programas baseados em goto
. Os problemas causados pelo goto
resolvidos principalmente em idiomas modernos. Se aprendermos a corrigir o goto
, isso ajudará a criar APIs assíncronas mais convenientes? Vamos descobrir!
O que aconteceu com o goto
?
Então, o que há de errado com o goto
que causa tantos problemas? No final dos anos 60, Edsger Wieb Dijkstra escreveu algumas obras agora conhecidas que ajudaram a entender isso muito mais claramente: os argumentos contra o operador goto e o Notes sobre programação estrutural .
goto
destrói abstração
Nesses trabalhos, Dijkstra se preocupou com a forma como escrevemos programas não triviais e garantimos sua correção. Existem muitos pontos interessantes. Por exemplo, você provavelmente ouviu esta frase:
Os programas de teste podem mostrar a presença de erros, mas nunca a ausência deles.
Sim, isso é das Notas de programação estruturais . Mas sua principal preocupação era abstração . Ele queria escrever programas grandes demais para mantê-los na cabeça. Para fazer isso, você deve tratar as partes do programa como caixas pretas - por exemplo, você vê este programa em Python:
print("Hello World!")
e você não precisa conhecer todos os detalhes de como a print
(formatação da linha, buffer, diferenças entre plataformas etc.). Tudo o que você precisa saber é que print
alguma forma imprime o texto que você passou, e você pode se concentrar no que deseja fazer neste pedaço de código. Dijkstra queria que os idiomas suportassem esse tipo de abstração.
Nesse ponto, a sintaxe do bloco foi inventada e linguagens como o ALGOL acumularam ~ 5 tipos diferentes de instruções de controle: eles ainda tinham um encadeamento seqüencial de execução e goto
:

E também adquiriu condições, ciclos e chamadas de função:

Você pode implementar essas construções de alto nível usando goto
, e é assim que as pessoas pensavam nelas antes: como um atalho conveniente. Mas Dijkstra apontou a grande diferença entre o goto
e o restante dos operadores de controle. Para tudo, exceto goto
, o fio da execução
- vem de cima => [algo acontece] => o fluxo vem de baixo
Podemos chamar isso de “regra da caixa preta” - se a estrutura de controle (operador de controle) tiver esse formato, em uma situação em que você não esteja interessado nos detalhes internos, poderá ignorar a parte “algo acontece” e tratar o bloco como um seqüencial regular equipe. Melhor ainda, isso é verdade para qualquer código que é composto desses blocos. Quando olho para:
print("Hello World!")
Não preciso ler as fontes de print
e todas as suas dependências para entender para onde o thread de execução irá. Talvez dentro da print
exista um loop, e nele haja uma condição na qual existe uma chamada para outra função ... isso não é importante - eu sei que o encadeamento será print
, a função fará seu trabalho e, eventualmente, o encadeamento retornará ao código que eu Eu leio
Mas se você tem uma linguagem com goto
- uma linguagem em que funções e tudo o mais são construídas com base em goto
, e goto
pode pular para qualquer lugar, a qualquer momento -, essas estruturas não são caixas pretas! Se você tem uma função com um loop, dentro do qual existe uma condição e dentro dela há goto
... então esse goto
pode passar a execução em qualquer lugar. Talvez o controle repentinamente retorne completamente de outra função que você nem chamou! Você não sabe!
E isso quebra a abstração - qualquer função pode ter um potencial de goto
dentro, e a única maneira de descobrir se esse é o caso é manter em mente todo o código fonte do seu sistema. Depois que o idioma for goto
, você não poderá prever o fluxo de execução. É por isso que o goto
leva ao código do espaguete.
E assim que Dijkstra entendeu o problema, ele foi capaz de resolvê-lo. Aqui está sua suposição revolucionária - não devemos pensar em condições / loops / chamadas de função como abreviações para goto
, mas como primitivas fundamentais de nossos direitos - e devemos remover completamente goto
de nossas línguas.
A partir de 2018, isso parece bastante óbvio. Mas como os programadores reagem quando você tenta pegar seus brinquedos inseguros? Em 1969, a proposta de Dijkstra parecia incrivelmente dúbia. Donald Knuth defendeu goto
. As pessoas que se tornaram especialistas em codificação com goto
estavam justamente indignadas por terem que reaprender a expressar suas idéias em termos novos e mais restritivos. E, claro, foi preciso criar linguagens completamente novas.
Como resultado, as línguas modernas são um pouco menos rigorosas do que as palavras originais de Dijkstra.

Esquerda: goto
tradicional. Direita: goto
domesticado, como em C, C #, Golang, etc. Não cruzar os limites de uma função significa que ele ainda pode fazer xixi nos sapatos, mas é improvável que você se rasgue.
Eles permitem que você pule os níveis de aninhamento de instruções de controle estrutural usando instruções de break
, continue
ou return
. Mas, em um nível básico, todos eles são construídos em torno da ideia de Dijkstra e podem interromper o fluxo seqüencial de execução de maneira estritamente limitada. Em particular, funções - uma ferramenta fundamental para envolver um segmento de execução em uma caixa preta - são indestrutíveis. Você não pode executar o comando break
de uma função para outra e o return
não pode retornar você além da função atual. Nenhuma manipulação do encadeamento de execução dentro da função afetará outras funções.
E os idiomas que preservavam o operador goto
(C, C #, Golang, ...) o limitavam severamente. No mínimo, eles não permitem que você pule do corpo de uma função para outra. Se você não estiver usando o Assembler [2], o goto
clássico e ilimitado é coisa do passado. Dijkstra venceu.
Admirável mundo novo sem ir
Algo interessante aconteceu com o desaparecimento do goto
- os criadores da linguagem puderam começar a adicionar novos recursos com base em um fluxo estruturado de execução.
Por exemplo, o Python tem uma sintaxe legal para limpar automaticamente os recursos - um gerenciador de contexto . Você pode escrever:
e isso garante que o arquivo seja aberto em tempo de execução some code
mas depois disso - imediatamente fechado. A maioria das linguagens modernas possui equivalentes ( RAII , using
, try-with-resource, defer
, ...). E todos assumem que o fluxo de controle está em ordem. E o que acontece se pularmos no bloco with
usando goto
? O arquivo está aberto ou não? E se pularmos de lá em vez de sairmos como de costume?
após a conclusão do código dentro do bloco, inicia o __exit__()
que fecha recursos abertos, como arquivos e conexões.
O arquivo será fechado? Além disso, os gerentes de contexto simplesmente não funcionam de maneira clara.
O mesmo problema com o tratamento de erros - quando algo dá errado, o que o código deve fazer? Frequentemente - envie uma descrição do erro na pilha (de chamadas) para o código de chamada e deixe-o decidir o que fazer. As linguagens modernas têm construções específicas para isso, como exceções ou outras formas de geração automática de erros . Mas essa ajuda só está disponível se o idioma tiver uma pilha de chamadas e um conceito robusto de "chamada". Lembre-se do espaguete no exemplo de fluxo no exemplo FLOW-MATIC e imagine a exceção lançada no meio. Onde isso pode vir?
Chega de goto
Portanto, o goto
tradicional - que ignora os limites das funções - é ruim não apenas porque é difícil de usar corretamente. Se apenas isso, goto
poderia ter ficado - muitas construções de linguagem ruim permaneceram.
Mas até o próprio recurso de goto
para o idioma torna tudo mais complicado. Bibliotecas de terceiros não podem ser consideradas uma caixa preta - sem conhecer a fonte, você não consegue descobrir quais funções são normais e quais imprevisivelmente controlam o fluxo de execução. Este é um grande obstáculo para prever o comportamento do código local. Recursos poderosos, como gerenciadores de contexto e pop-ups automáticos de erros, também são perdidos. É melhor remover o goto
completamente, em favor dos operadores de controle que suportam a regra da caixa preta.
Sobre os perigos de expressões como "Go"
Então, vimos a história do goto
. Mas é aplicável ao operador go
? Bem ... apesar de tudo! A analogia é chocantemente precisa.
expressões ir quebram abstrações.
Lembre-se de como dissemos que, se a linguagem permitir goto
, qualquer função pode ocultar goto
em si mesma? Na maioria das estruturas assíncronas, as expressões go
levam ao mesmo problema - qualquer função pode (ou não) executar a tarefa em segundo plano. Parece que a função retornou o controle, mas ainda funciona em segundo plano? E não há como descobrir sem ler a fonte da função e tudo o que ela chama. E quando isso vai acabar? Difícil dizer. Se você go
seus análogos, as funções não serão mais caixas negras que respeitam o fluxo de execução. No meu primeiro artigo sobre APIs assíncronas , chamei isso de “violação de causalidade” e descobri que essa é a causa raiz de muitos problemas reais e comuns em programas que usam asyncio
e Twisted, como problemas de controle de fluxo, problemas com desligamentos adequados etc.
Isso se refere ao controle do fluxo de dados que entra e sai do programa. Por exemplo, o programa recebe dados a uma velocidade de 3 MB / s e sai a uma velocidade de 1 MB / s, e, portanto, o programa consome cada vez mais memória, consulte outro artigo do autor
As expressões go quebram a limpeza automática de recursos abertos.
Vamos dar uma olhada em um exemplo with
instrução novamente:
Anteriormente, dissemos que estávamos "garantidos" de que o arquivo seria aberto enquanto some code
funcionando e fechado depois. Mas e se some code
iniciar uma tarefa em segundo plano? : , , with
, with
, , , . , ; , , some code
.
, , - , , .
, Python threading
— , , — , with
, , , ( ). , . , .
go- .
, , (exceptions), . " ". , . , , . , , … , . , - . ( , , " - " — ; .) Rust — , , - — . (thread) , Rust .
, , join , errbacks Twisted Promise.catch Javascript . , , . , Traceback . Promise.catch
.
, .
go
, goto
, go- — , , , . , goto
, , go
.
, , ! :
, Trio .
go
: , , , . , , :

, , , " " .
? " " ,
) , , ( ),
) , .
. , - . , .. [3]
: , , , "" , . Trio , async with
:

, as nursery
nursery
. nursery.start_soon()
, () : myfunc
anotherfunc
. . , , () , , .

, , — , , . , .
, .
:

, . Aqui estão alguns deles:
.
go- — , , , . — , , . , , .
.
, . :
run_concurrently([myfunc, anotherfunc])
async.gather Python, Promise.all Javascript, ..
, , , . , accept
, .
accept
Trio:
async with trio.open_nursery() as nursery: while True: incoming_connection = await server_socket.accept() nursery.start_soon(connection_handler, incoming_connection)
, , run_concurrently
. , run_concurrently
— , , run_concurrently
, .
.
. , , ? : . , async with open_nursery()
nursery.start_soon()
, — [4], , , . , , .
, , " ", :
, .
, , go-, .
, .
, - . , , . :
async with my_supervisor_library.open_supervisor() as nursery_alike: nursery_alike.start_soon(...)
, , . .
Trio , asyncio
: start_soon()
, Future
( , Future
). , ( , Trio Future
!), .
, , .
, , — — .
Trio, . , , " " ( ), Cancelled
. , , — - , " ", , .. , , . , , , .
.
" ", with
. , with
, .
.
, , . .
Trio, , … - . , . , — " " — , myfunc
anotherfunc
, . , , .
, : (re-raise) , . ,
" " , , , , , .
, , . ?
— ( ) , . , , , , .
, , - ( task cancellation ). C# Golang, — .
go
goto
, with
; go
- . Por exemplo:
- , , , . ( : - )
- — Python ,
ctrl-C
( ). , .
, . ?
… : ! , , . , , , break
continue
.
, . — , 1970 , goto
.
. (Knuth, 1974, .275):
, goto
, , " " goto
. goto
! , , goto
, . , , . , — , — "goto" .
: . , , . , , . , , .
, Happy Eyeballs ( RFC 8305 ), TCP . , — , , . Twisted — 600 Python . 15 . , , , . , , . , . ? . .
Conclusões
— go
, , , Futures
, Promises
,… — goto
, . goto
, -- goto
, . , , ; , . , goto
, .
, , ( CTRL+C
) , .
, , , , — , goto
. FLOW-MATIC , , - . , , Trio , , .
Comentários
Trio .
:
Trio : https://trio.discourse.group/
Agradecimentos
Graydon Hoare, Quentin Pradet, and Hynek Schlawack . , , .
berez .
: FLOW-MATIC (PDF), .
Wolves in Action, Martin Pannier, CC-BY-SA 2.0 , .
, Daniel Borker, CC0 public domain dedication .
[2] WebAssembly , goto
: ,
[3] , , , , :
The "parallel composition" operator in Cooperating/Communicating Sequential Processes and Occam, the fork/join model, Erlang supervisors, Martin Sústrik's libdill , crossbeam::scope / rayon::scope Rust. golang.org/x/sync/errgroup github.com/oklog/run Golang.
, - .
[4] start_soon()
, , start_soon
, , , . , .
Nathaniel J. Smith , Ph.D., UC Berkeley numpy
, Python . Nathaniel .
:
, , , Haskell , , .
( , 0xd34df00d , ) , ( Happy Eyeballs ), .
, Trio ? Haskell Golang ?
: