Site menu A telegrafia dos BSD Sockets - parte 3

A telegrafia dos BSD Sockets - parte 3

Este é o final da trilogia sobre BSD Sockets. Se você caiu aqui de paraquedas, aqui estão a primeira e segunda partes.

UDP

Diferente do TCP, o UDP é um protocolo "sem conexão": o relacionamento UDP entre dois terminais começa quando um deles emite um pacote, e termina quando o outro recebe esse pacote. Não há fase de conexão, desconexão, retransmissão, nada disso.

Depois de décadas sendo o patinho feio, UDP vem crescendo em importância, por dois motivos:

O QUIC, desenvolvido pelo Google, é um protocolo de transporte encapsulado em UDP; o novo protocolo HTTP/3 usa exclusivamente QUIC como transporte, e deve tornar-se o protocolo mais popular da Internet nos próximos anos. No caso do SCTP, a RFC 6951 especifica um encapsulamento sobre UDP (suportada nativamente pelo Linux), e WebRTC implementa SCTP sobre UDP.

Sockets e UDP

Usar BSD Sockets com UDP é, em si, mais fácil que TCP. Exemplo de servidor UDP:

sk = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sk.bind(("0.0.0.0", 12345))

while True:
    pacote, addr = sk.recvfrom(1500)
    print("Pergunta de", addr, "dados", pacote)

    time.sleep(1)
    inverso = [b for b in pacote]
    inverso.reverse()
    sk.sendto(bytearray(inverso), 0, addr)
    print("Resposta enviada")

O único grande defeito da implementação acima é implementar o timeout colocando o processo para dormir. A "telegrafia" de envio e recebimento está basicamente correta. Uma vez que UDP não tem o conceito de conexão, não precisamos de um socket por cliente. Um único socket pode atender um número ilimitado de clientes.

Utilizamos duas APIs novas: recvfrom() e sendto(). Em recvfrom(), recebemos o endereço da contraparte junto com o pacote. Poderíamos usar recv()? Sim, mas aí não saberíamos de onde os dados vieram, e não saberíamos para quem enviar a resposta.

A chamada recvfrom(1500) sempre recebe um pacote por vez. Nunca vamos receber um pacote "a prestações", nunca vamos receber zero dados sinalizando que a conexão fechou. Se o pacote for maior que 1500, o excesso será truncado. (Um pacote IP não pode ser maior que 64KiB, então não faz sentido o valor ser maior que 65507.) Não precisamos nos preocupar em tratar recebimento parcial.

Em sendto(), informamos o conteúdo do pacote e o endereço de destino. Do jeito que o programa está, não temos a opção de usar send() (vamos ver depois quando isto seria possível). Assim como em recvfrom(), a mensagem será enviada em um pacote, de uma só vez; se não couber no pacote, será truncada. Não precisamos nos preocupar em tratar envio parcial.

Agora, um possível cliente UDP para o mesmo protocolo:

sk = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)

pacote = b'abcdef123456789'
sk.sendto(pacote, 0, ("127.0.0.1", 12345))
print("Pergunta enviada")

pacote, addr = sk.recvfrom(1500)
print("Resposta de", addr, "dados", pacote)

Em UDP, a distinção entre "cliente" e "servidor" recai totalmente sobre o protocolo de aplicação. De modo que, pelo menos na minha visão, o cliente UDP parece mais com o servidor UDP, do que um cliente TCP se pareceria com um servidor TCP.

Outra possível versão de cliente UDP:

sk = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sk.connect(("127.0.0.1", 12345))

pacote = b'abcdef123456789'
sk.send(pacote)
print("Pergunta enviada")

pacote = sk.recv(1500)
print("Resposta:", pacote)

Neste caso, fizemos uso de connect(), não para realmente conectar, mas sim para fixar o endereço da contraparte no socket. Tendo feito isso, podemos usar send() e recv(). É garantido que esse socket não receberá pacotes UDP de outros endereços.

Assim como em TCP, também podemos usar bind() em "clientes" UDP para atrelar o socket a uma interface de rede em particular e/ou a um número de porta fixo. Só se deve fazer isso quando realmente necessário.

Por que alguém usaria bind() no lado cliente?

Essa é a pergunta que não quer calar, desde os textos anteriores sobre sockets e TCP.

Em primeiro lugar, vamos rememorar o que o bind() faz: ele atrela um socket a uma interface de rede e/ou um número de porta específicos. Ele recebe dois parâmetros: um endereço IP, que deve coincidir com o IP de uma interface de rede; e um número de porta.

O endereço 0.0.0.0 ou socket.INADDR_ANY é um coringa que significa "todas as interfaces de rede". Num servidor, isto significa que ele recebe conexões por qualquer interface de rede. Num cliente, isto significa que a tabela de roteamento decidirá por onde a conexão deve sair.

A porta zero significa que o sistema operacional pode escolher a porta do nosso socket. Será então um número aleatório, embora geralmente alto, para não conflitar com as portas "baixas".

Invocar bind("0.0.0.0", 0) é o mesmo que não invocá-lo.

Temos então quatro possibilidades de usar bind():

a) Invocar bind() com endereço tudo-zero e porta positiva

Esta é a forma mais comum em servidores. Normalmente você quer que um servidor seja agnóstico em relação às interfaces de rede, mas com um número de porta bem definido (80 e 443 para Web, 53 para DNS, etc.), do contrário os clientes não teriam como achá-lo.

Se um aplicativo fixa o número de porta, não é possível rodar simultaneamente mais de uma instância dele na mesma máquina. Por conta disso, clientes só usam esta forma em situações muito especiais.

Podemos usar esta forma no lado cliente quando a iniciativa de conexão pode partir de qualquer lado, então ambos os lados precisam atender numa porta bem definida. Um caso é o protocolo DNS, onde os diversos hosts DNS fazem consultas entre si. (Um "cliente DNS" é simplesmente um servidor DNS que não é responsável por nenhum domínio.)

b) Não invocar bind()

Esta é a forma mais comum em clientes. Pelas razões elencadas na forma anterior, é vantagem deixar a escolha de interface de rede e de porta para o sistema operacional.

Para esta modalidade ser útil num servidor, teria de haver um diretório ou serviço de descoberta, para quem o servidor reportaria seu número de porta atual (obtenível com getsockname()). Assim os clientes teriam como achar o caminho das pedras. Note que implementar tal diretório implica num segundo protocolo auxiliar, é um esforço extra que tem de ser bem justificado. (Acho que algumas redes P2P funcionam assim.)

c) Invocar bind() com endereço definido e porta positiva

Quando um socket atrela a uma interface de rede específica, ele só poderá trafegar dados através daquela interface. Ou seja, o programa está tomando uma decisão de roteamento, que normalmente caberia ao sistema operacional.

Um caso de uso bem comum é limitar o acesso a um serviço, que deve atender apenas conexões locais (do mesmo computador). Atrelar ao endereço 127.0.0.1 atinge este objetivo, ainda que um firewall corretamente configurado fosse a solução mais elegante.

Outro caso de uso é quando o serviço conhece a topologia de rede, e muda seu comportamento em função dela. Por exemplo, um servidor DHCP só deve atender na rede LAN, não na rede WAN; um servidor SIP só vai usar STUN/TURN ao comunicar-se com alguém fora da LAN.

Nosso script de monitoramento de links redundantes vmonitor também usa esta modalidade de atrelamento, tanto no cliente quanto no servidor. A ideia é forçar os pacotes a trafegar por um link específico, para testar se ele está funcionando. Usar números de porta fixos em ambos os lados permite a qualquer um iniciar a troca de pacotes.

d) Invocar bind() com endereço definido e porta zero

Para um cliente, um caso de uso é quando a conexão deve sair por uma interface de rede específica. É o caso de DHCP, pode ser o caso de SIP ou UPnP. (Por outro lado, um cliente não precisa usar esta modalidade se apenas deseja garantir que uma conexão seja local. Para isto, basta conectar a 127.0.0.1; esse endereço nunca "vaza" para fora.)

Para um servidor, é difícil imaginar uma situação onde esta escolha seja útil. Se alguém souber de alguma, comente que nós adicionamos aqui.

Pacotes UDP grandes x fragmentação

Em tese, um pacote IP grande demais para trafegar pela inter-rede será automaticamente fragmentado, e remontado no destino. Então, uma aplicação UDP pode enviar mensagens de até 65507 bytes sem se preocupar com MTU da rede. Isso em tese.

No mundo real, por diversos motivos, fragmentação não funciona bem. Contar com ela é pedir para se incomodar. Mesmo que funcionasse, o mecanismo de fragmentação IP não tem confirmação de entrega, o que amplifica a chance de perda de pacotes num transporte não-confirmado, como é o UDP.

O ideal é enviar pacotes UDP abaixo do tamanho que provocaria fragmentação. Uma aplicação UDP robusta teria de usar um algoritmo qualquer para descobrir o tamanho ótimo. Porém, uma regra prática muito usada é limitar o tamanho da mensagem a 512 bytes, um "número mágico" que cabe confortavelmente no MTU mínimo do IPv4 (576 bytes) e do IPv6 (1280 bytes).

Usar pacotes UDP bem pequenos (100 bytes ou menos) também era uma tática comum em aplicações realtime, tipo VoIP, jogos online, etc. para diminuir a latência.

Sockets UDP e select()

Uma aplicação UDP também pode usar select(). A seleção para leitura funciona da forma usual: indica que existe um pacote disponível para recebimento, e recvfrom() pode ser chamado sem bloquear.

Já a seleção para gravação tem semântica um pouco diferente. Ela indica que o buffer de saída da interface de rede tem espaço livre. Enviar pacotes tão rápido quanto select() permita, significaria transmitir à taxa da interface de rede local (tipicamente gigabit, ou pelo menos 100Mbps). Certamente a maioria dos pacotes seria perdida no caminho, pois dificilmente a inter-rede tem essa banda toda até o destino.

Um efeito colateral benigno é que chamar sendto() sem select() bloqueará por um tempo relativamente curto. Uma aplicação mais simples, que envia pacotes UDP esporadicamente, não precisa usar select() para envio.

Servidor UDP com timeout implementado direito

A discussão não ficaria completa sem apresentar um servidor UDP com implementação mais correta, que implementa o timeout de resposta usando select() em vez de time.sleep(), e pode atender inúmeros clientes simultaneamente:

sk = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sk.bind(("0.0.0.0", 12345))

nova = 0
pendencias = {}

while True:
    # Determina qual o cliente com timeout mais próximo de estourar
    indice, proximo_timeout = None, None
    for i, pendencia in pendencias.items():
        if proximo_timeout is None or \
                proximo_timeout > pendencia['timeout']:
            indice, proximo_timeout = i, pendencia['timeout']

    # Converte hora absoluta em relativa
    if proximo_timeout is not None:
        proximo_timeout = max(0, proximo_timeout - time.time())
        print("Proximo timeout %f" % proximo_timeout)

    rd, _, __ = select.select([sk], [], [], proximo_timeout)

    if rd:
        # Novo pacote de requisição
        pacote, addr = sk.recvfrom(65535)
        print("Requisição de", addr, "dados", pacote)
        inverso = [b for b in pacote]
        inverso.reverse()
        nova += 1
        pendencias[nova] = {'cliente': addr,
                            'timeout': time.time() + 1.0,
                            'resposta': bytearray(inverso)}
    else:
        # Hora de enviar resposta
        addr = pendencias[indice]['cliente']
        pacote = pendencias[indice]['resposta']
        sk.sendto(pacote, 0, addr)
        print("Resposta a", addr, "dados", pacote)
        del pendencias[indice]