Programação UDP e TCP sobre "Sockets de Berkeley"Os "sockets" de Berkeley são uma API genérica para programação sobre protocolos de comunicação. A implementação das system-calls desta interface é "standard" em todos os sistemas operativos UNIX e estende-se também a outras plataformas. Dentro do possível é mantida a semântica associada aos descritores de ficheiros (io.h), os descritores são números inteiros usados para facultar o acesso ao controlo das comunicações embebido no núcleo e são aqui conhecidos por "sockets". De um modo geral, quando ocorre um erro as "system-call" devolvem o valor -1, as aplicações devem verificar sempre a ocorrência de erros, para simplificar o código dos exemplos aqui apresentados este aspecto é muitas vezes omitido. Como esta API foi concebida para suportar diversos protocolos terá de suportar diversos formatos de dados, tais como os endereços. Estes são armazenados numa estrutura especial genérica: struct sockaddr { u_short sa_family; char sa_data[14]; }; Para cada família de protocolos existem depois estruturas mais especificas e adequadas, por exemplo para o protocolo IP, que nos interessa neste momento, estão definidas as seguintes estruturas:
Como se pode verificar a estrutura sockaddr e sockaddr_in têm exactamente o mesmo tamanho e o campo que designa a família de protocolos (sa_family e sin_family) têm posição coincidente. Abertura de Sockets Para ter acesso aos protocolos de comunicação começa-se por abrir um socket, para o efeito utiliza- se a "system-call" "socket" que devolve um descritor necessário em todas as operações subsequentes. Como já foi referido, esta API permite a utilização de diversos protocolos, assim esta "system-call" envolve vários parâmetros que descrevem o protocolo a utilizar:
A tabela 1 mostra algumas combinações possíveis
das constantes a usar para cada um dos parâmetros,
para a família AF_INET que de momento nos
interessa.
Quando se utiliza os protocolos UDP e TCP o parâmetro protocol não é necessário, pois, fica implícito pelo parâmetro type, devendo então ser utilizado o valor zero. Em sistemas operativos protegidos como o UNIX, os "sockets" do tipo SOCK_ROW não são permitidos para utilizadores correntes. Quando um descritor já não é necessário, tal como acontece com os ficheiros, deverá ser encerrado, para tal é usada a "system-call" close. Associação de Endereços a "Sockets" Antes de se poder receber ou enviar dados através de um descritor aberto, é necessário definir a porta que vai ser usada por esse descritor. Para este efeito deve ser utilizada a "system-call" bind:
O parâmetro sock é o descritor devolvido anteriormente pela função socket, o segundo parâmetro é um apontados para a estrutura que contém o endereço e o último parâmetro é o tamanho dessa estrutura. Se o bind falha (devolve -1), a causa mais provável é que a porta definida em myAddress esteja já a ser usada. A seguir apresenta-se um exemplo de utilização:
O valor INADDR_ANY representa o endereço IP da máquina onde a aplicação é executada. A utilização desta constante é vantajosa, se a máquina possui mais do que um endereço IP ("router") permite a recepção de dados em qualquer dos endereços da máquina. No exemplo utiliza-se a porta 6520, se o "socket" for usado para recepção de dados o emissor terá de os enviar para esta porta. Normalmente quando uma porta se destina a emissão não há necessidade de ter um valor preestabelecido, nestes casos pode usar-se o valor 0 que força o sistema a atribuir uma porta livre. A função bzero é usada para colocar zeros na estrutura myAddr. As funções htonl e htons permitem a conversão de números inteiros longos e curtos do formato interno da máquina ("host") para o formato usado na rede ("net"). As funções ntohl e ntohs, não usadas neste exemplo, realizam a operação inversa. Emissão e Recepção de "datagramas" UDP As "system-call" mais importantes para a emissão e recepção de "datagramas" UDP são sendto e recvfrom:
O parâmetro sock é o descritor a ser usado para o envio ou recepção dos datagramas, buffer e buffSize definem onde estão os dados a enviar, ou onde devem ser colocados os dados a receber. O parâmetro flags permite usar algumas opções que alteram alguns aspectos do modo de funcionamento destas "system-call", normalmente terá o valor zero. A estrutura to contém o endereço de destino para o "datagrama" a ser emitido, a estrutura from é usada para guardar o endereço de proveniência de um "datagrama" recebido. Note-se que o último parâmetro é passado de forma diferente em cada uma das "system- call". Os exemplos simples apresentados a seguir ilustram a utilização destas "system-call", o primeiro envia "datagramas" UDP contendo uma linha de texto para a porta 8450 da máquina com endereço 193.136.62.4:
O segundo exemplo recebe "datagramas" na porta 8450 e deverá ser usado na máquina com endereço 193.136.62.4 para recepção dos "datagramas" emitidos pelo programa anterior:
Enquanto a aplicação que recebe "datagramas" tem de usar uma porta bem definida, aplicação que os emite utiliza uma porta atribuída dinamicamente pelo sistema. Note-se que embora ambos os exemplos usem duas estruturas para guardar endereços (local e remoto), apenas no caso do emissor é necessário definir previamente o endereço remoto. Neste ultimo exemplo, após a recepção de uma datagrama, o endereço de proveniência está disponível na estrutura from. Para apresentar no receptor a porta de proveniência e endereço IP basta converter os dados da estrutura from para alfanumérico. A função inet_ntoa converte um endereço IP guardado dentro numa estrutura in_addr em texto legível:
No exemplo anterior, para apresentar a porta e endereço de proveniência do "datagrama", antes da linha de texto, bastaria acrescentar as seguintes linhas.
Quando se usam "datagramas" para diálogo entre duas aplicações a verificação de endereços de proveniência é importante porque não existe conexão. Para o receptor responder ao emissor tem usar o endereço de proveniência, por outro lado os "datagramas" podem ser originários de terceiros. Servidores UDP elementares O modelo cliente-servidor está muitas vezes associado ao conceito de sessão que implica a manutenção pelo servidor de contextos separados para cada um dos clientes activos. Neste caso diz-se que o servidor é "statefull", e a resposta a um pedido depende do diálogo privado anterior entre o cliente e o servidor. O protocolo UDP não é orientado à conexão, pelo que impossibilita uma implementação directa de sessões entre clientes e servidores. Como o servidor não tem estado os pedidos devem limitar-se a um datagrama, o mesmo se passando com as respostas. Isto não quer dizer que seja impossível a implementação de sessões sobre UDP, mas como não existe suporte de conexões o esforço da aplicação será substancialmente maior já que terá de ser esta a realizar a distinção entre "datagramas" de diferentes origens e processar os mesmos em diferentes contextos. Por outro lado o UDP não é fiável, para implementar sessões sobre ele será necessário que as aplicações cliente e servidor procedam elas próprias à detecção e correcção dos diversos tipos de erros a que os "datagramas" estão sujeitos. Por estas razões o UDP é mais adequado para servidores sem estado em que os pedidos e respostas se limitam a um único datagrama. Quando se pretende estabelecer uma sessão cliente-servidor é aconselhável utilizar uma conexão TCP. Um servidor UDP é uma aplicação que escuta constantemente a chegada de "datagramas" contendo pedidos, processa os pedidos e envia um "datagrama" de resposta ao emissor do mesmo. Como é o cliente que toma a iniciativa de contactar o servidor, este ultimo tem de escutar os pedidos numa porta preestabelecida entre os dois. O cliente pode usar uma porta qualquer atribuída dinamicamente pelo sistema. Para enviar a resposta ao cliente, o servidor pode consultar o endereço de proveniência do pedido. Exercício Implementar um servidor UDP que receba um "datagrama" com uma linha de texto e a devolva ao emissor convertida para maiúsculas. Implemente também o cliente, o endereço IP da máquina servidora deverá ser passado ao cliente como parâmetro na linha de comando. Sockets não bloqueantes Durante os testes sobre os programas desenvolvidos no exercício anterior poderá ter notado uma grave deficiência: se um "datagrama" se perde o cliente bloqueia. Este bloqueio deve-se ao facto de o cliente, após o envio do "datagrama" ao servidor, invocar recvfrom para receber a resposta. Se o "datagrama" não chega ao servidor, se o servidor não está operacional ou se o "datagrama" de resposta se perde, o cliente fica indefinidamente bloqueado nesta system-call. Para tornar um "socket" não bloqueante pode ser usada a "system-call" ioctl para activar a característica FIONBIO ("File I/O NonBlocking I/O"). A "system-call" ioctl permite configurar vários aspectos de um descritor aberto. Tem três parâmetros: o descritor, um identificador da característica a alterar e finalmente um apontador para o novo valor dessa característica.
Como habitualmente, em caso de erro devolve - 1. Os identificadores de características (parâmetro "request") estão definidos no ficheiro sys/ioctl.h, pelo que a sua utilização é muito simples. É no entanto necessário atender a que dependendo de request, o tipo de dado para o qual arg aponta varia. No caso de FIONBIO arg deve apontar para um inteiro, se este tem valor zero esta característica é desactivada, se tem valor diferente de zero será activada. Usando esta "system-call", pode obter-se facilmente um "socket" não bloqueante tal como é exemplificado na seguinte sequência de código:
Ao tornar o "socket" não bloqueante é necessário atender a que a emissão de um "datagrama" pode falhar. Se o sistema está ocupado e um "datagrama" é emitido num "socket" bloqueante, a operação é suspensa até que o sistema esteja livre. Se o "socket" é não bloqueante a operação falha e a "system-call" retorna -1. Sob o ponto de vista de recepção, a respectiva "system-call" terá de ser invocada ciclicamente ("polling") para verificar se chegou algum datagrama. Exercício Introduza as modificações necessárias no cliente anterior para que a situação de bloqueio seja eliminada. Se após um tempo de espera não chega a resposta o cliente volta a enviar a linha de texto ao servidor, este procedimento será repetido várias vezes. Mais tarde serão abordados outros mecanismos que permitem a recepção de "datagramas" de um modo totalmente assíncrono. "Broadcast" de "datagramas" Um "datagrama" pode ser enviado em "broadcast", para o efeito é usado o endereço de destino 255.255.255.255. Um "datagrama" enviado em "broadcast" será recebido por todas as máquinas que estão directamente ligadas à mesma rede IP onde a operação é realizada. A principal utilidade do "broadcast" é permitir contactar uma aplicação que sabemos que está à escuta numa dada porta, sem necessidade de saber em que máquina se encontra. A emissão em "broadcast" também pode ser usada por aplicações que desejam ser contactadas por outras. Neste caso a aplicação envia periodicamente um "datagrama" que funciona como anuncio. Os procedimentos descritos são comuns na maioria das redes locais com partilha de recursos "peer-to- peer" ou com servidores dedicados, tais como MicroSoft, IBM e Novell. Na comunidade "internet", a utilização de "broadcast" para estas finalidades não é tão popular partindo-se do pressuposto que a localização dos servidores é conhecida. A utilização da emissão em "broadcast" tem dois aspectos negativos:
Para que um "socket" possa ser usado para envio em "broadcast" tem de ser previamente definida essa opção. Existem duas "system-call" que permitem manusear as opções de funcionamento de um "socket":
As opções estão divididas em níveis (lev), cada nível possui diversas opções (opt). Tal como para a "system-call" ioctl, cada tipo de opção é definida usando um tipo especifico, para o qual o parâmetro arg deve apontar. O último parâmetro indica o comprimento do argumento. Relativamente ao envio em "broadcast" o nível está definido na constante SOL_SOCKET e a opção é SO_BROADCAST. O valor adequado para esta opção é um inteiro que deverá ter um valor zero para desactivar e diferente de zero para activar. O extracto seguinte apresenta a utilização da "system-call" setsockopt de modo a permitir o envio de "datagramas" em "broadcast":
Filtragem de "datagramas" Embora o protocolo UDP não suporte conexões, é possível definir previamente um "parceiro" para um "socket". Sem existir conexão define-se contudo uma associação entre o "socket" local e o endereço e porta remota. Para obter este efeito usa-se a "system-call" connect, os parâmetros são exactamente iguais aos do bind, mas como endereço devemos utilizar o endereço de destino. Como o endereço de destino é predefinido, podem ser usadas as "system-call" write ou send, esta última é uma variante de sendto que omite o endereço de destino. No contexto do exemplo emissor anteriormente apresentado seria:
As consequências da utilização da "system-call" connect, sob o ponto de vista de recepção são extremamente interessantes pois é realizada uma filtragem dos "datagramas" recebidos. Apenas são passados à aplicação aqueles que têm como origem o endereço/porta indicados. Igualmente aqui podem ser utilizadas as "system-call" read e recv que não necessitam de uma estrutura para guardar o endereço de proveniência. Estabelecimento de Conexões TCP O estabelecimento da conexão garante a existência de um canal bidireccional dedicado de transferência de "bytes". O protocolo TCP proporciona serviços orientados à conexão. Antes de ser possível enviar ou receber dados há necessidade de se estabelecer a conexão. O estabelecimento da conexão exige a colaboração entre duas aplicações, uma aplicação escuta pedidos de conexão numa dada porta, enquanto a outra emite um pedido de conexão para essa porta. Para este feito, as "system-call" a usar são as seguintes:
Esta "system-call" permite colocar um "socket" em escuta de pedidos de conexões numa dada porta. A porta deverá ter sido previamente definida com a "system-call" bind. O valor backlog define o número de pedidos de conexão que podem ser mantidos em espera sem serem aceites pela "system-call" accept.
A "system-call" accept permite aceitar um pedido de conexão, devolve um novo "socket" já ligado ao emissor do pedido e o "socket" original mantém-se em escuta. A estrutura from é usada para guardar o endereço de proveniência do pedido de conexão. A "system-call" accept é bloqueante, quando é invocada o processo fica suspenso até que chegue um pedido de conexão, a menos que um pedido já tenha sido recebido desde a invocação de listen. Note-se que o conceito de conexão implica que o "socket" devolvido por accept é totalmente independente do "socket" original. Enquanto o "socket" original continua à escuta de pedidos de conexão de qualquer proveniência o novo "socket" está associado a uma conexão entre duas aplicações e portanto permite a circulação de dados exclusivamente entre essas duas aplicações. A aplicação que toma a iniciativa de estabelecer uma conexão utiliza para o efeito a "system-call" connect:
O "socket" sock deverá ser do tipo apropriado ("SOCK_STREAM") e não necessita de ter atribuída uma porta ("bind"). Esta "system-call" encarrega-se de a definir dinamicamente. A estrutura address deverá conter o endereço de destino no qual uma aplicação deverá estar à escuta de conexões. Se no endereço indicado isso não se verifica esta "system-call" devolve -1. Recepção/Envio de dados sobre conexões TCP Uma vez estabelecida a conexão passa a existir um canal dedicado para comunicação entre os dois intervenientes que não está acessível a terceiros. O endereço de destino está definido por natureza e podem ser utilizadas as "system-call" read e write, respectivamente para receber e enviar dados:
A "system-call" read recebe len bytes, do "socket" sock, colocando-os no buffer. A "system-call" write envia len" bytes "do buffer pelo "socket" sock. A utilização destas "system-call" deve ser cuidadosa: ambas devolvem o número de" bytes "recebidos ou emitidos que podem não coincidir com o parâmetro len. Isto é particularmente verdade se o "socket" é não bloqueante. O programador deve preocupar-se com este aspecto, invocando novamente as "system-call" para emitir ou receber os" bytes "em falta. Por outro lado, tal como recvfrom, a "system-call" read bloqueia até que sejam recebidos os dados, isto quer dizer que se a invocarmos para receber 1000" bytes "de uma conexão, podemos ter de esperar até que essa quantidade de informação chegue. Note-se que a "system-call" recvfrom desbloqueava quando chegasse um "datagrama" UDP de qualquer tamanho. O extracto seguinte estabelece de uma conexão TCP com a porta 8451 da máquina 193.136.62.4, seguida do envio de linhas de texto.
Note-se a ausência da invocação da "system-call" bind, a "system-call" connect encarrega-se de definir a porta local. O extracto seguinte recebe uma conexão TCP na porta 8450, e de seguida lê linhas de texto da conexão estabelecida.
Note-se que o "socket" usado para receber o pedido de conexão não serve para troca de dados e neste exemplo é fechado após o estabelecimento da conexão. O novo "socket", associado à conexão é depois usado para recepção de dados. Quando um "socket" TCP é definido como não bloqueante, as consequências são as seguintes:
Servidores TCP Uma característica fundamental num servidor é que enquanto está a responder a um cliente deve continuar disponível para atender outros clientes. Usando datagramas isto obriga a que um único processo atenda todos os pedidos dirigidos para a porta, dificultando a manutenção de contextos independentes para cada cliente. Com a existência de conexões tudo fica muito simplificado, mesmo que esteja a ser usada uma única porta, cada conexão é independente das restantes. Pode por isso existir um processo independente para lidar com cada cliente. Num ambiente multi-processo como o "UNIX" , isto é relativamente simples de implementar:
Assim, a estrutura tipo de um servidor "TCP" pode ter a seguinte forma:
Exercício Implementar um servidor TCP recebe nomes de ficheiros de texto e envia o seu conteúdo. Resolução de Nomes de Máquinas Nos exemplos anteriores é necessário fornecer ao cliente o endereço IP do servidor, contudo é muito mais cómodo usar nomes de máquinas. As associações entre nomes de máquinas e endereços IP podem estar definidas localmente, mas a solução geral é a utilização de servidores de nomes que são inquiridos pelos interessados. A obtenção do endereço IP a partir do nome de uma máquina é normalmente conhecida por resolução do nome. Graças a uma hierarquia de domínios de nomes sustentada por um conjunto de servidores interligados é possivel resolver o nome de qualquer máquina ligada à "internet". Cada máquina utiliza um servidor de nomes correspondente ao seu domínio. Para resolver o nome de uma máquina pode ser directamente utilizada a função gethostbyname:
Esta função devolve um apontador para uma estrutura do tipo hostent, o campo h_addr_list, é um apontador para um vector que contem os vários endereços IP da máquina cujo nome foi passado como parâmetro. Na prática, a menos que se trate de um "router", cada máquina apenas tem um endereço IP pelo que se usa o primeiro elemento do vector. Os endereços IP guardados no vector estão sob a forma de inteiros longos, já em formato de rede pelo que podem ser directamente copiados para o campo sin_addr das estruturas sockaddr_in. O exemplo seguinte ilustra a utilização desta função, supondo-se que o nome da máquina é fornecido como primeiro parâmetro da linha de comando:
Melhorar a utilização da rede Nos exemplos elementares apresentados verifica-se que a quantidade de octetos enviada é muitas vezes bastante maior do que a informação útil. Mais especificamente para enviar linhas de texto que podem conter apenas alguns caracteres está a ser enviado sempre todo o "buffer" com 81 caracteres. No caso do UDP este problema pode ser resolvido de uma maneira muito simples. Basta indicar à "system-call" sendto a quantidade exacta de octetos a enviar:
Note-se que a função strlen devolve o comprimento do "string" (número de caracteres), mas o zero que indica o seu final também deve ser enviado, daí a adição de uma unidade. Se para o UDP a solução é simples, para o TCP torna-se algo complicada. O TCP estabelece conexões, numa conexão os dados são transmitidos em continuo, não existindo qualquer tipo de delimitador de blocos de dados. O problema coloca-se sob o ponto de vista do receptor que, não sabendo à partida a quantidade de dados que vai receber não pode invocar directamente a "system-call" read para os ler na totalidade. Se a quantidade de dados a ler indicada à "system- call" read é menor do que os que foram enviados então a operação será incompleta, se ocorre a situação contrária o processo fica bloqueado. A solução corrente consiste em definir previamente entre as entidades qual será o separador utilizado, a invocação da "system-call" read será depois realizada caractere a caractere sendo interrompida quando o separador é lido. Por exemplo para o caso do envio de linhas de texto esta ideia pode ser implementada com os seguintes excertos de código:
Recepção assíncrona Por recepção assíncrona entende-se aqui recepção de dados por uma aplicação sem bloqueio da mesma. Independentemente das chegadas de dados a aplicação deve continuar a funcionar normalmente. A recepção assíncrona é necessária se a aplicação recebe dados em diferentes portas, ou mesmo que receba apenas numa porta, se necessita de executar outras tarefas e não pode ficar suspensa à espera que os dados cheguem. Um dos métodos possíveis já referido é definir o "socket" como não bloqueante, nesse caso a aplicação deverá invocar periodicamente as "system-call" recvfrom ou read para verificar se chegaram dados ("polling"). Existem contudo outros métodos que podem exigir um menor esforço para a aplicação:
Não será aqui detalhada a solução "threads", não existe qualquer dificuldade na sua implementação e existem grandes variações quanto às "system-calls" usadas para trabalhar com "threads". O "thread" que recebe dados invoca recvfrom ou read e fica bloqueado até à chegada de dados, mas os restantes "threads" do processo continuam a execução normal. A utilização de sinais envolve alguns passos bem definidos, é necessário definir uma função que vai ser invocada quando o sinal SIGIO é recebido. O PID tem de ser associado ao "socket" para que quando os dados cheguem, o sinal seja enviado ao processo correcto. Finalmente o "socket" tem de ser preparado para recepção assíncrona. O extracto seguinte exemplifica o procedimento referido:
char linha[81], dataReady=0; É claro que o processamento dos dados recebidos poderia se realizado directamente na função sigio_handler. Embora esta solução liberte totalmente a aplicação principal da recepção de dados, não soluciona o problema da recepção em várias portas. O sinal SIGIO indica que existem dados prontos, mas se existem vários descritores a ser usados para recepção, não indica em qual deles se encontram os dados. Para resolver este problema pode ser usada a "system-call" select:
A estrutura apontada por timeout contém o tempo de máximo de bloqueio desta "system- call":
Esta estrutura pode conter os valores zero e nesse caso não existe bloqueio ("poll"). O apontador passado à "system-call" pode ser NULL, nesse caso o bloqueio verifica-se até à chegada de dados. O objectivo desta "system-call" é detectar condições especificas num conjunto de "sockets", essas condições são:
Devido ao modo como são armazenados, os conjuntos de "sockets" a monitorizar são passados à "system-call" de um modo algo rebuscado, para o efeito é definido o tipo fd_set e 4 macros para lidar com este tipo:
A macro FD_ZERO deve ser sempre usada para inicializar o tipo fd_set. As macros FD_SET e FD_CLR, respectivamente incluem ou retiram os "socket" fd da variável fds. A macro FD_ISSET permite testar se um "socket" está incluído num conjunto do tipo fd_set. A "system-call" select retorna o número de condições detectadas, para saber a qual dos "sockets" diz respeito é necessário utilizar a macro FD_ISSET para analisar o conjunto que interessa. A "system-call" select pode também ser usada para receber conexões (accept), esta situação é tratada como se fosse uma chegada de dados. Bibliografia Stevens W. R. 1990, "UNIX Network Programming", Prentice Hall, Englewood Cliffs, New Jersey, 1990. Sun Microsystems 1990, "Network Programming Guide", Sun Microsystems, Inc., 1990. Todos estes exemplos estão a usar a interface de "loopback" (127.0.0.1):
pode modificar os endereços de destino de modo a coincidirem com o "host"
onde se encontra o receptor ou servidor.
|