Estamos escrevendo um cliente http de alto desempenho usando fasthttp como exemplo. Alexander Valyalkin (VertaMedia)

A biblioteca Fasthttp é uma alternativa acelerada ao net / http dos pacotes Golang padrão.
Como é organizado? Por que ela é tão rápida?


Trago à sua atenção uma transcrição do relatório dos internos de clientes de Alexander Valyalkin Fasthttp.
Padrões de Fasthttp podem ser usados ​​para acelerar seus aplicativos, seu código.



Quem se importa, bem-vindo ao gato.


Eu sou Alexander Valyalkin. Eu trabalho na VertaMedia. Desenvolvi fasthttp para nossas necessidades. Inclui a implementação do cliente http e do servidor http. O Fasthttp é muito mais rápido que o net / http dos pacotes Go padrão.



O Fasthttp é uma implementação rápida do servidor http e do cliente. Localizado fasthttp no github.com



Eu acho que muitos já ouviram falar do servidor fasthttp, que é muito rápido. Mas poucos ouviram falar do cliente fasthttp. O servidor Fasthttp participa do benchmark da techempower - o famoso benchmark em círculos estreitos para servidores http. O servidor Fasthttp participa das rodadas 12 e 13. A rodada 13 ainda não foi lançada (em 2016 - aprox. Ed.).



Os resultados de um dos testes da rodada 12, em que fasthttp está quase no topo. Os números mostram quantas consultas ele faz por segundo neste teste. Neste teste, é feita uma solicitação para uma página que retorna hello world. No hello world, fasthttp é muito rápido.



Resultados preliminares da próxima rodada, que ainda não foram divulgados (em 2016 - aprox. Ed.). 4 implementações fasthttp ocupam o primeiro lugar no benchmark, que não apenas o hello world dá, mas também rastreia o banco de dados e forma uma página html com base no modelo.



Muito poucas pessoas sabem sobre o cliente fasthttp. Mas na verdade ele também é legal. Neste relatório, vou falar sobre o cliente fasthttp do dispositivo interno e por que ele foi desenvolvido.



Na verdade, existem vários clientes no fasthttp: Client, HostClient e PipelineClient. Além disso, vou falar mais sobre cada um deles.



Fasthttp.Client é um cliente http de uso geral regular. Com ele, você pode fazer solicitações para qualquer site da Internet, receber respostas. Suas características: funciona rapidamente, pode limitar o número de conexões abertas por host, diferentemente do pacote net / http. A documentação está em https://godoc.org/github.com/valyala/fasthttp#Client .



O Fasthttp.HostClient é um cliente especializado para se comunicar com apenas um servidor. Geralmente é usado para acessar a API HTTP: API REST, API JSON. Também pode ser usado para proxy de tráfego da Internet para um DataCenter interno em vários servidores. A documentação está aqui: https://godoc.org/github.com/valyala/fasthttp#HostClient .


Como o Fasthttp.Client, o Fasthttp.HostClient pode limitar o número de conexões abertas para cada um dos servidores de back-end. Essa funcionalidade está ausente no net / http e também está ausente no nginx livre. Essa funcionalidade é apenas no nginx pago, tanto quanto eu sei.



O Fasthttp.PipelineClient é um cliente especializado que permite gerenciar solicitações de pipeline para um servidor ou para um número limitado de servidores. Ele pode ser usado para acessar a API, através do protocolo HTTP, onde você precisa executar muitas solicitações e o mais rápido possível. A limitação do Fasthttp.PipelineClient é que ele pode sofrer com o bloqueio do chefe de linha. É quando enviamos muitas solicitações ao servidor e não esperamos uma resposta para cada solicitação. O servidor está bloqueado em uma dessas solicitações. Por esse motivo, todas as outras solicitações que o seguiram aguardarão até que este servidor processe uma solicitação lenta. O Fasthttp.PipelineClient deve ser usado apenas se você tiver certeza de que o servidor responderá instantaneamente às suas solicitações. Documentação



Agora vou começar a falar sobre a implementação interna de cada um desses clientes. Começarei com Fasthttp.HostClient, porque quase todos os outros clientes são criados com base nele.



Essa é a implementação mais simples do cliente HTTP no pseudocódigo no Go. Estamos conectados, obtemos uma resposta http neste URL. Estamos nos conectando a este host. Temos conexão. Nesse código, para que seja menor que o volume, todas as verificações de erro estão ausentes. De fato, não é assim. Você deve sempre verificar se há erros. Crie uma conexão. Feche a conexão com o adiamento. Enviamos uma solicitação para esta conexão por URL. Recebemos a resposta, retornamos essa resposta. O que há de errado com esta implementação do cliente HTTP?



O primeiro problema é que nesta implementação, a conexão é estabelecida para cada solicitação. Esta implementação não suporta HTTP KeepAlive. Como resolver este problema? Você pode usar o pool de conexões para cada servidor. Você não pode usar o Conjunto de Conexões para todos os servidores, porque a próxima solicitação não está clara para qual servidor enviar. Cada servidor deve ter seu próprio pool de conexões. E usamos o HTTP KeepAlive. Isso significa que o cabeçalho da conexão não precisa especificar o fechamento da conexão. No HTTP / 1.1, por padrão, há suporte para o HTTP KeepAlive e o Connection Close deve ser removido do cabeçalho. Aqui está a implementação no pseudo-código do cliente com suporte ao Pool de Conexão. Há um conjunto de vários conjuntos de conexões para cada host. A primeira função, connPoolForHost, retorna o Conjunto de Conexões para um determinado host a partir de uma determinada URL. Em seguida, obtemos a conexão desse pool de conexões, planejamos usar o adiamento para enviar essa conexão de volta ao pool, enviar uma solicitação KeepAlive para essa conexão e retornar uma resposta. Após a resposta, o adiamento é executado e a conexão retorna ao pool. Assim, habilitamos o suporte HTTP KeepAlive e tudo começa a funcionar mais rapidamente. Porque não perdemos tempo criando uma conexão para cada solicitação.


Mas a solução também tem problemas. Se você observar a assinatura da função, poderá ver que ela retorna um objeto de resposta para cada solicitação. Isso significa que, para esse objeto, você precisa alocar memória toda vez, inicializá-lo e devolvê-lo. Isso é ruim para o desempenho. Pode ser ruim se você tiver muitas dessas chamadas para obter funções.



Portanto, esse problema pode ser resolvido da mesma maneira que no Fasthttp, colocando o objeto ponteiro no objeto de resposta nos parâmetros dessa função. Dessa forma, esse código de chamada pode reutilizar esse objeto de resposta várias vezes. No slide, está a implementação dessa ideia. Passamos uma referência ao objeto de resposta para a função Get - e a função preenche essa resposta. A última linha preenche esse objeto.



Veja como ele pode ficar no seu código. Uma função que aceita um canal ao qual é transmitida uma lista de URLs a serem pesquisados. Vamos organizar um ciclo neste canal. Criamos um objeto de resposta uma vez e o reutilizamos em um loop. Chame Get, passe um ponteiro para o objeto, processe esta resposta. Após processá-lo, redefini-lo para seu estado original. Dessa forma, evitamos a alocação de memória e agilizamos nosso código.



O terceiro problema é o fechamento da conexão. Fechamento da conexão - cabeçalho HTTP, que pode ser encontrado em solicitação e resposta. Se obtivermos esse cabeçalho, essa conexão deverá ser fechada. Portanto, na implementação do cliente, é imperativo fornecer o fechamento da conexão. Se você enviou uma solicitação com o cabeçalho Conexão encerrada, depois de receber a resposta, precisa fechar esta conexão. Se você enviou uma solicitação sem o fechamento da conexão e retornou uma resposta com o fechamento da conexão, também será necessário fechar esta conexão após receber uma resposta.



Aqui está o pseudo-código para esta implementação. Depois de receber uma resposta, verificamos se os cabeçalhos de conexão próximos estão instalados lá. Se instalado, basta fechar a conexão. Se não estiver instalado, retorne a conexão ao pool. Se isso não for feito, se o servidor fechar a conexão após retornar as respostas, seu pool de conexões conterá a conexão interrompida que o servidor fechou e você tentará escrever algo neles e obterá erros.



O quarto problema ao qual os clientes HTTP estão expostos é servidores lentos ou uma rede lenta e inativa. Os servidores podem parar de responder às suas solicitações por vários motivos. Por exemplo, o servidor está com problemas ou a rede entre o cliente e o servidor parou de funcionar. Por esse motivo, todas as suas goroutines que chamam a função Get, descritas anteriormente, serão bloqueadas, aguardando uma resposta do servidor indefinidamente. Por exemplo, se você implementar um proxy http que aceite uma conexão de entrada e chame a função Get em cada conexão, um grande número de goroutines será criado e todas elas permanecerão no servidor até que o servidor trave, até que a memória se esgote.



Como resolver este problema? Existe uma decisão tão ingênua que primeiro vem à mente - basta embrulhar este Get em uma goroutine separada. Em seguida, na goroutine, passe um canal vazio, que será fechado após a execução de Get. Depois de iniciar esta goroutine, aguarde neste canal por um tempo (tempo limite). Nesse caso, se algum tempo passar e este Get não for executado, a saída desta função ocorrerá por tempo limite. Se este Get for executado, o canal será fechado e a saída ocorrerá. Mas esta decisão está errada, porque transfere o problema de uma cabeça doente para uma saudável. Mesmo assim, as goroutines serão criadas e travadas, independentemente do tempo limite que você usar. O número de goroutines que causaram o tempo limite do Get será limitado, mas haverá um número ilimitado de goroutines que serão criadas dentro do Get with timeout.



Como resolver este problema? A primeira solução é limitar o número de goroutines bloqueadas na função Get. Isso pode ser feito usando um padrão conhecido como o uso de um canal em buffer de comprimento limitado, que contará o número de goroutines que executam a função Get. Se essa quantidade de goroutine exceder um certo limite - a capacidade deste canal, sairemos para o ramo padrão. Isso significa que temos todas as goroutines executadas em execução e, na ramificação padrão, precisamos apenas retornar Error, que não há recursos livres. Antes de criar a goroutine, tentamos escrever alguma estrutura vazia neste canal. Se isso não der certo, excederemos a quantidade de goroutines. Se acabou, criamos esse gorutin e depois que Get é executado, lemos um valor desse canal. Assim, limitamos a quantidade de goroutines que podem ser bloqueadas no Get.



A segunda solução, que complementa a primeira, é definir tempos limite na conexão com o servidor. Isso desbloqueará a função get se o servidor não responder por um longo tempo ou a rede estiver inoperante.


Se a rede não funcionar na Solução 1, tudo ficará travado. Depois de digitarmos na circuncisão um número limitado de goroutines penduradas aqui, a função getimeout sempre retornará um erro. Para começar a funcionar normalmente, você precisa de uma segunda solução (Solução 2), que define um tempo limite para leitura e gravação da conexão. Isso ajuda a desbloquear goroutines bloqueadas se a rede ou o servidor parar de funcionar.



A solução 1 tem uma corrida de dados. O objeto de resposta do qual o ponteiro foi passado será ocupado se Get for bloqueado. Mas essa função Get timeout pode expirar. Nesse caso, saímos dessa função, uma resposta será interrompida e depois de algum tempo será reescrita. Assim, uma corrida de dados é obtida. Como temos resposta após sair da função, ela ainda é usada em algum lugar da goroutina.


O problema é resolvido criando uma cópia de resposta e passando a cópia de resposta para a goroutine. Após a conclusão do Get, copie a resposta desta resposta para a nossa resposta original, que é passada aqui. Assim, a corrida de dados é resolvida. Essa cópia da resposta permanece por um curto período de tempo e retorna ao pool. Nós reutilizamos a resposta. Uma cópia de resposta pode não caber no pool apenas por tempo limite. Por tempo limite, há uma perda de resposta do pool.



Preciso fechar a conexão após o servidor não retornar uma resposta dentro de um tempo limite? A resposta é não. Em vez disso, sim, se você deseja fazer backup do servidor. Como quando você envia uma solicitação para o servidor, aguarde um pouco, o servidor não responde durante esse período - ele não lida com solicitações. Por exemplo, você fecha esta conexão, mas isso não significa que o servidor parará imediatamente de executar essa solicitação. O servidor continuará a executá-lo. O servidor detectará que essa solicitação não precisa ser executada após tentar retornar uma resposta para você. Você fechou a conexão, tentou novamente criar uma nova solicitação, novamente o tempo limite passou, fechou novamente, criou uma nova solicitação. Você terá uma carga no aumento do servidor. Como resultado, seu serviço depende de suas solicitações. Esses são DoS no nível de solicitações http. Se você possui servidores em execução lenta e não deseja fazer backup deles, não é necessário fechar a conexão após um tempo limite. Você precisa esperar um pouco, deixar a conexão para expiar este servidor. Deixe-o tentar lhe dar uma resposta. Enquanto isso, use outras conexões gratuitas. Tudo o que foi dito antes disso são todos os estágios da implementação do Fasthttp.Client e os problemas que ocorreram durante a implementação do Fasthttp.Client. Esses problemas foram resolvidos no Fasthttp.HostClient.


Agora temos um cliente rápido? Na verdade não. Você precisa ver como o Pool de conexão é implementado.



A implementação ingênua do Connection Pool se parece com isso. Há algum tipo de endereço do servidor em que você precisa instalar a conexão. Há uma lista de conexões gratuitas e um bloqueio para sincronizar o acesso a essa lista.



Aqui está a função para obter conexão do pool de conexões. Estamos vendo uma lista de nossa coleção. Se houver algo lá, obtemos uma conexão gratuita e a devolvemos. Se não houver nada, crie uma nova conexão com este servidor e devolva-a. O que há de errado aqui?


A função connPool.Put retorna uma conexão livre.


Na conta de tempo limite. No Fasthttp.Client, você pode especificar o tempo de vida máximo de uma conexão aberta não utilizada. Após esse período, as conexões não utilizadas são fechadas automaticamente e lançadas para fora desse pool.


As conexões mais antigas ficam sem uso com o tempo e são automaticamente fechadas e removidas do pool.


Quando a conexão é retirada do pool, e o servidor foi fechado e você tentou escrever algo lá, é feita uma segunda tentativa - uma nova conexão é obtida e tenta enviar novamente pedidos para essa conexão. Mas isso é apenas se essa solicitação é idempotente - ou seja, uma solicitação que pode ser executada várias vezes sem efeitos colaterais no servidor - é uma solicitação GET ou HEAD. Por exemplo, no net / http padrão agora adicionamos uma verificação de conexões fechadas. Lá eles fizeram uma checagem mais complicada. Eles verificam, quando tentam enviar uma nova solicitação para a conexão do pool, se pelo menos um byte é enviado a essa conexão. Se ativado, retorne Erro. Se você não saiu, pegamos uma nova conexão do pool.



O que há de errado com a piscina? Seu tamanho não é limitado. Mesma implementação que em net / http. Se você escrever um cliente que está quebrando de milhões de goroutines para um servidor lento, ele tentará criar um milhão de conexões com esse servidor. Não há limite para o número máximo de conexões no pacote net / http padrão. Para o cliente usado para acessar a API por HTTP, é recomendável limitar o tamanho desse conjunto de conexões. Caso contrário, seus clientes poderão cair, porque você usará todos os recursos: threads, objetos, conexão, goroutines e memória. Além disso, isso pode levar ao DoS de seus servidores, uma vez que será estabelecida muita conexão com eles, que não são usados ​​ou são usados ​​ineficientemente, porque o servidor não pode suportar tanta conexão.



Limite do conjunto de conexões. O código não está aqui, porque é muito grande para caber em um slide. Os interessados ​​podem ver a implementação desta função no github.com.



O segundo problema. Muitas solicitações chegam ao cliente em algum momento. E depois disso, há um declínio e um retorno ao número anterior de solicitações. Por exemplo, 10.000 solicitações chegaram simultaneamente e, em seguida, o número de solicitações retornou a 1000 por unidade de tempo. Depois disso, o conjunto de conexões aumentará para 10000. Essas conexões permanecerão intermináveis. Esse problema estava no cliente net / http padrão anterior à versão 1.7. Portanto, você precisa resolver esse problema.



Esse problema é resolvido limitando a vida útil de uma conexão não utilizada. Se, por algum tempo, nenhuma solicitação foi enviada por conexão, ela simplesmente fecha e é expulsa do pool. Não há implementação porque é muito grande.



Temos um cliente que trabalha rápido e legal? Não é bem assim. Ainda temos a função de criar a conexão - dialHost.



Vejamos sua implementação. Uma implementação ingênua se parece com isso. O endereço em que você deseja conectar é simplesmente transmitido. Chamamos a função padrão net.Dial. Ela retorna a conexão. O que há de errado com esta implementação?



Por padrão, o net.Dial faz uma solicitação de DNS para cada chamada. Isso pode levar ao aumento do uso de recursos do seu subsistema DNS. Se os clientes da API se conectarem a servidores que não suportam conexões KeepAlive, eles fecharão as conexões. Você é apoiado pelo KeepAlive e os servidores não. Após essa resposta, o servidor fecha a conexão. Acontece que net.Dial é chamado em cada solicitação. Existem cerca de 10 mil solicitações por segundo. Você tem 10 mil vezes por segundo vai resolver em DNS. Isso carrega o subsistema DNS.



Como resolver este problema? Crie um cache que mapeie o host no IP por um curto período diretamente no seu código Go e não chame o DNS que resolve em cada net.Dial. Conecte-se a endereços IP prontos.



O segundo problema é a carga desigual no servidor se você tiver vários servidores ocultos atrás do nome do domínio. Por exemplo, como Round Robin DNS. Se você armazenar em cache um endereço IP no DNS por um tempo, durante esse período, todas as suas solicitações irão para um servidor. Embora você possa ter vários deles lá. É necessário resolver este problema. Isso é resolvido enumerando todos os IPs disponíveis ocultos atrás de um determinado nome de domínio. Isso também é feito no Fasthttp.Client.



O terceiro problema é que o net.Dial também pode travar indefinidamente devido a problemas na rede ou no servidor em que você está tentando se conectar. Nesse caso, suas goroutines irão travar na função Get. Isso também pode levar ao aumento do uso de recursos.


A solução é adicionar um tempo limite. Dial package net. , , . , , , .



. Get Dial . - . Dial , , . , , . DialTimeout. , .



HostClient .


HostClient , . LoadBalance.


HostClient . , HostClient . connection . . .


Fauly host .


— . Dial. , Dial. Get, , - . , . , , .


— . Get , . , , , .


Error , Round Robin .


SSL , Golang . .



fasthttp.Client. HostClient, fasthttp.Client HostClient.



Get. HostClient . HostClient . HostClient Get. HostClient.



HostClient - , URL. web-crawling ( ), . HostClient . net/http, . , HostClient, . fasthttp.



Client HostClient, PipelineClient . PipelineClient connection pool. PipelineClient connection, . PipelineClient connection. connection pool. PipelineClient connection .



PipelineClient connection . PipelineConnClient.writer — connection, . PipelineConnClient.reader — connection , PipelineConnClient.writer. PipelineConnClient.reader , Get.



PipelineClient.Get PipelineClient. pipelineWork url, , response, channel done, response.


Get. C . channel, PipelineConnClient.writer connection. channel w.done, PipelineConnClient.reader, response request.



net/http fasthttp.Client 2 .



, , fasthttp. , , . fasthttp. , fasthttp, . allocation . .



net/http. , allocation net/nttp. .



: PipelineClient connection?


: — pending , . . request, pending , Error.


: API , fasthttp, net/http?


: . net/http . . string -, string . , net/http, . - , . fasthttp , . . net/http fasthttp , net/http POST-, response, () . fasthttp , request response . 10 request 10 response . , . fasthttp 10 request 10 response? . — . , net/http. . , net/http — .


PS .


.


— .

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


All Articles