Go Diary-Você quer que o Go seja mais rápido?

译自>https://bravenewgeek.com/so-you-wanna-go-fast/

Dicas para escrever Go de alto desempenho

Até agora, esqueci o que estou escrevendo, mas garanto que este artigo é sobre Go. Isso é verdade e depende muito das melhorias de desempenho, e não da velocidade de entrega - as duas frequentemente se contradizem. Até agora, tudo é apenas contexto e reclamações inúteis. Mas também mostra que estamos resolvendo alguns problemas e porque queremos manter o status quo. Sempre há história.

Eu trabalho com muitas pessoas inteligentes. Muitos de nós somos quase obcecados por desempenho, mas uma coisa que tentei ressaltar antes é que estamos tentando exceder a faixa esperada de software em nuvem. O App Engine tem alguns limites rígidos, então fizemos mudanças. Desde a adoção do Go, aprendemos muito sobre como tornar as coisas mais rápidas e como fazer o Go funcionar no campo da programação de sistemas.

A simplicidade e o modelo de simultaneidade do Go o tornam uma escolha atraente para sistemas back-end, mas a grande questão é como ele beneficia os aplicativos sensíveis à latência? É necessário sacrificar a simplicidade da linguagem para torná-la mais rápida? Vamos entender gradualmente vários aspectos da otimização de desempenho em Go, ou seja, funções de linguagem, gerenciamento de memória e simultaneidade, e tentar tomar uma decisão. Todo o código de benchmark fornecido aqui pode ser encontrado no GitHub .

Canal

Os canais em Go atraem muita atenção porque são primitivos de simultaneidade convenientes, mas é importante entender seu impacto no desempenho. Geralmente, na maioria dos casos, o desempenho é "bom o suficiente", mas em alguns casos em que há requisitos rígidos de latência, eles podem causar gargalos. Canal não é mágico. Sob o capô, eles estão apenas fazendo um bloqueio. Isso funciona bem em aplicativos single-threaded sem contenção de bloqueio, mas em um ambiente multi-threaded, o desempenho será bastante reduzido. Podemos facilmente imitar a semântica de Channel usando um buffer de anel sem bloqueio .

O primeiro teste de benchmark analisou o desempenho de um único canal de buffer e de um buffer de anel com um único produtor e único consumidor. Primeiro, vamos olhar para o desempenho em um caso de thread único (GOMAXPROCS = 1).

BenchmarkChannel 3000000 512 ns / op
BenchmarkRingBuffer 20000000 80,9 ns / op

Como você pode ver, o buffer de anel é cerca de seis vezes mais rápido (se você não estiver familiarizado com a ferramenta de benchmarking de Go, o primeiro número próximo ao nome do benchmark indica o número de vezes que o benchmark foi executado antes de dar um resultado estável). A seguir, olhamos para o mesmo benchmark com GOMAXPROCS = 8.

BenchmarkChannel-8 3000000 542 ns / op
BenchmarkRingBuffer-8 10000000 182 ns / op

O buffer de anel é quase três vezes mais rápido.

Os canais geralmente são usados ​​para distribuir o trabalho entre um grupo de trabalhadores. Neste teste de benchmark, nos concentramos no desempenho de alta contenção de leitura no Canal com buffer e no buffer de anel. O experimento GOMAXPROCS = 1 mostra como o canal pode ser usado em um sistema de thread único definitivamente melhor.

BenchmarkChannelReadContention 10000000 148 ns / on
BenchmarkRingBufferReadContention 10000 390195 ns / on

No entanto, no caso de multithreading, o buffer de anel é mais rápido:

BenchmarkChannelReadContention-8 1000000 3105 ns / on
BenchmarkRingBufferReadContention-8 3000000 411 ns / on

Por fim, estudamos o desempenho tanto do leitor quanto do escritor. Da mesma forma, o desempenho do buffer de anel é muito pior em um caso de thread único, mas melhor em um caso de multi-thread.

BenchmarkChannelContention 10000 160892 ns / on
BenchmarkRingBufferContention 2 806834344 ns / on
BenchmarkChannelContention-8 5000 314428 ns / on
BenchmarkRingBufferContention-8 10000 182557 ns / on

O buffer de anel livre de bloqueio usa apenas operações CAS para obter segurança de thread. Podemos ver que a decisão de usá-lo no Channel depende muito do número de threads de sistema operacional disponíveis para o programa. Para a maioria dos sistemas, GOMAXPROCS> 1, portanto, quando o desempenho é importante, um buffer de anel sem bloqueio costuma ser a melhor escolha. Para executar acesso de alto desempenho ao estado compartilhado em um sistema multithread, o Canal é uma escolha bastante ruim.

Adiar

Defer é um recurso de linguagem útil no Go que melhora a legibilidade e evita erros relacionados à liberação de recursos. Por exemplo, quando abrimos um arquivo para leitura, precisamos ter o cuidado de fechá-lo após a conclusão. Se não houver adiamento, precisamos nos certificar de que o arquivo seja fechado em cada ponto de saída da função.

func findHelloWorld(filename string) error {
        file, err := os.Open(filename)
        if err != nil {
                return err
        }
        
        scanner := bufio.NewScanner(file)
        for scanner.Scan() {
                if scanner.Text() == "hello, world!" {
                        file.Close()
                        return nil
                }
        }

        file.Close()
        if err := scanner.Err(); err != nil {
                return err
        }
        
        return errors.New("Didn't find hello world")
}

É muito fácil cometer erros porque é fácil perder o ponto de retorno. Adiar resolve esse problema adicionando efetivamente o código de limpeza à pilha e chamando-o quando a função envolvente retornar.

func findHelloWorld(filename string) error {
        file, err := os.Open(filename)
        if err != nil {
                return err
        }
        defer file.Close()
        
        scanner := bufio.NewScanner(file)
        for scanner.Scan() {
                if scanner.Text() == "hello, world!" {
                        return nil
                }
        }

        if err := scanner.Err(); err != nil {
                return err
        }
        
        return errors.New("Didn't find hello world")
}

À primeira vista, pode-se pensar que o compilador pode otimizar totalmente a instrução defer. Se eu adiar algumas operações no início da função, só preciso inserir um fecho em cada ponto que a função retornar. No entanto, é mais complicado do que isso. Por exemplo, podemos adiar a chamada em instruções condicionais ou loops. O primeiro caso pode exigir que o compilador acompanhe as condições que causaram o adiamento. O compilador também precisa ser capaz de determinar se a instrução pode ter uma emergência, porque esse é outro ponto de saída para a função. Pelo menos superficialmente, esse parece ser um problema indeterminado.

A questão é que Adiar não é uma abstração de custo zero. Podemos compará-lo para mostrar a sobrecarga de desempenho. Neste benchmark, compararemos o bloqueio do mutex e o uso de adiar para desbloqueá-lo em um loop com o bloqueio do mutex e o desbloqueio sem adiamento.

BenchmarkMutexDeferUnlock-8 20000000 96,6 ns / on
BenchmarkMutexUnlock-8 100000000 19,5 ns / on

Neste teste, o uso de Defer foi quase cinco vezes mais lento. Para ser justo, queremos uma diferença de 77 nanossegundos, mas em um loop fechado no caminho crítico, isso se soma. Você notará uma tendência nessas otimizações, que geralmente é uma compensação entre desempenho e capacidade de leitura pelos desenvolvedores. A otimização raramente é gratuita.

Reflexão e JSON

A reflexão é geralmente lenta e deve ser evitada para aplicativos sensíveis a atrasos. JSON é um formato de troca de dados comum, mas o pacote de codificação / json do Go depende do reflexo de estruturas de empacotamento e descompactação. Com ffjson , podemos usar a geração de código para evitar reflexos e identificar diferenças.

BenchmarkJSONReflectionMarshal-8 200000 7063 ns / on
BenchmarkJSONMarshal-8 500000 3981 ns / on

BenchmarkJSONReflectionUnmarshal-8 200000 9362 ns / on
BenchmarkJSONUnmarshal-8 300000 5839 ns / on

O JSON gerado pelo código é 38% mais rápido do que a implementação baseada em reflexão da biblioteca padrão. Claro, se estamos preocupados com o desempenho, devemos evitar o uso de JSON completamente. MessagePack é uma escolha melhor e também pode gerar código de serialização. Neste teste de benchmark, usamos a biblioteca msgp e comparamos seu desempenho com JSON.

BenchmarkMsgpackMarshal-8 3000000 555 ns / em
BenchmarkJSONReflectionMarshal-8 200000 7063 ns / em
BenchmarkJSONMarshal-8 500000 3981 ns / em

BenchmarkMsgpackUnmarshal-8 20000000 94,6 ns / on
BenchmarkJSONReflectionUnmarshal-8 200000 9362 ns / on
BenchmarkJSONUnmarshal-8 300000 5839 ns / on

A diferença aqui é enorme. Mesmo quando comparado ao código de serialização JSON gerado, MessagePack é significativamente mais rápido.

Se realmente estivermos tentando microotimizar, também devemos prestar atenção para evitar o uso de interfaces, porque as interfaces não só farão o empacotamento, mas também trarão a sobrecarga das chamadas de método. Como outros tipos de despacho dinâmico, há sobrecarga indireta ao realizar pesquisas em chamadas de método em tempo de execução. O compilador não pode embutir essas chamadas.

BenchmarkJSONReflectionUnmarshal-8 200000 9362 ns / on
BenchmarkJSONReflectionUnmarshalIface-8 200000 10099 ns / on

Também podemos olhar para o custo da chamada para encontrar I2T, que converte a interface para o tipo específico suportado. Este benchmark chama o mesmo método na mesma estrutura. A diferença é que o segundo se refere à interface implementada pela estrutura.

BenchmarkStructMethodCall-8 2000000000 0,44 ns / on
BenchmarkIfaceMethodCall-8 1000000000 2,97 ns / on

A classificação é um exemplo mais prático, mostra a diferença de desempenho. Neste teste de benchmark, comparamos a ordem de 1.000.000 de fatias de estrutura e 1.000.000 de interfaces suportadas pela mesma estrutura. Classificar estruturas é quase 92% mais rápido do que classificar interfaces.

BenchmarkSortStruct-8 10 105276994 ns / op
BenchmarkSortIface-8 5 286123558 ns / op

Resumindo, evite usar JSON tanto quanto possível. Se necessário, gere códigos de empacotamento e desempacotamento. Geralmente, é melhor evitar código que depende de reflexão e interfaces e, em vez disso, escrever código que use tipos específicos. Infelizmente, isso geralmente leva a uma grande quantidade de código duplicado, então é melhor abstraí-lo por meio da geração de código. A compensação aparece novamente.

Gerenciamento de memória

Go não expõe heap ou alocação de pilha diretamente aos usuários. Na verdade, as palavras "heap" e "stack" não aparecem na especificação da linguagem . Isso significa que tudo relacionado à pilha e ao heap é tecnicamente dependente da implementação. Claro, na verdade, Go tem uma pilha e um heap para cada goroutine. O compilador realizará a análise de escape para determinar se o objeto pode existir na pilha ou se precisa ser alocado no heap.

Não surpreendentemente, evitar a alocação de heap pode ser a principal área de otimização. Ao alocar na pilha, evitamos chamadas malloc caras, conforme mostrado no benchmark abaixo.

BenchmarkAllocateHeap-8 20000000 62,3 ns / op 96 B / op 1 分配 / 操作
BenchmarkAllocateStack-8 100000000 11,6 ns / op 0 B / op 0 alocações / op

Naturalmente, passar por referência é mais rápido do que passar por valor, pois o primeiro só precisa copiar um ponteiro, enquanto o último precisa copiar o valor. Embora essas diferenças dependam principalmente do que deve ser copiado, as diferenças da estrutura usada nesses benchmarks são insignificantes. Lembre-se de que algumas otimizações do compilador também podem ser realizadas neste benchmark sintético.

BenchmarkPassByReference-8 1000000000 2,35 ns / on
BenchmarkPassByValue-8 200000000 6,36 ns / on

No entanto, o maior problema com a alocação de heap é a coleta de lixo. Se você quiser criar muitos objetos de curto prazo, o GC travará. Nesses casos, o pool de objetos se torna muito importante. Neste teste de benchmark, comparamos a estrutura de alocação em 10 goroutines simultâneas usando sync.Pool no heap e para o mesmo propósito . A mesclagem pode aumentar o desempenho em 5 vezes.

BenchmarkConcurrentStructAllocate-8 5000000 337 ns / on
BenchmarkConcurrentStructPool-8 20000000 65,5 ns / on

Deve ser destacado que o sync.Pool de Go se esgotou durante a coleta de lixo. O objetivo do sync.Pool é reutilizar a memória entre as coletas de lixo. Uma pessoa pode manter sua própria lista de objetos livres a serem armazenados na memória ao longo do ciclo de coleta de lixo, embora isso possa subverter o propósito do coletor de lixo. A ferramenta pprof de Go é muito útil para analisar o uso de memória. Use-o antes de fazer a otimização de memória às cegas.

Compartilhamento falso

Quando o desempenho é realmente importante, você deve começar a pensar no nível do hardware. O piloto de Fórmula 1 Jackie Stewart disse uma vez um ditado famoso: "Você não precisa ser um engenheiro para se tornar um piloto, mas deve ter uma mentalidade que entenda de mecânica." Uma compreensão profunda da estrutura interna de um carro Faça de você um motorista melhor. Da mesma forma, saber como o computador realmente funciona o tornará um programador melhor. Por exemplo, como organizar a memória? Como funciona o cache da CPU? Como funciona o disco rígido?

A largura de banda da memória ainda é um recurso limitado nas arquiteturas de CPU modernas, portanto, o armazenamento em cache é muito importante para evitar gargalos de desempenho. As CPUs modernas com vários processadores armazenam dados em cache em linhas menores, geralmente de 64 bytes , para evitar acessos caros à memória principal. Gravar na memória fará com que o cache da CPU despeje a linha para manter a coerência do cache. A leitura subsequente deste endereço requer a atualização da linha do cache. Este é um fenômeno chamado de falso compartilhamento , que é especialmente problemático quando vários processadores acessam dados independentes na mesma linha de cache.

Imagine a estrutura em Go e seu layout na memória. Tomemos o buffer de anel anterior como exemplo. A estrutura geralmente se parece com esta:

type RingBuffer struct {
	queue          uint64
	dequeue        uint64
	mask, disposed uint64
	nodes          nodes
}

Os campos de fila e de desenfileiramento são usados ​​para determinar as localizações de produtores e consumidores, respectivamente. Esses campos têm 8 bytes e são acessados ​​e modificados por vários threads ao mesmo tempo para adicionar e excluir itens da fila. Como esses dois campos são colocados consecutivamente na memória e ocupam apenas 16 bytes de memória, é provável que sejam armazenados em uma única linha de cache da CPU. Portanto, escrever para um resultará no despejo do outro, o que significa que as leituras subsequentes serão interrompidas. Mais especificamente, adicionar ou excluir conteúdo no buffer de anel tornará as operações subsequentes mais lentas e causará muita confusão no cache da CPU.

Podemos modificar a estrutura adicionando preenchimento entre os campos. Cada preenchimento tem a largura de uma única linha de cache da CPU para garantir que o campo termine em uma linha diferente. Acabamos com o seguinte:

type RingBuffer struct {
	_padding0      [8]uint64
	queue          uint64
	_padding1      [8]uint64
	dequeue        uint64
	_padding2      [8]uint64
	mask, disposed uint64
	_padding3      [8]uint64
	nodes          nodes
}

Qual é a diferença real no preenchimento da linha de cache da CPU? Como com qualquer coisa, depende. Depende da quantidade de multiprocessamento. Depende da quantidade de contenção. Depende do layout da memória. Existem muitos fatores a serem considerados, mas devemos sempre usar dados para apoiar nossas decisões. Podemos avaliar o buffer de anel com ou sem preenchimento para entender a diferença real.

Primeiro, comparamos um único produtor e um único consumidor, cada um executando em uma goroutine. Por meio desse teste, a melhora entre preenchido e não preenchido é muito pequena, cerca de 15%.

BenchmarkRingBufferSPSC-8 10000000 156 ns / op
BenchmarkRingBufferPaddedSPSC-8 10000000 132 ns / op

No entanto, quando temos vários produtores e vários consumidores (digamos 100 cada), a diferença se torna mais óbvia. Neste caso, a velocidade da versão de enchimento aumentou cerca de 36%.

BenchmarkRingBufferMPMC-8 100000 27763 ns / op
BenchmarkRingBufferPaddedMPMC-8 100000 17860 ns / op

O falso compartilhamento é um problema muito real. Dependendo da quantidade de concorrência e contenção de memória, pode ser necessário introduzir preenchimento para ajudar a mitigar seu impacto. Esses números podem parecer triviais, mas eles começam a se somar, especialmente quando cada ciclo de clock é importante.

não feche

Estruturas de dados sem bloqueio são essenciais para fazer uso total de vários núcleos. Considerando que Go é voltado para casos de uso altamente simultâneos, ele não fornece muitos métodos sem bloqueio. O incentivo parece visar principalmente os canais e, em menor medida, mutuamente exclusivos.

Em outras palavras, a biblioteca padrão fornece as primitivas de memória de baixo nível usuais com pacotes atômicos . Comparação e troca, acesso por ponteiro atômico - tudo está lá. No entanto, é altamente recomendável não usar o pacote atômico:

Normalmente não queremos usar sincronização / atômica ... A experiência tem nos mostrado uma e outra vez que poucas pessoas podem escrever código correto que usa operações atômicas ... Se pensarmos em pacotes internos / pacotes atômicos ao adicionar sincronização, talvez o usemos. Agora, devido à garantia do Go 1, não podemos excluir o pacote.

Quão difícil é ser desbloqueado? Apenas esfregue um pouco de CAS nele e chame-o de Tian, ​​certo? Depois de muita vaidade, comecei a entender que esta é definitivamente uma espada de dois gumes. O código sem bloqueio pode se tornar rapidamente complicado. Pacotes de software atômicos e inseguros não são fáceis de usar, pelo menos no início. Este último é nomeado por um motivo. Pise com cuidado - esta é uma área perigosa. Mais importante, escrever algoritmos sem bloqueio pode ser complicado e sujeito a erros. Estruturas de dados simples sem bloqueio (como buffers de anel) são muito fáceis de gerenciar, mas fora isso, todo o resto começa a se tornar complicado.

O Ctrie , escrevi em detalhes , é minha cobrança padrão para me envolver em estruturas de dados sem bloqueio que transcendem as filas e listas do mundo. Embora a teoria seja razoavelmente compreensível, sua implementação é muito complicada. Na verdade, a complexidade se deve em grande parte à falta de comparação dupla nativa e troca , que requer comparação atômica de nós indiretos (para detectar mutações na árvore) e gerações de nós (para detectar instantâneos de árvore). Visto que nenhum hardware fornece esse tipo de operação, ele deve ser emulado usando primitivas padrão ( e pode ).

Na verdade, a primeira implementação do Ctrie foi seriamente danificada , nem mesmo porque eu não usei as primitivas de sincronização do Go corretamente. Em vez disso, fiz suposições erradas sobre a linguagem. Cada nó em Ctrie tem uma geração associada a ele. Ao tirar um instantâneo da árvore, seu nó raiz será copiado para a nova geração. Quando os nós na árvore são acessados, eles são copiados lentamente para a nova geração (chamadas de estruturas de dados persistentes) para que instantâneos de tempo constante possam ser obtidos. Para evitar o estouro de inteiros, usamos objetos alocados no heap para dividir gerações. Em Go, isso é feito usando estruturas vazias. Em Java, dois objetos recém-construídos não são equivalentes quando comparados porque seus endereços de memória serão diferentes. Eu cegamente acho que o mesmo é verdade em Go, mas não é. Literalmente, a especificação da linguagem Go é a seguinte:

Se a estrutura ou tipo de array não contiver um campo (ou elemento) com tamanho maior que zero, seu tamanho será zero. Duas variáveis ​​de tamanho zero diferentes podem ter o mesmo endereço na memória.

Droga. O resultado é que duas gerações diferentes são consideradas equivalentes, portanto, as comparações e trocas duplas são sempre bem-sucedidas. Isso possibilita que o instantâneo deixe a árvore em um estado inconsistente. Este é um erro interessante e vale a pena rastreá-lo. Depurar código altamente concorrente e livre de bloqueio é realmente problemático. Se você não acertar na primeira vez, vai gastar muito tempo consertando, mas apenas se houver alguns erros muito sutis. E é improvável que você acerte na primeira vez. Você venceu desta vez, Ian Lance Taylor.

Mas espere! Obviamente, o uso de algoritmos complexos sem bloqueio será recompensado, ou por que alguém deveria restringir isso? Com o Ctrie, o desempenho da pesquisa pode ser comparável ao mapeamento síncrono ou listas de ignorar simultâneas. A inserção é mais cara devido ao aumento indireto. O benefício real do Ctrie é sua escalabilidade em termos de consumo de memória, o que é diferente da maioria das tabelas hash porque é sempre uma função do número de chaves atualmente na árvore. Outra vantagem é que ele pode realizar instantâneos linearizados de tempo constante. Podemos comparar usando Ctrie para usar o mesmo teste para realizar "instantâneos" no mapa de sincronização simultaneamente em 100 goroutines diferentes:

BenchmarkConcurrentSnapshotMap-8 1000 9941 784 ns / on
BenchmarkConcurrentSnapshotCtrie-8 20000 90412 ns / on

Dependendo do modo de acesso, as estruturas de dados sem bloqueio podem fornecer melhor desempenho em um sistema multithread. Por exemplo, o barramento de mensagem NATS usa uma estrutura baseada em mapa de sincronização para realizar a correspondência de assinatura. Comparado com a estrutura sem bloqueio inspirada por Ctrie, o rendimento é muito mais escalável. A linha azul é a estrutura de dados baseada em bloqueio e a linha vermelha é a implementação livre de bloqueio.

[Falha na transferência da imagem do link externo, o site de origem pode ter um mecanismo de link anti-leech, é recomendado salvar a imagem e carregá-la diretamente (img-K72NtSbt-1570527660635) (leanote: // file / getImage? FileId = 5d9c4bb066be486e4c000004)]

Dependendo da situação, evitar bloqueios pode ser benéfico. Ao comparar buffers de anel com Channel, as vantagens são óbvias. No entanto, é importante pesar quaisquer benefícios em relação à complexidade do código. Na verdade, às vezes nenhuma fechadura oferece quaisquer benefícios óbvios!

Notas sobre otimização

Como vimos ao longo deste artigo, a otimização de desempenho quase sempre tem um preço. Identificar e compreender a otimização em si é apenas a primeira etapa. É mais importante entender quando e onde aplicá-los. A famosa citação de CAR Hoare promovida por Donald Knuth tornou-se um lema de longo prazo dos programadores:

O verdadeiro problema é que os programadores gastam muito tempo se preocupando em estar no lugar errado e com a eficiência de tempo errada. A otimização prematura é a raiz de todos os males (ou pelo menos da maioria dos males) na programação.

Embora o objetivo desta frase não seja eliminar completamente a otimização, mas aprender como atingir um equilíbrio entre a velocidade, essas velocidades incluem a velocidade do algoritmo, velocidade de entrega, velocidade de manutenção e velocidade do sistema. Este é um tópico muito subjetivo e não existe uma regra prática. A otimização prematura é a raiz de todo mal? Devo fazer funcionar e depois torná-lo rápido? Precisa ser rápido? Estas não são decisões binárias. Por exemplo, se houver um problema fundamental no design, às vezes é impossível fazê-lo funcionar e depois executá-lo rapidamente.

No entanto, concentre-se na otimização ao longo do caminho crítico e na expansão desse caminho quando necessário. Quanto mais longe você estiver do caminho crítico, maior será a probabilidade de seu retorno sobre o investimento diminuir e mais tempo será desperdiçado. É importante determinar o que é desempenho suficiente. Não gaste mais tempo do que isso. Nesse campo, a tomada de decisão baseada em dados é essencial - empírica ao invés de impulsiva. Mais importante, devemos ser pragmáticos. Se não importa, é inútil reduzir as operações em um bilionésimo de segundo. A execução rápida é mais do que uma execução rápida de código.

Em geral

Parabéns, se você fez isso até agora, pode ter um problema. Aprendemos que, na verdade, existem dois tipos de velocidade de software: velocidade de entrega e desempenho. Os clientes precisam primeiro, os desenvolvedores precisam depois e os CTOs precisam de ambos. De longe, o primeiro é o mais importante, pelo menos quando você está tentando ir a público. O segundo é algo que você precisa planejar e iterar. Os dois geralmente se contradizem.

Talvez mais interessante é que estudamos várias maneiras de obter desempenho extra no Go e torná-lo viável em sistemas de baixa latência. O design da linguagem é muito simples, mas às vezes há um preço a pagar. Assim como a compensação entre dois jejuns, há uma compensação semelhante entre o ciclo de vida do código e o desempenho do código. A velocidade vem ao custo da simplificação, ao custo do tempo de desenvolvimento e ao custo da manutenção contínua. Faça escolhas sábias.

Acho que você gosta

Origin blog.csdn.net/qq_32198277/article/details/102399339
Recomendado
Clasificación