I/O por reação à disponibilidade

Published by

on

Revisão em 2022-08-15: definição do conceito handler corrigida.

Completei minha graduação em Ciência da Computação na Universidade do Estado do Rio de Janeiro em 2009, entregando como trabalho de conclusão de curso a monografia “Projetando Servidores Concorrentes Genéricos em C++0x“. Naquele trabalho, explorei o projeto de uma API de “sockets” aproveitando as inovações do C++0x e uma API para servidores concorrentes baseadas na “programação genérica”. Neste post, recapitularei alguns mecanismos de concorrência por reação à disponibilidade.

Grosso modo, o problema do I/O concorrente é o problema de esperar pela possibilidade de trabalhar objetos da camada de I/O, minimizando este tempo de espera, idealmente esperando pelo tempo exato durante o qual há nada para fazer. Programas ingênuos esperam mais do que o mínimo necessário quando aguardam para tratar um objeto enquanto outros estão prontos para serem tratados.

Em muitos contextos, o termo reactor faz referência a um tipo de máquina capaz de reagir a estímulos; no contexto do I/O, reactor refere-se especificamente a um tipo de máquina capaz de reagir a estímulos da camada de rede. No meu trabalho de graduação, me debrucei especificamente sobre máquinas que reagem à disponibilidade de I/O, ou seja, à eventualidade de um certo tipo de I/O estar disponível.

Em abstrato, um reactor por disponibilidade trabalha buffers com capacidade finita, tal que podem estar cheios ou vazios. No caso de buffers de saída, existe disponibilidade se e somente se estiver não cheio; no caso de buffers de entrada, existe disponibilidade se e somente se estiver não vazio. Havendo disponibilidade, o programa pode trabalhar; caso contrário, é preciso aguardar. O programa será ótimo se o tempo de espera for idêntico ao tempo de indisponibilidade.

No núcleo do reactor existe um componente com a forma de uma fila, de onde o reactor tira eventos colocados por algum colaborador, como o sistema operacional. O reactor faz par com este colaborador em uma relação consumidor/produtor. Há disponibilidade se há itens na fila; o tempo durante o qual a fila está vazio é o tempo de indisponibilidade.

Nos exemplos abaixo, vamos ignorar dois problemas muito importantes. Primeiro, vamos ignorar o problema de como cancelar o reactor, ou seja, como fazer o reactor terminar. Segundo, vamos ignorar o problema de como acrescentar ou remover um dispositivo do monitoramento.

A forma geral do núcleo de um reactor é como abaixo.

void react ()
{
  while (true)
  {
    auto event = pop_event();
    auto handler = find_handler(event.tag);
    handler->dispatch(event);        
  }
}

O reactor aguarda pelo próximo evento em pop_event; quando não há nada para fazer, o programa aguarda. De posse do próximo evento, o reactor procura o responsável por este evento e o despacha. Neste caso, o objeto event possui algum tipo de marca dedicada a permitir uma busca indexada. No estado da arte, event permite transportar uma referência direta ao responsável, economizando uma busca indexada, como abaixo.

void react ()
{
  while (true)
  {
    auto event = pop_event();
    auto handler = reinterpret_cast<Handler*>(event.ptr);
    handler->dispatch(event);        
  }
}

O responsável deve fazer alguma coisa útil com a informação de disponibilidade. Assumindo um modelo simples de entradas e saídas, sua forma geral é como abaixo.

void dispatch (event_type event)
{
  if (event.type == in) { do_read(); }
  else if (event.type == out) { do_write(); }
}

A responsabilidade inicial do responsável é interpretar o evento, descobrindo qual é a sua natureza, e reagir de acordo. Se há disponibilidade para entrada, então consumir; se há disponibilidade para saída, então produzir. No estado da arte, event_type permite sinalizar disponibilidade de diferentes naturezas ao mesmo tempo, sendo event.type é um conjunto, como abaixo.

void dispatch (event_type event)
{
  if (event.has(in)) { do_read(); }
  if (event.has(out)) { do_write(); }
}

Caso a entrada esteja disponível, a responsabilidade é consumir itens da camada de I/O para a camada de aplicação; caso a saída esteja disponível, a responsabilidade é produzir itens da camada de aplicação para a camada de I/O. A partir deste ponto, a especificida dos mecanismos envolvidos entra em jogo; se a máquina de eventos é level-triggered ou edge-triggered; se a própria aplicação está ou não está disponível e como sincronizar essas disponibilidades; o que fazer caso o responsável seja muito lento para não “travar” o reactor etc.

Existem vários mecanismos concretos para suprir as necessidades do núcleo do reactor.

Acredito que o mais antigo de todos ainda em produção é o venerável select. O reactor mantém listas de “interesse” em eventos por dispositivo. A função de select é transformar essas listas de “interesse” em listas de “disponibilidade”. Após select, o reactor está em condições de checar as listas de “disponibilidade” e despachar essa informação de acordo. A regra envolvendo weird não é interessante.

void react ()
{
  using event_nature::exception;
  using event_nature::in;
  using event_nature::out;

  fd_set exception {};
  fd_set read {};
  fd_set write {};

  while (true)
  {
    int weird = 0;

    FD_ZERO(&exceptions);
    FD_ZERO(&reads);
    FD_ZERO(&writes);

    for (auto handler : handlers)
    {
      auto interest = handler->interest();
      auto device = handler->device();

      if (interest.has(exception) FD_SET(device,exceptions);
      if (interest.has(in)        FD_SET(device,reads);
      if (interest.has(out)       FD_SET(device,writes);

      weird = std::max(weird,device);
    }
    
    auto r = ::select(weird+1,&reads,&writes,&exceptions,nullptr);
    if (r == -1) return;

    for (auto handler : handlers)
    {
      auto device = handler->device();

      event_type event {};
      if (FD_ISSET(device,&exceptions)) event.set(exception);
      if (FD_ISSET(device,&reads))      event.set(in);
      if (FD_ISSET(device,&writes))     event.set(out);

      handler->dispatch(event);
    }    
  }
}

O mecanismo select representa o interesse e a disponibilidade no mesmo objeto, exigindo reiniciar este objeto a cada rodada. Se sucessor, poll, representa o interesse e a disponibilidade em objetos diferentes, eliminando a etapa de reinício. No Windows, este mecanismo se chama WSAPoll.

std::vector<pollfd> interests;

void react ()
{
  using event_nature::exception;
  using event_nature::in;
  using event_nature::out;

  while (true)
  {
    auto r = ::poll(interests.data(),interests.size(),-1);
    if (r == -1) return;

    for (auto& interest : interests)
    {
      event_type event {};
      if (interest.revent & POLLERR) event.set(exception);
      if (interest.revent & POLLIN)  event.set(in);
      if (interest.revent & POLLOUT) event.set(out);

      auto handler = handlers.find(interest.fd);
      handler->dispatch(event);
    }
  }
}

Ambos os mecanismos select e poll representam interesse e disponibilidade conjuntamente, exigindo percorrer a lista completa de objetos a cada rodada. No Linux, seu sucessor, epoll, representa essas listas separadamente, eliminando do percurso os itens que estão indisponíveis. Além disso, epoll permite representar uma referência ao responsável diretamente em cada item, evitando uma busca.

int queue;

void react ()
{
  using event_nature::exception;
  using event_nature::in;
  using event_nature::out;

  std::vector<epoll_event> items {};
  items.resize(...);

  while (true)
  {
    auto r = ::epoll_wait(queue,items.data(),items.size(),-1);
    if (r == -1) return;

    for (size_t i = 0; i != r; ++i)
    {
      auto& item = items[i];

      event_type event {};
      if (item.event & EPOLLERR) event.set(exception);
      if (item.event & EPOLLIN)  event.set(in);
      if (item.event & EPOLLOUT) event.set(out);

      auto handler = reinterpret_cast<Handler*>(item.data.ptr);
      handler->dispatch(event);
    }
  }
}

Todos esses reactors tem uma definição genérica para qualquer responsável do tipo Handler como abaixo, onde device_type e interest_type ficam como exercício para o leitor.

template <typename T>
concept handler = requires (T t)
{
  { t.device() } -> std::same_as<device_type>;

  { t.interest() } -> std::same_as<interest_type>;

  t.dispatch(event_type{});
};

Assim, podemos generalizar os mecanismos concretos avaliados anteriormente definindo classes template capazes de aplicação por qualquer Handler, como abaixo, onde handlers_type fica como exercício para o leitor.

template <Handler H>
class select_reactor
{
  handlers_type handlers;

  // ...

  void react ();
};
template <Handler H>
class poll_reactor
{
  handlers_type handlers;
  std::vector<pollfd> interests;

  // ...

  void react ();
};
template <Handler H>
class epoll_reactor
{
  int queue;

  // ...

  void react ();
}

Em um posts futuros, vamos abordar preencher algumas lacunas e abordar problemas evitados, como o término do reactor, o registro/desregistro de dispositivos, algumas interações com a API de sockets, mecanismos esquisitos como WSAWaitForMultipleEvents etc. Além disso, espero cobrir uma área que não foi abordada pela minha monografia original: mecanismos que reagem não a disponibilidade mas sim a completude, ou seja, não readiness-based mas sim completion-based.

Uma resposta para “I/O por reação à disponibilidade”.

  1. […] um post anterior, recapitulei antigos estudos sobre concorrência no processamento de I/O com mecanismos de reação […]

    Curtir

Deixar mensagem para I/O por reação à disponibilidade: aplicações – Pedro Lamarão Cancelar resposta