Mecânica Quântica de Cálculos em JS

Olá, meu nome é Dmitry Karlovsky e eu ... desempregado. Portanto, tenho muito tempo livre para tocar música, esportes, criatividade, idiomas, conferências em JS e ciência da computação. Vou falar sobre as pesquisas mais recentes no campo da divisão semiautomática de cálculos longos em pequenos quanta de vários milissegundos, o que resultou em uma biblioteca em miniatura $mol_fiber . Mas primeiro, vamos descrever os problemas que resolveremos ..


Quanta!


Esta é uma versão em texto da performance de mesmo nome no HolyJS 2018 Piter . Você pode lê-lo como um artigo ou abri-lo na interface de apresentação ou assistir a um vídeo .


Problema: Baixa capacidade de resposta


Se queremos ter 60 quadros por segundo estáveis, temos apenas 16 com uma ninharia de milissegundos para fazer todo o trabalho, incluindo o que o navegador faz para mostrar os resultados na tela.


Mas e se tomarmos o fluxo por mais tempo? Em seguida, o usuário observará uma interface atrasada, inibindo a animação e similares da degradação do UX.


Baixa capacidade de resposta


Problema: Não há escapatória


Acontece que, enquanto realizamos os cálculos, o resultado não é mais interessante para nós. Por exemplo, temos um pergaminho virtual, o usuário o puxa ativamente, mas não podemos acompanhá-lo e não podemos renderizar a área real até que a renderização anterior retorne o controle para processar eventos do usuário.


Não pode ser desfeito


Idealmente, não importa quanto tempo trabalhemos, devemos continuar processando eventos e poder cancelar a qualquer momento o trabalho que começamos, mas ainda não concluímos.


Eu sou rápido e sei disso


Mas e se o nosso trabalho não for um, mas vários, mas um fluxo? Imagine que você dirige com o lótus amarelo recém-adquirido e vai até a travessia ferroviária. Quando está livre, você pode colocá-lo em uma fração de segundo. Mas ..


Carro legal


Problema: sem simultaneidade


Quando a travessia é ocupada por um trem de um quilômetro, é preciso ficar de pé e esperar dez minutos até que ela passe. Não é por isso que você comprou um carro esportivo, certo?


Espera rápida lenta


E como seria legal se este trem fosse dividido em 10 trens de 100 metros cada e houvesse vários minutos entre eles para passar! Você não chegaria tão tarde então.


Então, quais são as soluções para esses problemas no mundo JS agora?


Solução: Trabalhadores


A primeira coisa que vem à mente: vamos colocar todos os cálculos complexos em um segmento separado? Para fazer isso, temos um mecanismo para WebWorkers.


Lógica dos Trabalhadores


Eventos do fluxo da interface do usuário são passados ​​para o trabalhador. Lá eles são processados ​​e as instruções sobre o que e como alterar na página já são passadas de volta. Assim, salvamos o fluxo da interface do usuário de uma grande camada de computação, mas nem todos os problemas são resolvidos dessa maneira e, além disso, novos são adicionados.


Trabalhadores: problemas: (des) serialização


A comunicação entre fluxos ocorre enviando mensagens serializadas em um fluxo de bytes, transferidas para outro fluxo e, em seguida, são analisadas em objetos. Tudo isso é muito mais lento que uma chamada de método direta em um único encadeamento.


(Des) serialização


Trabalhadores: problemas: somente assíncrono


As mensagens são transmitidas estritamente de forma assíncrona. E isso significa que alguns recursos solicitados não estão disponíveis. Por exemplo, você não pode parar a ascensão de um evento de interface do usuário de um trabalhador, pois quando o manipulador é iniciado, o evento no encadeamento da interface do usuário já completará seu ciclo de vida.


Filas de mensagens


Trabalhadores: problemas: APIs limitadas


As seguintes APIs não estão disponíveis para nós nos trabalhadores.


  • DOM, CSSOM
  • Tela
  • Localização geográfica
  • História e Localização
  • Sincronizar solicitações http
  • XMLHttpRequest.responseXML
  • Janela

Trabalhadores: Problemas: Não é possível cancelar


E, novamente, não temos como parar os cálculos no woker.


Pare com isso!


Sim, podemos parar o trabalhador inteiro, mas isso interromperá todas as tarefas nele.
Sim, você pode executar cada tarefa em um trabalhador separado, mas consome muitos recursos.


Solução: Reagir fibra


Certamente muitos ouviram o FaceBook reescrever heroicamente o React, dividindo todos os cálculos nele em um monte de pequenas funções lançadas por um agendador especial.


Tricky React Fiber Logic


Não entrarei em detalhes de sua implementação, pois esse é um grande tópico separado. Anotarei apenas alguns recursos, pelos quais pode não ser adequado para você.


Reagir fibra: Reagir necessário


Obviamente, se você usar Angular, Vue ou outra estrutura diferente de React, o React Fiber será inútil para você.


Reagir Everywere!


React Fiber: Somente renderização


React - cobre apenas a camada de renderização. Todas as outras camadas do aplicativo são deixadas sem quantização.


Não é tão rápido!


O React Fiber não o salvará quando você precisar, por exemplo, filtrar um grande bloco de dados por condições difíceis.


React Fiber: A quantização está desativada


Apesar do suporte alegado para quantização, ele ainda está desativado por padrão, pois quebra a compatibilidade com versões anteriores.


Armadilha de marketing


A quantização no React ainda é uma coisa experimental. Cuidado!


Reagir fibra: depuração é dor


Quando você ativa a quantização, o callstack não corresponde mais ao seu código, o que complica bastante a depuração. Mas retornaremos a esta questão.


Toda a dor da depuração


Solução: quantização


Vamos tentar generalizar a abordagem React Fiber para se livrar das desvantagens mencionadas. Queremos permanecer em um fluxo, mas dividimos cálculos longos em pequenas quanta, entre as quais o navegador pode processar as alterações já feitas na página e responderemos a eventos.


gráficos de chama


Acima, você vê um longo cálculo que parou o mundo inteiro em mais de 100ms. E de baixo - o mesmo cálculo, mas dividido em fatias de tempo de cerca de 16ms, o que deu uma média de 60 quadros por segundo. Como geralmente não sabemos quanto tempo os cálculos levarão, não podemos dividi-lo manualmente em 16ms de antecedência. portanto, precisamos de algum tipo de mecanismo de tempo de execução que mede o tempo necessário para concluir a tarefa e quando o quantum é excedido, o que interrompe a execução até o próximo quadro de animação. Vamos pensar em quais mecanismos temos para implementar tarefas suspensas aqui ..


Concorrência: fibras - stackfull coroutines


Em idiomas como Go e D, existe um idioma como uma "corotina com uma pilha", também é uma "fibra" ou "fibra".


 import { Future } from 'node-fibers' const one = ()=> Future.wait( future => setTimeout( future.return ) ) const two = ()=> one() + 1 const three = ()=> two() + 1 const four = ()=> three() + 1 Future.task( four ).detach() 

No exemplo de código, você vê a one função, que pode pausar a fibra atual, mas possui uma interface completamente síncrona. As funções two , three e four são funções síncronas regulares que não sabem nada sobre fibra. Neles, você pode usar todos os recursos do javascript na íntegra. E, finalmente, na última linha, simplesmente executamos as four funções em uma fibra separada.


O uso de fibras é bastante conveniente, mas para suportá-las, você precisa de suporte em tempo de execução, que a maioria dos intérpretes JS não possui. No entanto, para o NodeJS, há uma extensão nativa node-fibers que adiciona esse suporte. Infelizmente, nenhum navegador está disponível em nenhum navegador.


Simultaneidade: FSM - coroutines sem pilha


Em linguagens como C # e agora JS, há suporte para "coroutines sem pilha" ou "funções assíncronas". Tais funções são uma máquina de estado sob o capô e não sabem nada sobre a pilha; portanto, elas precisam ser marcadas com a palavra-chave especial "async", e os locais onde podem ser pausadas são "aguardam".


 const one = ()=> new Promise( done => setTimeout( done ) ) const two = async ()=> ( await one() ) + 1 const three = async ()=> ( await two() ) + 1 const four = async ()=> ( await three() ) + 1 four() 

Como podemos precisar adiar o cálculo a qualquer momento, acontece que quase todas as funções no aplicativo precisarão ser assíncronas. Isso não é apenas a complexidade do código, mas também afeta muito o desempenho. Além disso, muitas APIs de aceitação de retorno de chamada ainda não oferecem suporte a retornos de chamada assíncronos. Um exemplo impressionante é o método de reduce de qualquer matriz.


Simultaneidade: semi-fibras - reinicia


Vamos tentar fazer algo semelhante à fibra, usando apenas os recursos disponíveis em qualquer navegador moderno.


 import { $mol_fiber_async , $mol_fiber_start } from 'mol_fiber/web' const one = ()=> $mol_fiber_async( back => setTimeout( back ) ) const two = ()=> one() + 1 const three = ()=> two() + 1 const four = ()=> three() + 1 $mol_fiber_start( four ) 

Como você pode ver, as funções intermediárias não sabem nada sobre interrupção - isso é JS comum. Somente a one função sabe da possibilidade de suspensão. Para abortar o cálculo, ela simplesmente lança Promise como uma exceção. Na última linha, executamos a função four em uma pseudo-fibra separada, que monitora as exceções lançadas no interior e, se o Promise chegar, assina sua resolve e reinicia a fibra.


Figuras


Para mostrar como as pseudo-fibras funcionam, escreveremos um código complicado.


Gráfico de execução típico


Vamos imaginar que a função step aqui grave algo no console e faça algum outro trabalho duro por 20ms. E a função walk chama o step duas vezes, registrando todo o processo. No meio, ele mostrará o que agora é exibido no console. E à direita está o estado da árvore de pseudo-fibra.


$ mol_fiber: sem quantização


Vamos executar esse código e ver o que acontece ..


Execução sem quantização


Até agora, tudo é simples e óbvio. A árvore de pseudo-fibra, é claro, não está envolvida. E tudo ficaria bem, mas esse código é executado por mais de 40 ms, o que é inútil.


$ mol_fiber: cache primeiro


Vamos agrupar as duas funções em um invólucro especial que o executa em uma pseudo-fibra e ver o que acontece.


Caches de preenchimento


Aqui vale a pena prestar atenção ao fato de que para cada local de chamada da one função dentro da fibra de walk , uma fibra separada foi criada. O resultado da primeira chamada foi armazenado em cache, mas, em vez da segunda, a Promise foi lançada, pois tínhamos esgotado nosso tempo.


$ mol_fiber: cache segundo


Lançada no primeiro quadro, o Promise será resolvido automaticamente no próximo, o que levará ao reinício da fibra de walk .


Reutilização de cache


Como você pode ver, devido ao reinício, produzimos novamente “start” e “first done” para o console, mas “first begin” não está mais lá, pois está na fibra com o cache preenchido anteriormente, razão pela qual seu manipulador é mais não chamado. Quando o cache da fibra de walk é preenchido, todas as fibras incorporadas são destruídas, pois a execução nunca as alcançará.


Então, por que você first begin imprimir uma vez e first done duas? É tudo sobre idempotência. console.log - operação não idempotente, quantas vezes você o chama, tantas vezes ele adiciona uma entrada ao console. Mas a fibra que está sendo executada em outra fibra é idempotente, apenas executa o identificador na primeira chamada e, nos retornos subsequentes, imediatamente o resultado do cache, sem levar a efeitos colaterais adicionais.


$ mol_fiber: idempotência primeiro


Vamos agrupar console.log em uma fibra, tornando-a idempotente e ver como o programa se comporta.


preenchendo caches idempotentes


Como você pode ver, agora na árvore de fibras temos entradas para cada chamada para a função de log .


$ mol_fiber: idempotência em segundo


Na próxima reinicialização da fibra walk , chamadas repetidas para a função log não levam mais a chamadas para o console.log real, mas assim que chegamos à execução das fibras com um cache vazio, as chamadas para console.log retomadas.


Reutilizando Caches Idempotentes


Observe que no console agora não exibimos nada supérfluo - exatamente o que seria exibido no código síncrono sem fibra e quantificação.


$ mol_fiber: break


Como o cálculo é interrompido? No início do quantum, um prazo é definido. E antes de iniciar cada fibra, é verificado se a alcançamos. E se você chegar, então Promise corre, o que é resolvido no próximo quadro e inicia um novo quantum.


 if( Date.now() > $mol_fiber.deadline ) { throw new Promise( $mol_fiber.schedule ) } 

$ mol_fiber: prazo


O prazo para o quantum é fácil de definir. 8 milissegundos são adicionados ao horário atual. Por que exatamente 8, porque existem até 16 para preparar a foto? O fato é que não sabemos antecipadamente quanto tempo o navegador precisará renderizar, portanto, precisamos deixar algum tempo para que ele funcione. Mas às vezes acontece que o navegador não precisa renderizar nada e, com 8 ms de quanta, podemos inserir outro quantum no mesmo quadro, o que fornecerá um pacote denso de quanta com tempo de inatividade mínimo do processador.


 const now = Date.now() const quant = 8 const elapsed = Math.max( 0 , now - $mol_fiber.deadline ) const resistance = Math.min( elapsed , 1000 ) / 10 // 0 .. 100 ms $mol_fiber.deadline = now + quant + resistence 

Mas se lançarmos uma exceção a cada 8ms, a depuração com a exceção ativada se transformará em um pequeno ramo do inferno. Precisamos de algum mecanismo para detectar esse modo de depurador. Infelizmente, isso só pode ser entendido indiretamente: uma pessoa leva cerca de um segundo para entender se deve continuar executando ou não. E isso significa que, se o controle não retornar ao script por muito tempo, o depurador será interrompido ou haverá um cálculo pesado. Para sentar em ambas as cadeiras, adicionamos ao quantum 10% do tempo decorrido, mas não mais que 100 ms. Isso não afeta muito o FPS, mas reduz a frequência de parada do depurador em uma ordem de magnitude devido à quantização.


Depuração: try / catch


Como estamos falando sobre depuração, o que você acha, em que lugar desse código o depurador para?


 function foo() { throw new Error( 'Something wrong' ) // [1] } try { foo() } catch( error ) { handle( error ) throw error // [2] } 

Como regra, ele precisa parar onde a exceção é lançada pela primeira vez, mas a realidade é que ele para apenas onde foi lançada pela última vez, o que geralmente é muito longe de onde ocorreu. Portanto, para não complicar a depuração, as exceções nunca devem ser capturadas através do try-catch. Mas mesmo sem manipulação de exceção é impossível.


Depuração: eventos não manipulados


Normalmente, um tempo de execução fornece um evento global que ocorre para cada exceção não capturada.


 function foo() { throw new Error( 'Something wrong' ) } window.addEventListener( 'error' , event => handle( event.error ) ) foo() 

Além da inconveniência, essa solução tem uma desvantagem que todas as exceções se enquadram aqui e é bastante difícil entender de qual fibra e fibra se o evento ocorreu.


Depuração: Promessa


Promessas são a melhor maneira de lidar com exceções.


 function foo() { throw new Error( 'Something wrong' ) } new Promise( ()=> { foo() } ).catch( error => handle( error ) ) 

A função passada para o Promise é chamada imediatamente, de forma síncrona, mas a exceção não é capturada e para com segurança o depurador no lugar de sua ocorrência. Um pouco mais tarde, de forma assíncrona, ele já chama o manipulador de erros, no qual sabemos exatamente qual fibra causou a falha e qual falha. Este é precisamente o mecanismo usado no $ mol_fiber.


Rastreamento de pilha: reagir fibra


Vamos dar uma olhada no rastreamento de pilha que você obtém no React Fiber ..


Stackrace vazio


Como você pode ver, temos muita reação no estômago. Do útil aqui, apenas o ponto de ocorrência da exceção e os nomes dos componentes são mais altos na hierarquia. Não muito.


Rastreamento de pilha: $ mol_fiber


No $ mol_fiber, obtemos um rastreamento de pilha muito mais útil: sem tripas, apenas pontos específicos no código do aplicativo através do qual houve uma exceção.


Conteúdo strace


Isto é conseguido através do uso da pilha nativa, promessas e remoção automática dos intestinos. Se desejar, você pode expandir o erro no console, como na captura de tela, e ver as entranhas, mas não há nada de interessante.


$ mol_fiber: handle


Então, para interromper um quantum, Promise é lançado.


 limit() { if( Date.now() > $mol_fiber.deadline ) { throw new Promise( $mol_fiber.schedule ) } // ... } 

Mas, como você pode imaginar, Promise pode ser absolutamente qualquer coisa - para uma fibra, de um modo geral, não importa o que esperar: o próximo quadro, a conclusão do carregamento de dados ou algo mais.


 fail( error : Error ) { if( error instanceof Promise ) { const listener = ()=> self.start() return error.then( listener , listener ) } // ... } 

O Fiber simplesmente se inscreve para resolver promessas e reinicializações. Mas não é necessário lançar e receber promessas manualmente, porque o pacote inclui vários invólucros úteis.


$ mol_fiber: funções


Para transformar qualquer função síncrona em uma fibra idempotente, envolva-a em $mol_fiber_func ..


 import { $mol_fiber_func as fiberize } from 'mol_fiber/web' const log = fiberize( console.log ) export const main = fiberize( ()=> { log( getData( 'goo.gl' ).data ) } ) 

Aqui, tornamos o console.log idempotente e o main ensinou a interromper enquanto aguardava o download.


$ mol_fiber: tratamento de erros


Mas como responder a exceções se não queremos usar o try-catch ? Em seguida, podemos registrar o manipulador de erros com $mol_fiber_catch ...


 import { $mol_fiber_func as fiberize , $mol_fiber_catch as onError } from 'mol_fiber' const getConfig = fiberize( ()=> { onError( error => ({ user : 'Anonymous' }) ) return getData( '/config' ).data } ) 

Se retornarmos algo diferente do erro nele, será o resultado da fibra atual. Neste exemplo, se não for possível fazer o download da configuração do servidor, a função getConfig retornará a configuração por padrão.


$ mol_fiber: métodos


Obviamente, você pode agrupar não apenas funções, mas também métodos usando um decorador.


 import { $mol_fiber_method as action } from 'mol_fiber/web' export class Mover { @action move() { sendData( 'ya.ru' , getData( 'goo.gl' ) ) } } 

Aqui, por exemplo, carregamos dados do Google e no Yandex.


$ mol_fiber: promessas


Para baixar dados do servidor, basta levar, por exemplo, a fetch assíncrona da função e, com um movimento do pulso, transformá-la em síncrona.


 import { $mol_fiber_sync as sync } from 'mol_fiber/web' export const getData = sync( fetch ) 

Essa implementação é boa para todos, mas não suporta o cancelamento de uma solicitação quando uma árvore de fibras é destruída; portanto, precisamos usar uma API mais confusa.


$ mol_fiber: solicitação de cancelamento


 import { $mol_fiber_async as async } from 'mol_fiber/web' function getData( uri : string ) : Response { return async( back => { var controller = new AbortController(); fetch( uri , { signal : controller.signal } ).then( back( res => res ) , back( error => { throw error } ) , ) return ()=> controller.abort() } ) } 

A função passada para o wrapper async é chamada apenas uma vez e o wrapper de back é passado para ele, no qual é necessário agrupar os retornos de chamada. Portanto, nesses retornos de chamada, você deve retornar o valor ou lançar uma exceção. Qualquer que seja o resultado do retorno de chamada, ele também será o resultado da fibra. Observe que, no final, retornamos uma função que será chamada em caso de destruição prematura da fibra.


$ mol_fiber: cancelar resposta


No lado do servidor, também pode ser útil cancelar o cálculo quando o cliente cair. Vamos implementar um wrapper sobre o midleware que criará uma fibra na qual o midleware original será executado. , , , .


 import { $mol_fiber_make as Fiber } from 'mol_fiber' const middle_fiber = middleware => ( req , res ) => { const fiber = Fiber( ()=> middleware( req , res ) ) req.on( 'close' , ()=> fiber.destructor() ) fiber.start() } app.get( '/foo' , middle_fiber( ( req , res ) => { // do something } ) ) 

$mol_fiber: concurrency


, . , 3 : , , - ..


Solicitações rápidas e lentas


: , . . , , .


$mol_fiber: properties


, ..


Pros:
  • Runtime support isn't required
  • Can be cancelled at any time
  • High FPS
  • Concurrent execution
  • Debug friendly
  • ~ 3KB gzipped


Cons:
  • Instrumentation is required
  • All code should be idempotent
  • Longer total execution

$mol_fiber — , . — , . , , . , , , , . , . .


Links



Call back


Comentários


: , , )


: , .


: . , .


: . , . , .


: , . , )


: , .


: - . , , .


: . , , .


: , . 16ms, ? 16 8 , 8, . , . , «».


: — . Obrigada


: . , . !


: , . .


: , , , , , / , .


: , .


: .


: , . mol.


: , , . , , , .


: .


: , . , $mol, , .


: , , . — . .


: - , .


: $mol , . (pdf, ) , .


: , . , .


: , ) .


: . .


: In some places I missed what the reporter was saying. The conversation was about how to use the "Mola" library and "why?". But how it works remains a mystery for me.To smoke an source code is for the overhead.


: , .


: . , . . .


: : . - (, ). , : 16?


Mais ou menos : eu não trabalhei com fibra. No relatório, ouvi a teoria do trabalho da fibra. Mas eu absolutamente não descobri como usar o mol_fiber em casa ... Pequenos exemplos são ótimos, mas como isso pode ser aplicado em um aplicativo grande com 30fps para acelerar para 60fps - não havia entendimento. Agora, se o autor prestasse mais atenção a este e menos design do módulo interno - a classificação seria maior.

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


All Articles