Sockets: Criando um httpd simples (Parte 5)

Em teoria, desenvolver um http daemon é simples: Para cada conexão cria-se uma thread que a aceitará, lerá a requisição, a processará, executará alguma ação com base nos dados da requisição e enviará uma resposta. Mas, temos que considerar os problemas nesse meio tempo! Já que estaremos lidando com um stream de dados que podem nem mesmo chegar a ser transmitido completamente ou a conexão pode cair no meio da coisa toda…

Neste meu exemplo de implementação (que estou fazendo devagarzinho para tentar explicar o funcionamento!), criarei uma thread para cada conexão que chamarei de sessão, seguindo a nomenclatura conhecida pelos desenvolvedores “web”… A “sessão” faz o que descrevi no primeiro parágrafo e precisaremos manter uma lista de sessões ativas (threads) para que, em casos especiais, possamos percorrê-la e nos livrarmos daquelas que não atendam mais certos requisitos… O tempo de inatividade é um deles.

A primeira ideia era implementar um garbage collector. Uma thread isolada que ficaria varrendo a lista, de tempos em tempos, à procura de estados específicos de uma sessão e a última vez que este estado foi iniciado… Por exemplo: Se a sessão estiver em estado IDLE por mais que 30 minutos, a matamos… Outros estados podem ser implementados, como WAITING_DATA e/ou RECEIVING_REQUEST, PROCESSING e TRANSMITTING_RESPONSE. Para simplificar escolhi esse estado “ocupado” sendo apenas BUSY. O terceiro estado óbvio é CLOSED, se o cliente ou o servidor fecharem a conexão (fácil de detectar com recv retornando 0, neste caso).

Ao invés de usar uma thread isolada para implementar o garbage collector usarei a própria thread que “ouve” as conexões. Vale lembrar que a função poll pode receber um parâmetro de timeout… No caso do socket listener, se esperarmos mais que, por exemplo, 30 segundos, por uma conexão, trataremos o timeout de poll e executaremos o coletor de lixo, lidando com a lista de sessões, verificando seus estados e respectivos timeouts. Se houver um timeout numa sessão basta cancelarmos a thread com pthread_cancel() que, ela própria, conterá o mecanismo para auto-destruição.

Neste ponto, vale a pena mostrar como os estados são mantidos. Inicialmente pensei em alocar todas as estruturas dinamicamente, no heap. No entanto, cada thread tem uma pilha relativamente grande, de cerca de 16 KiB, no mínimo (usarei a constante PTHREAD_STACK_MIN em limits.h), e uma “guarda” default de 4 KiB… Como assim “guarda”? Eis como a pilha, inclusive da thread principal, funciona: A medida que RSP vai sendo decrementado e dados vão sendo gravados, quando o endereço em RSP cruza a fronteira da última página alocada para a pilha entramos na página de “guarda” (não é à toa que ela tenha, por default, 4 KiB!). Essa página, provavelmente, é alocada com o atributo PROT_WRITE desligado (ou seja, é “read-only”), o que gera uma falta de página… No caso da thread principal, o sistema operacional lida com a falta aumentando a quantidade de páginas para a pilha até o limite especificado em ulimit -s (de cerca de 8 MiB, na minha estação de trabalho). Se passar desse limite o Linux te dará um erro de “Stack Overflow” e abortará o processo… Esses 8 MiB incluem a página de guarda!

Já numa thread secundária, o tamanho da página é fixo, definido pelos atributos da thread via função pthread_attr_setstacksize() que, por default, pode chegar até o mesmo limite especificado em ulimit -s. Não é isso que eu quero: Definirei que cada thread secundária poderá ter uma pilha de tamanho máximo de 16 KiB, o que é mais que suficiente para conter tanto variáveis locais da thread quanto os endereços de retorno das chamadas das funções… Acontece que, se esse limite for extrapolado, entramos na página de guarda e a thread “crasha”… Podemos retirar a página de guarda via pthread_attr_setguardsize(), mas é prudente deixá-la por lá! Isso só fará que cada thread tenha uma pilha de 20 KiB.

Porque o limite da pilha é importante? Quanto menor a pilha, mais threads podemos disparar sem nos importamos muito com o consumo de memória e, ainda, manter as estruturas de sessões locais às threads (veja a função session_manager(), abaixo). O que colocaremos na lista gerenciada pelo garbage collector, que chamarei de sessions_list, são apenas os ponteiros para essas estruturas locais. Podemos fazer isso porque todas as threads compartilham o mesmo address space do processo…

Até o momento, a estrutura de uma sessão usada no projeto sofreu uma simplificação:

typedef struct session_s {
  /* Para controle de thread. */
  pthread_t thread_id;
  pthread_mutex_t mutex;
  
  /* Para controle de conexão. */
  int sock_fd; /* obtido via accept() */
  int close_session; /* Por default é 0 (falso). */
  struct sockaddr_in sin; /* Endereço/porta do cliente. */
  socklen_t sin_sz; /* No futuro podemos querer usar
                       IPv6... quardo o tamanho da 
                       estrutura aqui. */

  /* Para controle de estado da sessão. */
  session_state_t state;
  time_t state_time;  /* timestamp da última
                         atualização de estado. */

  /* A requisição */
  request_t req;

  /* Não precisa de ponteiro para resposta.
     Ela será montada e enviada por último. */
} session_t;

A sessão é mantida pela thread com a simples declaração da estrutura… A função abaixo está incompleta (faltam os sincronismos de thread com o garbage collector):

/* Essa é a thread que tratará da sessão. */
void *session_manager(void *data)
{
  int listener_fd = *(int *)data;
  session_t s; /* A sessão é local à thread! */

  /* Inclusive, seta a sessão para SESSION_IDLE. */
  session_init(&s);

  /* Aceita a conexão. */
accept_again:
  if ((s.sock_fd = accept(listener_fd,
                          (struct sockaddr *)&s.sin, 
                          &s.sin_sz)) == -1)
  {
    if (errno == EINTR) goto accept_again;
    else return (void *)-1;
  }

  /* Muda o modo do socket para O_NONBLOCK. */
  set_socket_non_blocking_mode(&sock_fd);

  /* Insere a sessão na lista do garbage collector. */
  dlist_insert(&sessions_list, &s);

  /* Registra procedimento de limpeza da sessão.
     Ele, inclusive, retira a sessão da lista do
     "garbage collector". */
  pthread_cleanup_push(session_cleanup, &s);

  /* Só sai se a thread for cancelada ou
     se o processamento foi completado e
     http keep alive não for requisitado. */
  while (1)
  {
    request_t *req = &session.req;

    request_init(req);

    /* get request. */
    if ((recv_req_buffer(s.sock_fd, 
                         &req->buf, 
                         &req->pld)) == -1)
    {
      /* O usuário desconectou? Sai da sessão. */
      if (errno == ECONNABORTED)
        break;

      /* A requisição é muito grande? Loga isso
         e sai da sessão. */
      if (errno == E2BIG)
      {
        error("Request from %s:%hu too long.", 
              inet_ntoa(s.sin.s_addr), s.sin.s_port);
        break;
      }

      if (errno == ETIME && 
          buffobj_isempty(req->buf))
        continue;
    }

    /* processa a requisição... */
    process_request(req);

    /* chama a função que irá lidar com a requisição,
       montar a resposta e enviá-la. */
    handle_request(req);

    /* Acabamos por aqui! Livra-se da requisição. */
    request_destroy(req);

    /* Se o atributo "Connection" do header HTTP é
       "close", então sairemos e fecharemos a sessão. */
    if (s.close_session)
      break;

    /* Fez tudo o que devia, coloca sessão em idle. */
    set_session_state(&s, SESSION_IDLE);
  }

  pthread_cleanup_pop(1);

  return (void *)0;
}

A rotina session_cleanup faz a mágica de liberar toda a memória usada pela sessão da thread… Ela será chamada se a thread for cancelada ou terminada normalmente.

Ainda existem detalhes a serem considerados… Mas já que quase todas as funções possuem pontos de cancelamento, o garbage collector, ao detectar a condição suficiente para livrar-se de uma sessão, só tem que chamar pthread_cancel() com o id da thread em questão (e é por isso que ele consta da estrutura da sessão!). O coletor, sem os sincronismos necessários (que serão implementados nas rotinas de manipulação da lista) e sem o bloco de temporização (a coleta não deve ser um loop infinito sem colocarmos a thread para dormir e a acordarmos de tempos em tempos!), fica mais ou menos assim:

void garbage_collector(void)
{
  dlist_node_t *node = sessions_list.head.next;
  dlist_node_t *next = node->next;
  time_t tm;

  time(&tm);
  for (; node != next; node = next)
  {
    next = node->next;
    
    session_t *s = (session_t *)node->data;
    if (session_get_state(s) == SESSION_IDLE)
      if (((tm - s->last_state_time)*60) > SESSION_TIMEOUT)
        pthread_cancel(s->thread_id);
  }
}
Anúncios