domingo, 3 de fevereiro de 2008

POSIX Sockets e Windows Sockets (Parte I)

Certa vez, vi uma entrevista (creio que foi no channel 9 da Microsoft) sobre funcionamento do Kernel do Windows Vista, e o engenheiro da Microsoft entrevistado falou sobre modelos de programar com sockets, no estilo Winsock e estipo POSIX.
Segundo o engenheiro, programar a Winsock no estilo Posix seria subutilizar a API, e que os programadores apenas precisam aprenas reaprender a programar no estilo MS.

Bem, tendo trabalhado com sockets utilizando a Winsock e implementações de sockets Posix em diversos Sistemas operacionais, decidi escrever três posts sobre o assunto, para auxiliar aqueles que, como eu, quebram a cabeça com o assunto. Os exemplos citados são sobre o protocolo IP, naturalmente por ser o mais consultado pelos leitores.

Primeiro vamos enumerar algumas tarefas comuns e necessárias ao programar com sockets.
  1. Criar um tratador para o socket;
  2. Para um servidor, fazer bind, entrar em listen e aceitar conexões;
  3. Para um cliente, conectar;
  4. Enviar e receber dados;
  5. Trabalhar de forma assíncrona e definir timeouts;
  6. Tratar múltiplos clientes em um servidor;
Este me parece um conjunto básico bem arranjado, mas se alguém quiser sugerir alterações, fique a vontade em comentar. Neste primeiro post então vamos descrever como fazer as coisas nesta lista utilizando sockets Posix, e como algumas implementações em alguns SOs podem variar, vou tentar ser o mais "compatível" possível.

1. Criar o socket
Bem, criar o socket é bastante simples e direto. A função Posix a ser utilizada é chamada socket. Geralmente, possui 3 parâmetros como ilustra a assinatura (em C) abaixo.

socket_handle = socket(int socket_family, int socket_type, int socket_protocol)

Não vamos entrar em detalhes sobre os parâmetros, mas é aqui que definimos se socket é do tipo com ou sem conexão, TCP/UDP/RAW, IPv4, IPv6, etc. O importante é o tratador socket_handle resultante, que é utilizado para qualquer outra operação sobre este socket.

2. Colocando um servidor para receber conexões
Para fazer um socket de um servidor (ou serviço de socket) aceitar conexões, são necessários 3 passos: fazer a vinculação do socket a uma porta e interface de rede, colocar para "ouvir" e aceitar conexões. São tarefas bastante simples, na maioria dos casos.
Vincular o socket a uma porta e interface de rede é possível utilizando a função bind. Os parâmetros da função recebem o tratador do socket (criado no passo 1) e uma estrutura cujos campos informam à qual porta o socket estará vinculado, i.e, em qual porta o servidor ficará ouvindo. O importante para uma vinculação controlada, é acertar corretamente os parâmetros da estrutura sockaddr passada como parâmetro. O campo sin_port define a porta do socket, e é utilizado na notação de rede (network byte order) então precisa ser convertido utilizando alguma função como por exemplo, htons.
Isto quer dizer que, se deseja-se colocar o socket para receber conexões na porta 6600, não é possível atribuir diretamente sockaddr.sin_port=6600, o correto seria sockaddr.sin_port=htons(6600).

Outro campo importante é sin_addr. Neste campo define-se em qual interface o socket será vinculado. Isto é aplicável quando se tem diversas placas de rede e quer colocar o programa ouvindo em apenas uma, ou apenas na inteface de loopback (127.0.0.1). Algumas constantes podem ser utilizadas, como por exemplo, INET_ADDR_ANY para ouvir em todas as intefaces, e INET_ADDR_LOOPBACK para ouvir na interface de loopback. Para definir uma interface em específico, este campo recebe o endereço IP da interface também na notação de rede. Por exemplo, se deseja-se vincular o socket a interface 10.10.1.5, converte-se o endereço para a notação de rede, e atribui-se em sin_addr, que também é uma estrutura, e possui geralmente apenas 1 campo in_addr_t. O exemplo então ficaria:

sockaddr.sin_addr.in_addr_t=inet_addr("10.10.1.5")

Configurado tudo, chama-se a função bind com os parâmetros desejados. Em caso de sucesso (pode falhar como, por exemplo, se a porta estiver sendo já utilizada) chamamos a função listen para colocar o socket para ouvir.
A chamada a listen tem 2 parâmetros, sendo um o socket e o outro o chamado backlog. Este último é importante pois determina o tamanho da fila que existirá para as conexões pendentes. Este parâmetro deve ser cuidadosamente ajustado quando deseja-se criar serviços de alta disponibilidade e velocidade de resposta.

Aceitar conexões é pertinente apenas para sockets conectados (do tipo stream por exemplo e aqui se encaixa sockets TCP) e é feita com a função accept. Neste ponto o servidor irá párar de executar, até que algum cliente conecte, isto é, a função só retorna quando alguém conectar na porta definida. O resultado da função é um tratador para o socket cliente, por onde pode-se fazer todo o futuro envio/recebimento de dados. Informações extras são retornadas na estrutra sockaddr, passada como parâmetro para a função.


3. Conectando um cliente em um servidor
Esta parte é bastante simples na maioria das vezes, e é pertinente apenas para sockets conectados. Esta etapa é feita utilizando a função connect. Com o sucesso da função, informações sobre a conexão realizada retornam no parâmetro sockaddr passado à função. Uma vez que conectado, o tratador do socket cliente é válido para realizar envio e recebimento de dados.

4. Enviando e recebendo dados
Enviar dados em sockets conectados é normalmente feito com a função send, e para sockets não conectados utiliza-se a função sendto. Ambas recebem parâmetros que informam o socket, os dados e a quantidade de dados (tamanho) que será enviado.
Para receber dados, utiliza-se normalmente a função recv, para sockets conectados, e recvfrom para sockets não conectados. Estas também recebem parâmetros com informações dos dados a serem recebidos.
Neste ponto é muito importante verificar os buffers utilizados e seus tamanhos. É um ponto comum para falhas de segurança ao lidar-se com sockets.

5. Trabalhar de forma assíncrona
Imagine que o programa que se deseja fazer necessita ao mesmo tempo processar os dados na conexão via socket (enviar e receber) e ainda receber entradas do usuário em uma janela. Uma forma de fazer isto é tratando as operações de socket de forma assíncrona.
POSIX sockets assíncronos são muito utilizados, e apesar de não ser obrigatório para fazer uma aplicação funcionar, é obrigatório para que as aplicações tenham boa usabilidade e tolerância. Talvez a maneira mais simples de codificar isto seria utilizando threads, e fazendo todas operações com os sockets dentro de threads.
Algo bem simples seria, colocar todo o processo de criação do socket, conexão (entrar em listen ou conectar em um servidor), envio e recebimento dentro da thread.
Isto funciona suficientemente bem para uma aplicação simples. Mas não tem-se muito controle
Por exemplo, é preciso determinar tempo máximo para uma conexão tentar encontrar um servidor, ou tempo máximo de envio de dados, ou até mesmo quantas conexões por segundo um servidor vai querer tratar. Para isto, é interessante utilizar uma combinação de processamento assíncrono com threads e sockets não bloqueantes.

No universo POSIX funciona assim: utiliza-se a função fcntl para colocar um socket como não bloqueante, passando e ativando o flag O_NONBLOCK. Este flag deve ser ativado antes de qualquer operação que pode bloquear e afeta o funcionamento das funções de accept, connect, recv/recvfrom e send/sendto. Estas funções retornam de suas chamadas imediatamente e permitem que o código continue rodando normalmente.
Assim pode-se ter mais controle sobre o fluxo de execução do código, permitindo sair do tratamento do socket, retornando em caso de erros ou em caso de mal funcionamento de alguma coisa. Por exemplo, imagine que estamos codificando a conexão de um cliente a um servidor que está super utilizado, e demorando para atender as solicitações. A chamada connect, por padrão bloqueante, não retornará até que a conexão seja estabelecida, ou algum erro ocorra (algumas implementações definem um tempo limite de algumas horas como padrão, o que é bastante tempo).
Deixar o usuário esperando por horas para dizer algo ao usuario, por que estamos esperando a resposta do connect, não parece algo muito prático.
Assim, com o socket não bloqueante, podemos definir o tempo que queremos pois a função vai retornar imediatamente. Mas como saber se teve resultado?
Bem, para qualquer operação com sockets POSIX é sempre interessante verificar a variavel errno, que no caso de sockets não bloqueantes retornam EAGAIN ou EWOLDBLOCK ao serem chamadas.
Mas, para saber com mais precisão quando um socket está disponível para gravação ou leitura, utiliza-se as funções select e/ou poll. Ambas possuem parâmetros para definir um timeout da operação, e para dizer se o socket está disponível para ser lido, gravado, ou com erro.
Combinando isto tudo, com threads, temos um processamento assíncrono, não-bloqueante com sockets, sejam conectados ou não. Alguns detalhes devem ser observados, no que diz respeito a pequenas diferenças de comportamento de sockets não-bloquantes, conectados ou não (especialmente TCP e UDP). Também os sinais passados ao programa (POSIX signals). Isto é bem documentado em qualquer implementação de sockets POSIX, e de fácil identificação.

Um exemplo do fluxo de código para um cliente IP não-bloqueante que deseja apenas enviar um fluxo de dados, poderia ser:
  • Criar o socket cliente e configurar o servidor de conexão;
  • Definir o socket como não bloqueante (com fcntl)
  • Fazer a chamada a connect();
  • Fazer uma chamada a select com um tempo limite de 10s para verificar se o socket está disponível para gravação;
  • Caso estiver, enviar os dados;
  • Fechar o socket;
Nunca esqueça de fechar o socket com a chamad close (em algumas implementações, closesocket) para libera o tratador utilizado pelo programa.

6. Tratar múltiplos clientes em um servidor
Um servidor de sockets mais elaborado deve ser capaz de tratar múltiplos clientes ao mesmo tempo.
Para isto, basta combinar sockets não-bloqueantes com threads no momento certo. E com alguns passos básicos é possível fazer algo funcional:
  • Crie um socket e configure porta e interface de rede que deseja-se abrir as conexões;
  • Configure o socket para não bloqueante;
  • Coloque dentro de um laço uma chamada para accept;
  • Para cada resultado de accept com sucesso, crie uma outra thread, passando o tratador recebido no resultado do accept como parâmetro;
  • Esta nova thread se encarregará do envio/recebimento e processamento dos dados;
  • Uma vez criada a thread, o laço faz voltar ao accept, e estamos prontos para processar outra conexão, enquanto a anterior está sendo tratada dentro da thread;
Vale lembrar que nem sempre o socket resultante de um accept em um socket não-bloqueante também é não-bloqueante. Contudo, pode-se definí-lo como não bloqueante após o accept, utilizando novament fcntl.

Esta primeira parte tratou então das diversas opções em trabalhar com sockets POSIX. De forma bem simples, tentamos definir os passos básicos para o uso geral de sockets, mas sem entrar em detalhes da API, que podem ter sua documentação encontrada facilmente.
No próximo post estaremos mostrando como realizar as mesmas tarefas, porém utilizando a Winsock 2.
Até lá.

Referências
- Linux manpages, incluindo funções POSIX sockets: http://linux.die.net/man/
- GNU Sockets: http://www.gnu.org/software/libc/manual/html_node/Sockets.html
-

Nenhum comentário: