Alguns anos atrás, publicamos o RESTinio , nossa pequena estrutura OpenSource C ++ para incorporar um servidor HTTP em aplicativos C ++. O RESTinio não se tornou mega-popular durante esse período, mas não foi perdido . Alguém o escolhe para o suporte "nativo" ao Windows, alguém para alguns recursos individuais (como suporte ao sendfile), alguém para a proporção de recursos, facilidade de uso e personalização. Mas, eu acho, inicialmente muitos RESTinio são atraídos por este lacônico "Olá, Mundo":
#include <restinio/all.hpp> int main() { restinio::run( restinio::on_this_thread() .port(8080) .address("localhost") .request_handler([](auto req) { return req->create_response().set_body("Hello, World!").done(); })); return 0; }
Isso é realmente tudo o que é necessário para executar o servidor HTTP dentro de um aplicativo C ++.
E, embora sempre tentemos dizer que o principal recurso para o qual geralmente nos envolvemos no RESTinio era o processamento assíncrono de solicitações recebidas, ainda ocasionalmente encontramos perguntas sobre o que fazer se dentro do request_handler você precisar executar operações demoradas.
E como essa pergunta é relevante, você pode falar sobre ela novamente e dar alguns pequenos exemplos.
Uma pequena referência às origens
Decidimos tornar nosso servidor HTTP incorporável após várias vezes seguidas enfrentar tarefas muito semelhantes: era necessário organizar uma entrada HTTP para um aplicativo C ++ existente ou era necessário escrever um microsserviço no qual era necessário reutilizar o C ++ "pesado" já existente qualquer código. Um recurso comum dessas tarefas era que o processamento do aplicativo da solicitação poderia se estender por dezenas de segundos.
Grosso modo, por um milissegundo, o servidor HTTP estava classificando uma nova solicitação HTTP, mas para emitir uma resposta HTTP, era necessário recorrer a outros serviços ou executar cálculos demorados. Se você processar solicitações HTTP no modo síncrono, o servidor HTTP precisará de um conjunto de milhares de threads de trabalho, o que dificilmente pode ser considerado uma boa idéia, mesmo em condições modernas.
É muito mais conveniente quando o servidor HTTP pode trabalhar em apenas um thread de trabalho, no qual a E / S é executada e os manipuladores de solicitação são chamados. O manipulador de solicitação simplesmente delega o processamento real de algum outro thread de trabalho e retorna o controle ao servidor HTTP. Quando, muito mais tarde, em algum lugar de outro encadeamento de trabalho, as informações estão prontas para responder à solicitação, simplesmente é gerada uma resposta HTTP que automaticamente pega o servidor HTTP e envia essa resposta ao cliente apropriado.
Como nunca encontramos uma versão pronta que fosse simples e conveniente de usar, ela era multiplataforma e suportava o Windows como uma plataforma "nativa", proporcionaria desempenho mais ou menos decente e, mais importante, seria aprimorada especificamente para assincrônicas. trabalho, então, no início de 2017, começamos a desenvolver o RESTinio.
Queríamos criar um servidor HTTP incorporado assíncrono, fácil de usar, liberando o usuário de algumas preocupações rotineiras, além de mais ou menos produtivo, multiplataforma e permitindo uma configuração flexível para diferentes condições. Parece estar dando certo, mas vamos deixar que os usuários julguem ...
Portanto, há uma solicitação de entrada que requer muito tempo de processamento. O que fazer
Linhas de trabalho RESTinio / Asio
Às vezes, os usuários do RESTinio não pensam sobre quais threads de trabalho e como exatamente usam o RESTinio. Por exemplo, alguém pode considerar que quando o RESTinio é iniciado em um thread de trabalho (usando run(on_this_thread(...))
, como no exemplo acima), nesse segmento de trabalho o RESTinio chama apenas manipuladores de solicitação. Enquanto que para E / S, o RESTinio cria um encadeamento separado sob o capô. E esse encadeamento separado continua a servir novas conexões quando o encadeamento principal é ocupado pelo request_handler.
De fato, todos os encadeamentos que o usuário aloca para o RESTinio são usados para executar operações de E / S e para chamar request_handlers. Portanto, se você iniciou o servidor RESTinio através da run(on_this_thread(...))
, e dentro da run()
no encadeamento atual, serão executados os manipuladores de E / S e de solicitação.
Grosso modo, o RESTinio lança um loop de eventos do Asio, no qual processa novas conexões, lê e analisa dados de conexões existentes, grava dados prontos para envio, gerencia conexões de fechamento, etc. Entre outras coisas, depois que a solicitação recebida é lida e analisada completamente a partir da próxima conexão, o request_handler especificado pelo usuário é chamado para processar essa solicitação.
Portanto, se request_handler bloquear a operação do encadeamento atual, o loop de eventos do Asio-action trabalhando no mesmo encadeamento também será bloqueado. Tudo é simples.
Se o RESTinio for iniciado em um pool de threads de trabalho (por exemplo, por meio de run(on_thread_pool(...))
, como neste exemplo ), quase o mesmo acontece: um loop de eventos do Asio é iniciado em cada thread do pool. Portanto, se algum request_handler começar a multiplicar matrizes grandes, isso bloqueará o encadeamento de trabalho no pool e as operações de E / S não serão mais atendidas nesse encadeamento.
Portanto, ao usar o RESTinio, a tarefa do desenvolvedor é concluir seus request_handlers em um prazo razoável e, de preferência, não muito tempo.
Você precisa de um pool de fluxos de trabalho para o RESTinio / Asio?
Portanto, quando o request_handler especificado pelo usuário bloqueia o thread de trabalho no qual é chamado por um longo tempo, esse thread perde a capacidade de processar operações de E / S. Mas e se request_handler precisar de muito tempo para formar uma resposta? Suponha que ele faça algum tipo de operação pesada de computação, cujo tempo, em princípio, não pode ser reduzido para alguns milissegundos?
Um dos usuários pode pensar que, como o RESTinio pode trabalhar em um pool de threads de trabalho, basta especificar o tamanho do pool maior e é isso.
Infelizmente, isso só funcionará em casos simples quando você tiver poucas conexões paralelas. E a intensidade da consulta é baixa. Se a contagem de consultas paralelas for de milhares (pelo menos apenas algumas centenas), será fácil obter uma situação em que todos os threads de trabalho do pool estarão ocupados processando solicitações já aceitas. E não haverá mais threads restantes para executar operações de E / S. Como resultado, o servidor perderá sua capacidade de resposta. A inclusão de RESTinio perderá a capacidade de processar tempos limite que o RESTinio contabiliza automaticamente quando recebe novas conexões e ao processar solicitações.
Portanto, se você precisar executar operações de bloqueio demoradas para atender às solicitações recebidas, é melhor alocar apenas um encadeamento de trabalho para o RESTinio, mas atribua um grande conjunto de fluxos de trabalho para executar essas mesmas operações. O manipulador de solicitações colocará a próxima solicitação em alguma fila, de onde a solicitação será recuperada e enviada para processamento.
Vimos um exemplo desse esquema em detalhes quando falamos sobre nosso projeto de demonstração de camarão neste artigo: " Camarão: dimensionar e compartilhar imagens HTTP em C ++ moderno usando ImageMagic ++, SObjectizer e RESTinio ".
Exemplos de delegação de processamento de solicitações em threads de trabalho individuais
Acima, tentei explicar por que não é necessário executar um processamento demorado dentro do request_handler. De onde vem o resultado óbvio: o processamento demorado da solicitação deve ser delegado a algum outro segmento de trabalho. Vamos ver como isso pode parecer.
Nos dois exemplos abaixo, precisamos de um único segmento de trabalho para executar o RESTinio e outro de trabalho para simular o longo processamento de solicitações. E também precisamos de algum tipo de fila de mensagens para transferir solicitações do encadeamento RESTinio para um encadeamento de trabalho separado.
Não foi fácil para mim fazer uma nova implementação da fila de mensagens com segurança de thread para esses dois exemplos; portanto, usei o SObjectizer nativo e seus mchains, que são canais CSP. Você pode ler mais sobre o mchain aqui: " Troca de informações entre threads de trabalho sem problemas? Canais CSP para nos ajudar ."
Salvando o objeto request_handle
A técnica básica na qual a delegação do processamento de solicitações é criada é a transferência do objeto request_handle_t
algum lugar.
Quando o RESTinio chama o request_handler especificado pelo usuário para processar uma solicitação de entrada, um objeto do tipo request_handle_t
é passado para esse request_handle_t
. Esse tipo nada mais é do que um ponteiro inteligente para os parâmetros da solicitação recebida. Portanto, se for conveniente que alguém pense que request_handle_t
é shared_ptr
, você pode pensar com segurança. Este shared_ptr
é.
E como request_handle_t
é shared_ptr
, podemos passar com segurança esse ponteiro inteligente em algum lugar. O que faremos nos exemplos abaixo.
Portanto, precisamos de um thread e canal de trabalho separados para se comunicar com ele. Vamos criar tudo:
int main() {
O corpo do próprio thread de trabalho está localizado dentro da função processing_thread_func()
, que discutiremos um pouco mais adiante.
Agora já temos um segmento de trabalho separado e um canal para comunicação com ele. Você pode iniciar o servidor RESTinio:
A lógica para este servidor é muito simples. Se uma solicitação GET chegou para '/', delegamos o processamento da solicitação de um único encadeamento. Para fazer isso, realizamos duas operações importantes:
- envie o objeto
request_handle_t
para o canal CSP. Enquanto esse objeto é armazenado dentro do canal CSP ou em outro local, o RESTinio sabe que a solicitação ainda está ativa; - retornamos o valor
restinio::request_accepted()
do manipulador de solicitações. Isso faz com que o RESTinio entenda que a solicitação foi aceita para processamento e que a conexão com o cliente não pode ser fechada.
O fato de request_handler não gerar imediatamente uma resposta RESTinio não incomoda. Depois que restinio::request_accepted()
for retornado, o usuário restinio::request_accepted()
a responsabilidade de processar a solicitação e um dia a resposta será gerada.
Se o manipulador de solicitação retornou restinio::request_rejected()
, o RESTinio entende que a solicitação não será processada e retornará um erro 501 ao cliente.
Portanto, corrigimos o resultado preliminar: a instância request_handle_t
pode ser transmitida para algum lugar, pois é, de fato, std::shared_ptr
. Enquanto essa instância estiver ativa, o RESTinio considera que a solicitação está sendo processada. Se o manipulador de solicitação retornou restinio::request_accepted()
, o RESTinio não se preocupará com o fato de a resposta à solicitação não ter sido gerada no momento.
Agora podemos ver a implementação desse segmento muito separado:
void processing_thread_func(so_5::mchain_t req_ch) {
A lógica aqui é muito simples: obtemos a solicitação inicial na forma de uma mensagem handle_request
e a encaminhamos para nós mesmos na forma de uma mensagem timeout_elapsed
atrasada por algum tempo aleatório. Realizamos o processamento real da solicitação somente após o recebimento de timeout_elapsed
.
Upd. Quando o método done()
é chamado em um thread de trabalho separado, o RESTinio é notificado de que apareceu uma resposta pronta que precisa ser gravada na conexão TCP. O RESTinio inicia a operação de gravação, mas a própria operação de E / S não será executada quando o nome done()
chamado, mas onde o RESTinio faz o E / S e chama request_handlers. I.e. neste exemplo, done()
é chamado em um thread de trabalho separado, e a operação de gravação será executada no thread principal, onde restinio::run()
funciona.
As próprias mensagens são as seguintes:
struct handle_request { restinio::request_handle_t m_req; }; struct timeout_elapsed { restinio::request_handle_t m_req; std::chrono::milliseconds m_pause; };
I.e. um thread de trabalho separado pega request_handle_t
e o salva até que surja a oportunidade de formar uma resposta completa. E quando essa oportunidade surgir, create_response()
é chamado no objeto de solicitação salva e a resposta é retornada ao RESTinio. Em seguida, o RESTinio já em seu contexto de trabalho grava a resposta em conexão com o cliente correspondente.
Aqui, a instância request_handle_t
é armazenada em uma mensagem atrasada timeout_elapsed
, pois não há processamento real neste exemplo primitivo. Em um aplicativo real, request_handle_t
pode ser armazenado em algum tipo de fila ou dentro de algum objeto criado para processar a solicitação.
O código completo deste exemplo pode ser encontrado entre os exemplos regulares do RESTinio .
Algumas pequenas notas de código
Essa construção define as propriedades RESTinio que um servidor RESTinio deve ter:
Neste exemplo, preciso do RESTinio para registrar suas ações de processamento de solicitações. Portanto, defino logger_t
como diferente do padrão null_logger_t
. Mas desde O RESTinio funcionará, de fato, em vários encadeamentos (o RESTinio processa solicitações de entrada no encadeamento principal, mas as respostas chegam de um encadeamento de trabalho separado), então você precisa de um registrador seguro de encadeamento, que é shared_ostream_logger_t
.
Dentro de processing_thread_func()
, a função SObjectizer select()
, que é um pouco semelhante à construção Go-shn selecionada: você pode ler e processar mensagens de vários canais ao mesmo tempo. A função select()
funciona até que todos os canais passados para ela sejam fechados. Ou até que lhe seja dito à força que é hora de terminar.
Ao mesmo tempo, se o canal de comunicação com o servidor RESTinio estiver fechado, não faz sentido continuar trabalhando. Portanto, em select()
, a resposta ao fechamento de qualquer um dos canais é determinada: assim que um canal é fechado, o sinalizador de parada é levantado. E isso levará à conclusão de select()
e sairá de processing_thread_func()
.
Salvando o Objeto response_builder
No exemplo anterior, consideramos um caso simples quando é possível salvar request_handle_t
até que possamos fornecer imediatamente toda a resposta à solicitação.
Mas pode haver cenários mais complexos quando, por exemplo, você precisar dar uma resposta em partes. Ou seja, recebemos uma solicitação, podemos formar imediatamente apenas a primeira parte da resposta. Nós formamos isso. Depois de algum tempo, temos a oportunidade de formar a segunda parte da resposta. Depois de mais algum tempo, podemos formar a próxima parte, etc.
Além disso, pode ser desejável para nós que todas essas partes desapareçam à medida que as formam. I.e. Primeiro, a primeira parte da resposta, para que o cliente possa subtraí-la, depois a segunda, depois a terceira, etc.
O RESTinio permite fazer isso devido a diferentes tipos de respondce_builders . Em particular, tipos como user_controlled_output e chunked_output .
Nesse caso, não é suficiente salvar request_handle_t
, porque request_handle_t
será útil apenas até a primeira chamada para create_reponse()
. Em seguida, precisamos trabalhar com response_builder. Bem ...
Bem, tudo bem. Response_builder é um tipo móvel, um pouco semelhante ao unique_ptr. Assim, também podemos mantê-lo enquanto precisarmos. E para mostrar como fica, refazemos um pouco o exemplo acima. Deixe a função processing_thread_func()
formar a resposta em partes.
Isso não é nada difícil.
Primeiro, precisamos decidir sobre os tipos que o novo processing_thread_func()
precisará:
struct handle_request { restinio::request_handle_t m_req; };
A mensagem handle_request
permanece inalterada. Mas na mensagem timeout_elapsed
agora armazenamos não request_handle_t
, mas response_builder do tipo que precisamos. Além disso, um contador das peças restantes. Assim que esse contador for redefinido, a solicitação de serviço termina.
Agora podemos ver uma nova versão da função processing_thread_func()
:
void processing_thread_func(so_5::mchain_t req_ch) { std::random_device rd; std::mt19937 generator{rd()}; std::uniform_int_distribution<> pause_generator{350, 3500}; auto delayed_ch = so_5::create_mchain(req_ch->environment()); bool stop = false; select( so_5::from_all() .on_close([&stop](const auto &) { stop = true; }) .stop_on([&stop]{ return stop; }), case_(req_ch, [&](handle_request cmd) {
I.e. , . . .
Upd. flush()
, done()
: RESTinio , I/O- , flush()
, , RESTinio - request_handler-. I.e. flush()
, , , restinio::run()
.
, RESTinio :
[2019-05-13 15:02:35.106] TRACE: starting server on 127.0.0.1:8080 [2019-05-13 15:02:35.106] INFO: init accept #0 [2019-05-13 15:02:35.106] INFO: server started on 127.0.0.1:8080 [2019-05-13 15:02:39.050] TRACE: accept connection from 127.0.0.1:49280 on socket #0 [2019-05-13 15:02:39.050] TRACE: [connection:1] start connection with 127.0.0.1:49280 [2019-05-13 15:02:39.050] TRACE: [connection:1] start waiting for request [2019-05-13 15:02:39.050] TRACE: [connection:1] continue reading request [2019-05-13 15:02:39.050] TRACE: [connection:1] received 78 bytes [2019-05-13 15:02:39.050] TRACE: [connection:1] request received (#0): GET / [2019-05-13 15:02:39.050] TRACE: [connection:1] append response (#0), flags: { not_final_parts, connection_keepalive }, write group size: 1 [2019-05-13 15:02:39.050] TRACE: [connection:1] start next write group for response (#0), size: 1 [2019-05-13 15:02:39.050] TRACE: [connection:1] start response (#0): HTTP/1.1 200 OK [2019-05-13 15:02:39.050] TRACE: [connection:1] sending resp data, buf count: 1, total size: 167 [2019-05-13 15:02:39.050] TRACE: [connection:1] outgoing data was sent: 167 bytes [2019-05-13 15:02:39.050] TRACE: [connection:1] finishing current write group [2019-05-13 15:02:39.050] TRACE: [connection:1] should keep alive [2019-05-13 15:02:40.190] TRACE: [connection:1] append response (#0), flags: { not_final_parts, connection_keepalive }, write group size: 3 [2019-05-13 15:02:40.190] TRACE: [connection:1] start next write group for response (#0), size: 3 [2019-05-13 15:02:40.190] TRACE: [connection:1] sending resp data, buf count: 3, total size: 42 [2019-05-13 15:02:40.190] TRACE: [connection:1] outgoing data was sent: 42 bytes [2019-05-13 15:02:40.190] TRACE: [connection:1] finishing current write group [2019-05-13 15:02:40.190] TRACE: [connection:1] should keep alive [2019-05-13 15:02:43.542] TRACE: [connection:1] append response (#0), flags: { not_final_parts, connection_keepalive }, write group size: 3 [2019-05-13 15:02:43.542] TRACE: [connection:1] start next write group for response (#0), size: 3 [2019-05-13 15:02:43.542] TRACE: [connection:1] sending resp data, buf count: 3, total size: 42 [2019-05-13 15:02:43.542] TRACE: [connection:1] outgoing data was sent: 42 bytes [2019-05-13 15:02:43.542] TRACE: [connection:1] finishing current write group [2019-05-13 15:02:43.542] TRACE: [connection:1] should keep alive [2019-05-13 15:02:46.297] TRACE: [connection:1] append response (#0), flags: { not_final_parts, connection_keepalive }, write group size: 3 [2019-05-13 15:02:46.297] TRACE: [connection:1] start next write group for response (#0), size: 3 [2019-05-13 15:02:46.297] TRACE: [connection:1] sending resp data, buf count: 3, total size: 42 [2019-05-13 15:02:46.297] TRACE: [connection:1] append response (#0), flags: { final_parts, connection_keepalive }, write group size: 1 [2019-05-13 15:02:46.297] TRACE: [connection:1] outgoing data was sent: 42 bytes [2019-05-13 15:02:46.298] TRACE: [connection:1] finishing current write group [2019-05-13 15:02:46.298] TRACE: [connection:1] should keep alive [2019-05-13 15:02:46.298] TRACE: [connection:1] start next write group for response (#0), size: 1 [2019-05-13 15:02:46.298] TRACE: [connection:1] sending resp data, buf count: 1, total size: 5 [2019-05-13 15:02:46.298] TRACE: [connection:1] outgoing data was sent: 5 bytes [2019-05-13 15:02:46.298] TRACE: [connection:1] finishing current write group [2019-05-13 15:02:46.298] TRACE: [connection:1] should keep alive [2019-05-13 15:02:46.298] TRACE: [connection:1] start waiting for request [2019-05-13 15:02:46.298] TRACE: [connection:1] continue reading request [2019-05-13 15:02:46.298] TRACE: [connection:1] EOF and no request, close connection [2019-05-13 15:02:46.298] TRACE: [connection:1] close [2019-05-13 15:02:46.298] TRACE: [connection:1] close: close socket [2019-05-13 15:02:46.298] TRACE: [connection:1] close: timer canceled [2019-05-13 15:02:46.298] TRACE: [connection:1] close: reset responses data [2019-05-13 15:02:46.298] TRACE: [connection:1] destructor called
, RESTinio 167 . , , RESTinio .
, RESTinio - response_builder , .
. , , . response_builder . , responce_builder , ..
.
, ?
, request_handler- - . , , ?
RESTinio , - request_handler-. - , , RESTinio . , . , :
[2019-05-13 15:32:23.618] TRACE: starting server on 127.0.0.1:8080 [2019-05-13 15:32:23.618] INFO: init accept #0 [2019-05-13 15:32:23.618] INFO: server started on 127.0.0.1:8080 [2019-05-13 15:32:26.768] TRACE: accept connection from 127.0.0.1:49502 on socket #0 [2019-05-13 15:32:26.768] TRACE: [connection:1] start connection with 127.0.0.1:49502 [2019-05-13 15:32:26.768] TRACE: [connection:1] start waiting for request [2019-05-13 15:32:26.768] TRACE: [connection:1] continue reading request [2019-05-13 15:32:26.768] TRACE: [connection:1] received 78 bytes [2019-05-13 15:32:26.768] TRACE: [connection:1] request received (#0): GET / [2019-05-13 15:32:30.768] TRACE: [connection:1] handle request timed out [2019-05-13 15:32:30.768] TRACE: [connection:1] close [2019-05-13 15:32:30.768] TRACE: [connection:1] close: close socket [2019-05-13 15:32:30.768] TRACE: [connection:1] close: timer canceled [2019-05-13 15:32:30.768] TRACE: [connection:1] close: reset responses data [2019-05-13 15:32:31.768] WARN: [connection:1] try to write response, while socket is closed [2019-05-13 15:32:31.768] TRACE: [connection:1] destructor called
- . , , RESTinio , .. .
- handle_request_timeout
, RESTinio- ( ).
Conclusão
, , RESTinio — , . , RESTinio, , RESTinio, .
RESTinio , , , : ? - ? - ? - - ?
PS. RESTinio , SObjectizer, . , - RESTinio , : " C++ HTTP- ", " HTTP- C++: RESTinio, libcurl. 1 ", " Shrimp: HTTP C++ ImageMagic++, SObjectizer RESTinio "