quarta-feira, 12 de setembro de 2012

Dia do Programador 2012: Programação Assíncrona com Notificação de Eventos em C

import datetime
x = datetime.date(2012, 1, 1)
y = datetime.date.today()
if (y - x).days == 256:
    print("Feliz Dia do Programador, via #tocadoelfo!")

u_time hora_postagem = 1347451200;

Hoje é dia do programador! Todos os anos eu faço uma postagem nesta data pra lembrar o porque de eu gostar tanto de programação. O post dessa edição é algo que eu já venho querendo fazer há algum tempo, que é falar sobre eventos em C. Isso tudo porque depois que eu programei em Delphi há alguns anos e comecei a mexer com node.js recentemente, eu vi a luz e me interessei no assunto e pensei, por que não em C?

Sobre o Dia do Programador

O Dia do Programador é uma data festiva no 256º dia do ano, celebrada por programadores de computador em boa parte do mundo. Este número foi escolhido porque é o maior número que pode ser representado por um byte (oito bits). Além disso, esse número é a maior potência de dois que é menor que o número 365 (o número de dias do ano, duh). Também pode ser representado, em hexadecimal como 0x100 e em octal como 0400.

O Dia do Programador é dia 13 de setembro, exceto em anos bissextos (como este ano), nos quais ele é comemorado no dia 12 de setembro, pois esse é o 256º dia do ano bissexto.

Meu Dia do Programador

Quatro meses atrás, eu e alguns amigos estávamos conversando sobre programação. Papo vai, papo vem um deles falou o seguinte:

"Olha, eu só queria que programar sockets fosse mais fácil. Ficar criando threads ou forkeando a aplicação a cada nova conexão é um saco"

Eu prontamente respondi:

"Então, mas dá sim, quem te disse que não é possível?"

E o kra respondeu:

"Não dá, vc precisa ficar monitorando o socket servidor e os malditos sockets clientes, e pra isso é necessário pelo menos dois processos"

Eu disse, em seguida:

"Então, não precisa mesmo! Com um processo só dá pra fazer todo o serviço de servidor"

Eu e meu amigo ainda ficamos discutindo alguns minutos e eu disse "Então tá, vou te mostrar que é possível". E aqui estou, pra provar que é possível sim, e ainda de uma maneira extremamente elegante!

O desafio de criar um servidor com um único processo.


Eu tenho certa bagagem de programação com sockets pra poder afirmar isso. Na época do Delphi, quando eu ainda usava os antigos componentes ClientSocket e ServerSocket, eu não precisava criar nenhum processo para tratar das conexões extras do socket servidor. Eu simplesmente mapeava os eventos OnServerClientConnect e OnServerClientRead, e ele fazia todo o serviço sujo por trás. Só que há tempos eu não programo mais em Delphi, hoje meu foco é Java e Python (e também um pouco de C, que estou aprendendo a passos de tartaruga). Esse assunto ficou um pouco esquecido até eu começar a mexer com Node.js e ficar intrigado com a forma mágica como ele lida com eventos. Chega a ser mais bonito e simples que no Delphi. Claro que eu fui atrás de mais informações sobre isso e cheguei nas bibliotecas libev e libevent.

Essas bibliotecas criam loops de eventos, que ao serem disparados, geram uma notificação que pode ser tratada através de uma função de callback ou diretamente, através dos sinais gerados desses eventos. A libevent é mais conhecida e antiga ao passo que a libev, segundo seus fundadores, é uma biblioteca que foi inspirada na primeira, mas sem suas limitações e bugs. Achei a proposta muito boa, até vi alguns exemplos e mexi um pouco mais, mas no fundo eu não queria usar uma biblioteca só pra isso.

Estou nessa fase de que quero fazer tudo usando chamadas de API do kernel sabe, e então deixei as bibliotecas pra trás e fui pesquisar um pouco mais, e então um amigo me falou da API select() e da poll(). Li sobre as duas, gostei e quando estava para começar a fazer algo com elas, li sobre a epoll() - que existe somente no Linux - e depois de ver sobre as vantagens de usá-la, eu resolvi investir nela.

Alguns de vocês podem argumentar que usar diretamente epoll() diminuirá a possibilidade de ter meu aplicativo rodando em mais ambientes. Na boa, eu não me importo hehehe. Não estou fazendo nenhum projeto mais avançado e querendo conhecer mais do kernel, então vou ficar por aqui mesmo. Sempre será possível usar a API select() ou as bibliotecas citadas acima, para ter um grau de compatibilidade maior, mas esse não é o meu caso.

Bom, já enrolei demais na história, então vamos brincar!

Notificações de Prontidão (que chamarei de Notificação de Leitura)

Uma maneira geral de se lidar com sockets servidores é usar a estratégia "Uma Thread/Processo por Conexão". Segundo o meu amigo C10K, essa seria a abordagem perfeita, se não tivéssemos o problema de que a cada nova conexão, é necessário alocar uma pilha de execução para cada cliente, e isso custa bastante memória.

Ainda no mesmo artigo C10K, ele fala sobre outras implementações usando I/O não bloqueante e algum tipo de notificação de disponibilidade do socket. Existem dois tipos de notificação de disponibilidade que podem ser utilizados nesse caso, que são (não traduzi pois não achei expressões boas e suscintas): Level-Triggered e Edge-Triggered Readiness Notification. Vamos falar de cada uma antes de começar a exemplificar.

Level-Triggered vs Edge-Triggered - Readiness Notification

Esse tipo de notificação de disponibilidade funciona da seguinte maneira: Quando o kernel recebe algum dado para o socket não-bloqueante designado, ele notifica a aplicação de que o socket possui dados no buffer que precisam ser lidos. Até aí os dois modelos funcionam igual. A diferença acontece quando a aplicação entra em suspensão para uma nova notificação de leitura sem que o buffer tenha sido esvaziado.

Caso a notificação de leitura utilizada seja Level-Triggered (select() e poll() tradicionais), uma nova notificação é de leitura é lançada até que todos os dados do buffer tenham sido utilizados. Caso a notificação de leitura seja Edge-Triggered (epoll(), kevent() e kqueue()) o kernel irá lhe notificar somente uma vez sobre a disponibilidade de dados no buffer. Caso a aplicação entre em suspensão para aguardar um novo evento sem ter esvaziado o buffer de leitura (ou seja, quando operação read() retornar o erro EWOULDBLOCK), ela ficará esperando por toda a eternidade, pois o kernel já notificou à respeito daquele socket e não fará novamente.

Segundo o C10K, a segunda implementação é mais simples para o kernel por não haver a necessidade de se guardar o estado do descritor em relação à haver informação ainda pendente de leitura. A primeira implementação exige que o kernel tenha de verificar cada descritor da lista de descritores para verificar se os dados referentes ao último sinal enviado foram todos consumidos. A implementação Edge-Triggered permite que o kernel simplesmente emita as notificações sem se preocupar com o status atual do descritor.

É importante lembrar que a notificação de leitura do kernel é somente um aviso. O descritor pode não estar mais pronto quando você tentar ler dele. Por isso é importante usar um socket que não bloqueia quando utilizando esse método.

Usando epoll() para controlar sockets não bloqueantes

Como disse anteriormente, usar um processo por conexão é algo dispendioso quando estamos pensando em desenvolver algo que irá lidar com um grande número de conexões, por exemplo, mil conexões simultâneas. Você pode se perguntar "por que se preocupar com isso", mas eu digo que é melhor se preocupar com questões de escalabilidade enquanto essas questões ainda são fáceis de resolver e não têm uma implicação muito grande no resto da aplicação. Então, aqui estamos.

Esse post é mais uma introdução (e testes) à syscall epoll(7), considerada a melhor forma hoje de se trabalhar com notificações de disponibilidade no linux (desconsidere libevent e libev por um momento).

A epoll foi introduzida no kernel Linux a partir da versão 2.5.44 e não está disponível em outros sistemas Unix e funciona de forma similar às funções select() e poll(), com algumas diferenças:

select() pode monitorar até uma quantidade máxima de descritores, determinado por FD_SETSIZE, que não é um número muito grande e definido pela libc em tempo de compilação.

poll() não possui um limite máximo de descritores monitorados, mas sua performance degrada à medida que mais descritores são adicionados, pelo fato de a função efeturar uma passagem linear por todos os descritores frequentemente para checar as notificações atuais.

epoll() não possui limites máximos de descritores, nem efetua nenhum tipo de checagem nos descritores adicionados, e por isso é melhor escalado para lidar com uma grande quantidade de eventos.

Criando uma instância de epoll()

Uma instância de epoll() pode ser criada chamando-se a função epoll_create() ou epoll_create1(). As duas funções fazem a mesma coisa, apesar de usarem argumentos diferentes. As funções retornam uma instância de epoll(), representada por um descritor.



Criando um evento em epoll()

Para adicionar descritores à serem monitorados pelo epoll(), usa-se a função epoll_ctl() passando como parâmetro a instância de epoll() e uma estrutura que possui o descritor à ser monitorado e diversas flags que determinam quais eventos serão notificados.



Como vamos usar isso em vários pontos do nosso código, é interessante refatorar esse código para uma função, para diminuir as repetições de código ... Sabe como é, preguiça de escrever a mesma coisa várias vezes ...



Quando os descritores são adicionados à instância de epoll(), eles podem ser adicionados usando um dos dois modos que citamos anteriormente: level triggered e edge triggered. Isso é feito através da opção EPOLLET, que indica que aquele evento em específico segue o modo edge. A ausência de EPOLLET na opção events indica que a instância de epoll() usará o modo level triggered.

Para aguardar por eventos disparados para os descritores registrados na epoll(), usa-se a função epoll_wait(), que bloqueia até que um ou mais eventos estejam disponíveis. Nas manpages de cada função há mais informações sobre cada uma dessas funções.



Fazendo alguma coisa de útil

Bom, já falamos um pouco sobre como usar epoll(), como ele funciona, mas agora é hora de botarmos a mão na massa para fazer algo um pouco mais prático. Para nossa demonstração vamos criar um simples servidor que envia de volta qualquer mensagem que chegar pra ele. Vamos chamá-lo de GossipServer.

Antes de mais nada, vamos setar algumas coisas pra nos ajudar com nosso código:



Primeiramente vamos criar nosso socket servidor. Como eu adoro separar as coisas, criei uma função para fazer especificamente a tarefa de criar e inicializar o socket:



Eu poderia fazer essa criação portável para uso tanto em redes IPv4 quanto em IPv6, mas como minha preocupação aqui não é com o socket em si, então essa implementação já resolve. Mas, em todo caso, a manpage da função getaddrinfo() possui tudo o que você precisa pra criar um socket portável.

Agora que criamos o socket, é necessário torná-lo um socket não-bloqueante. Para isso, devemos simplesmente mudar uma flag no descritor do socket e adicionar a opção O_NONBLOCK:



Feito isso, podemos dar prosseguimento à nossa aplicação. Iremos aqui criar uma instância de epoll, adicionar o socket servidor no mesmo e iniciar o looping de eventos para o epoll:



Primeiramente, a função main() chama cria_servidor() que faz o trabalho de criar nosso servidor. Em seguida, chama a função nonblock_socket() que faz o socket passar para o modo não bloqueante. Em seguida, cria uma instância de epoll() e usando a função cria_evento() adiciona o socket servidor ao epoll() criando um evento edge-triggered.

Em seguida, temos o loop principal da aplicação. Esse loop é o nosso loop de eventos, por assim dizer, pois chamamos a função epoll_wait(), que bloqueia à espera de eventos. Quando há eventos disponíveis, epoll_wait retorna a quantidade de eventos disparados e o argumento i_eventos preenchido com os eventos.

Quando há eventos disponíveis, eles podem ser de três tipos:

Erros: Quando alguma condição de erro ocorre ou o evento não é uma notificação de dados disponíveis para leitura, nós simplesmente fechamos o descritor. Fechar o descritor automaticamente remove o mesmo do conjunto observado pela instância de epoll().

Novas Conexões: Quando o socket que está disponível é o socket servidor, significa que uma ou mais conexões foram solicitadas.

Dados de Cliente: Quando o socket que está disponível não é o socket servidor, significa que há dados disponíveis para serem lidos no socket designado.

Mas olha, há duas funções novas ali no meio do looping de eventos que não existem: ev_clienteconecta() e ev_clientedadosdisp(). Essas duas funções (que explicarei abaixo) são chamadas quando o comando epoll_wait() retorna com os descritores de eventos para os eventos que chegaram. A única diferença aqui é no tratamento dos sockets, já que se o evento disparado é para o socket servidor, significa que temos conexões esperando para serem efetuadas, ao passo que se for qualquer outro socket, isso indica que há dados aguardando para serem recebidos.

Recebendo novas conexões de clientes

Como vocês podem ter percebido, o socket servidor foi o primeiro cadastrado no epoll para ser monitorado. Só que ao contrário do socket cliente, o socket servidor somente escuta por novas conexões, que devem ser aceitas usando o comando accept(). A função ev_clienteconecta() faz justamente isso: ele pega o socket servidor e o epoll como parâmetro e aceita conexões que estejam aguardando serem iniciadas. Em seguida, depois de aceitado a conexão e ter criado um descritor de socket cliente correspondente à conexão efetuada, nós inserimos o socket cliente no epoll para também ser monitorado.



Quando essa função é chamada, significa que temos clientes aguardando para se conectar ao nosso servidor. Aqui nós aceitamos a conexão com accept(), pegamos algumas informações do socket cliente com getnameinfo() só para exibição, convertemos o socket para o modo não bloqueante com nonblock_socket() e chamamos a função cria_evento() para adicionar esse socket na nossa instância de epoll().

Você pode estar se perguntando qual o motivo do laço de repetição. Então, quando o evento é disparado, nós podemos ter um ou mais conexões aguardando, e precisamos tratar todas elas, da mesma forma que precisamos ler todos os dados em um socket cliente pois não iremos ser novamente notificados pelos dados não lidos (nesse caso, pelas conexões ainda não aceitas).

Recebendo dados dos clientes

O evento ev_clientedadosdisp() é uma função criada para fazer o tratamento dos dados recebidos pelo socket passado como parâmetro. Aqui temos de tomar especial cuidado, principalmente na hora de receber os dados do socket. Como os eventos que estamos registrando em epoll() são do tipo edge-triggered, nós temos de ler todos os dados disponíveis no socket antes de sair da função (o que fará com que epoll_wait() seja chamado novamente) pois nós não teremos novas notificações de leitura para os dados que não foram lidos, até que novos dados estejam disponíveis e outro evento seja disparado. Isso pode ser ruim por um lado, mas por outro nos permite ter de prestar mais atenção ao processo de receber os dados, já que se não lermos todos os dados, não seremos notificados novamente à respeito deles.



Algumas considerações. Quando lemos algum dado do socket com read(), ele retorna um valor positivo, indicando que dados foram lidos. Se retornar -1, algum erro foi disparado e é necessário verificar a variável errno para saber qual o erro. Se o erro for EAGAIN, isso indica que todos os dados do socket já foram lidos e que podemos sair da função. No entanto, se retornar 0, isso indica EOF e que a conexão foi fechada pelo lado do cliente, então podemos fechar a ponta de cá.

Aqui, temos novamente um laço de repetição, e o seu objetivo é o mesmo da função anterior, nos certificar de que todos os dados foram lidos.

Conclusão

Então, como podem ver usar epoll() não é assim um bicho de sete cabeças. No começo eu não conseguia encaixar o funcionamento de epoll() com um loop de eventos até me lembrar da forma como o Delphi tratava os eventos de janela que chegavam para aplicação. A partir daí, consegui finalmente visualizar o problema como um todo e então começar a desenvolver efetivamente. É como disse no post "Dia do Programador 2011: Vantagens de ser um Programador Poliglota", suas experiências passadas sempre podem ser úteis quando se está aprendendo coisas novas.

Agora, sobre o epoll(), realmente a API é bastante simples e bastante poderosa. Como alguns de vocês notarão, eu usei algumas coisas que não são totalmente portáveis entre sistemas (o próprio uso de epoll(), usar read() e write() para as operações de socket) mas que aqui não fazem diferença, pois minha idéia era mostrar o quão útil pode ser essa API. E por não sofrer dos problemas que outras implementações mais portáveis sofrem, ele pode lidar facilmente com grandes quantidades de conexões clientes.

Esse exemplo é mais uma prova de conceito do que um código prático. Se você realmente quer usar algo que seja portável, eu sugiro que você procure saber mais sobre as bibliotecas de eventos que citei no começo do post. Elas fazem tudo isso que eu demonstrei aqui, e até algumas coisas mais legais, como funções de callback para os eventos.

Eu não falei sobre todo o código. Minha ênfase foi na implementação de epoll() e um pouco na modularização do código. Eu realmente acredito que um código mais esparso mas mais organizado é muito melhor para entender. Não existe sintaxe obscura, existe programador que não escreve código bonito. E eu gosto de escrever código que seja bonito, legível e fácil de entender.

Caso você queira baixar o código completo, você irá encontrar no github:gist intitulado Socket não bloqueante com notificação de eventos usando epoll(). Aproveite!

P.S.: Sobre o cliente.

Você deve estar se perguntando: "Eduardo, mas e o cliente, você não vai implementar?". Eu não, pois pra isso eu posso usar o netcat, que é muito mais prático do que criar um cliente em C só pra isso. Mas, quem sabe eu não faça um post futuro com outras técnicas diferentes? We never know!

É isso! Espero que tenham gostado desse post e feliz Dia do Programador para vocês!

Fontes:

man epoll
epoll() - Asynchronous Network Programming
Hacker News
C10K
Create TCP Echo Server using libev