Sobre a estrutura da computação paralela ou os argumentos contra o operador "Go"


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:


 # Python with open("my-file") as file_handle: some code 

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:


 # Python with open("my-file") as file_handle: some code 

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 .


, , ! :


  • go -, , " ",
  • , 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 ?


:

Source: https://habr.com/ru/post/pt479186/


All Articles