Site menu A telegrafia dos BSD Sockets

A telegrafia dos BSD Sockets

Depois de escrever sobre select(), minha tergiversação de meia-idade naturalmente desliza para os BSD Sockets, os maiores "fregueses" da programação assíncrona.

Aliás, disse no outro texto que ia fazer, e fiz mesmo um frameworkzinho para programas assíncronos. É simplesmente unificação com uma pitada de generalização de código preexistente de outros projetinhos, alguns com quase 20 anos. Nesse processo, revisitei algumas pegadinhas do BSD Sockets. Sempre se aprende alguma coisa nova.

O BSD Sockets, ou simplesmente Sockets, é uma API de conectividade de rede. Ela é flexível o suficiente para qualquer protocolo ou tecnologia de rede, comunicação interprocessos e comunicação com o sistema operacional. Naturalmente, os casos de uso mais comuns envolvem os protocolos TCP e UDP da Internet.

O grande trunfo do BSD Sockets é que o "socket", objeto que representa uma conexão de rede, é um descritor de arquivo. Ele tenta imitar, na medida do possível, o comportamento de um arquivo aberto. Sendo assim, pode ser lido, gravado, fechado, etc. como se fosse um arquivo em disco, ou um pipe. Assim como arquivos e pipes são intercambiáveis (até certo ponto), o mesmo vale para os sockets.

Essa característica crucial do BSD Sockets é ao mesmo tempo fonte do seu poder, da sua beleza, e da sua aridez.

O fato é que uma conexão de rede não é um arquivo em disco. Se o disco do computador falhar, o computador inteiro trava. Então é seguro presumir que, enquanto o programa estiver rodando, manipulação de arquivos não falha. Para conexões de rede, a metáfora abrir-ler-gravar-fechar só vale enquanto tudo estiver funcionando perfeitamente. Mas redes falham o tempo todo no mundo real.

Um típico exemplo de cliente TCP, escrito na linguagem Python e facilmente encontrável na Internet:

sk = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sk.connect(("www.foo.com", 80))
# Em C, ainda poderíamos usar write() em vez de send()
sk.send(b"GET / HTTP/1.1\r\nHost: www.bar.com\r\n\r\n")
# Em C, ainda poderíamos usar read() em vez de recv()
ble = sk.recv(1000)
print(ble)
sk.close()

Exceto pelo método de criação, que usa socket()/connect() em vez de open(), a manipulação do socket é muito semelhante à de um arquivo: grava, lê, fecha. Se você sabe abrir um arquivo, sabe programação em rede. Será?

O problema do exemplo acima é que ele parece correto. Na maior parte do tempo, ele funciona. E o mau exemplo vai sendo difundido, ensinado, oferecido como resposta no Stack Overflow. É código que parece funcionar num primeiro momento, depois falha de formas misteriosas em produção.

Outra crítica ao BSD Sockets é que, se ele realmente queria aderir ao paradigma Unix, não deveria ter introduzido APIs novas. Só no exemplinho mais acima, já deparamos quatro: socket() em vez de open(); connect(), send() em vez de write(), e recv() em vez de read(). O jeito "puro Unix" seria algo como

sk = open("/dev/tcp/www.foo.com/80")

Uma vez que os arquivos em /dev são na verdade pseudo-arquivos que representam dispositivos ou portas, parece natural estender a metáfora para redes, não é? De fato o Bash implementa pseudo-pseudo-arquivos dessa forma para que shell scripts tenham conectividade de rede "nativa".

A telegrafia do BSD Sockets

Pelo menos a mim parece que, ao manipular um socket, temos de seguir um ritmo, uma dança, uma telegrafia. Vamos começar analisando a forma que nosso programinha anterior obtinha a resposta HTTP:

ble = sk.recv(1000)
print(ble)

Ler o socket desta forma é errado por diversos motivos:

Sim, você descobre que a conexão fechou desse jeito: tenta ler dados, e o retorno é vazio. É como quando você pergunta para alguém o que há de errado, e a resposta é "NADA!!!" — que pode significar muitas coisas, exceto "nada".

Um trecho de código mais correto, embora ainda imperfeito, seria este:

ble = b''
while True:
    buf = sk.recv(65000)
    if not buf:
        break
    ble += buf
print(ble)

Passar um valor maior a recv() permite receber mais dados a cada chamada, quiçá todo o buffer de recepção da conexão, o que é mais eficiente do que receber dados picadinhos.

A partir do Python 3.5, o erro recuperável EINTR é tratado internamente, então não precisamos mais nos preocupar com essa exceção não-fatal.

Claro, o ideal mesmo seria usar um select() para detectar quando o socket está disponível para leitura, e só então chamar recv(). Assim poderíamos implementar a questão do timeout, e temos a garantia que recv() não bloqueará por tempo indeterminado.

Agora, vamos analisar o envio de dados, igualmente eivado de erros:

sk.send(b"GET / HTTP/1.1\r\nHost: www.bar.com\r\n\r\n")

Uma versão mais correta seria

outbuf = b"GET / HTTP/1.1\r\nHost: www.bar.com\r\n\r\n"
while outbuf:
    sent = sk.send(outbuf)
    if sent <= 0:
        break
    outbuf = outbuf[sent:]
...

A versão abaixo também funciona corretamente, embora seja ineficiente, pois envia os dados a suaves prestações:

outbuf = b"GET / HTTP/1.1\r\nHost: www.bar.com\r\n\r\n"
while outbuf:
    sent = sk.send(outbuf[0:5])
    if sent <= 0:
        break
    outbuf = outbuf[sent:]
...

A versão acima envia a requisição HTTP fragmentada em bloquinhos de 5 caracteres. Isso significa que os pacotes Ethernet sairão com apenas 5 bytes úteis cada? Não necessariamente. A chance disto acontecer aumenta um pouco, mas não há garantias.

Por último, é interessante lembrar que o Python oferece o método sendall(), que é útil em programas simples, escritos de forma linear como o nosso exemplo:

sk.sendall(b"GET / HTTP/1.1\r\nHost: www.bar.com\r\n\r\n")

Novamente, um programa decente usaria select() para detectar quando o socket está pronto para gravação, e só então chamaria send() com a garantia que ele não transformará seu programa numa Bela Adormecida.

Atomicidade de mensagens

Como espero que ficou claro na seção anterior, numa conexão TCP, send() e recv() podem enviar ou receber qualquer quantidade de bytes. Isto tem uma implicação importantíssima: não há garantia de entrega atômica de mensagens. A API Sockets não garante isso, e nem o protocolo TCP garante isso!

Por exemplo, suponha um protocolo cujas mensagens têm exatamente 1000 bytes cada. Infelizmente, não podemos contar que recv() receberá cada mensagem de uma vez, inteirinha e sem mistura com a próxima mensagem. Tudo pode acontecer: podemos receber a mensagem em 30 pedaços; pode ser que o final da última mensagem esteja misturado com o começo da próxima.

É responsabilidade do protocolo de aplicação fazer a separação correta: acumular bytes num buffer até completar 1000. Quando o buffer alcançar 1000, tratar a mensagem. Se houver mais de 1000 bytes no buffer, deixar a sobra lá esperando pelo resto da próxima mensagem.

Se o protocolo é no estilo "half-duplex", onde é esperada uma resposta para cada mensagem, é um pouco mais fácil: podemos presumir que o buffer de entrada possua no máximo uma mensagem completa de cada vez. Se houver mais de 1000 bytes na mensagem, foi erro do outro lado (mas você ainda tem de decidir o que fazer nesta situação).

Presumir mensagens atômicas é um erro comum, ao menos em código de rede escrito de forma "inocente". Por quê? Porque a entrega de mensagens parece ser atômica num teste rápido.

Voltando ao exemplo do protocolo de 1000 bytes por mensagem. Um pacote Ethernet tem 1500 octetos, então em geral cada chamada a send() ou recv() consegue enviar ou receber uma mensagem inteira (porque ela cabe num pacote Ethernet) e a mensagem será manipulada atomicamente (porque não cabem duas mensagens de 1000 bytes num pacote Ethernet).

Porém, assim que este código tiver de operar numa situação um pouco menos "baunilha", tipo uma rede com pacotes menores, ou um protocolo que não seja estilo half-duplex, ou os roteadores intermediários fizerem alguma pajelança para controlar tráfego, o código começará a falhar de forma intermitente e "misteriosa".

Existem protocolos que garantem entrega atômica de mensagens? Sim: os pacotes UDP sempre são entregues atomicamente (mas não têm confirmação, que tem de ser implementada pelo protocolo de aplicação). O protocolo SCTP oferece o melhor dos dois mundos (mensagens atômicas e confirmadas) mas infelizmente não pegou como protocolo de transporte, sendo utilizado apenas em telefonia e também como canal de dados no WebRTC (usando UDP como transporte subjacente).

Stevens

Assim como Allan Kardec foi meio sem querer o codificador do espiritismo, o grande codificador acidental da "Filosofia Unix" foi Richard W. Stevens.

Esse autor conhecia muito de programação Unix e muito de TCP/IP, uma combinação relativamente rara (até onde noto, a maior fatia dos programadores não é versada em rede). Além disso, os livros distinguem-se pelo capricho, pela completeza e pela boa escrita.

Sua série de livros ainda é considerada autoritativa para Unix e um grande material didático para TCP/IP. Stevens morreu em 1999, mas alguns livros foram atualizados por outros autores desde então. Certamente os conteúdos ainda valem perfeitamente para os fundamentos.

Hoje em dia, Unix é sinônimo de Linux, nem que seja dentro de um container Docker. Isto é um luxo que não tínhamos há 20 ou 25 anos atrás, quando AIX, HP-UX, etc. ainda eram relevantes. Os livros de Stevens discutem à exaustão as pequenas diferenças entre sabores de Unix, o que é informação valiosa se você precisa escrever código Unix portável.

De volta à prática telegráfica

A função connect() toma a iniciativa da conexão, e costuma ser utilizado pelo lado cliente de um protocolo.

Um problema de connect() é que ele também bloqueia. É como se a abertura de um arquivo pudesse bloquear indefinidamente. O ideal seria que esta função apenas iniciasse o processo de conexão, mas a vida é assim.

Num programa com event loop, a forma correta de abrir uma conexão sem bloquear, é a seguinte:

sk.setblocking(0)
try:
    sk.connect(addr)
except BlockingIOError as e:
    pass

Tivemos de lançar mão de um truque, configurando explicitamente o socket como não-bloqueante. Dependendo do sabor de Unix e mesmo da versão de Linux, a exceção não-fatal EWOULDBLOCK pode ser lançada, e deve ser ignorada.

Deste ponto em diante, usamos select() para testar o socket para escrita. Quando o socket indicar livre para escrita, testamos se a conexão deu certo:

if sk.getsockopt(socket.SOL_SOCKET, socket.SO_ERROR):
    # conexão falhou
    return

# conexão boa
sk.setblocking(1)

Existem outras formas de determinar se a conexão deu certo, mas a forma acima, usando getsockopt(), é limpa e portável.

Um socket em fase de abertura de conexão não deve ser testado para leitura no select(). Pois conexões falhadas também indicam "disponível para leitura", mas chamar recv() para elas retorna erro. Portanto, em fase de abertura de conexão, o socket deve ser testado apenas para escrita.

Ei, o que é esse getsockopt()?

As funções getsockopt() e setsockopt() também são específicas para sockets. Elas destinam-se a ler e gravar configurações especiais, respectivamente.

Neste ponto é importante dar um passo atrás para ver o contexto maior. A filosofia Unix diz: "tudo é um arquivo". O que se pode fazer com um arquivo? Abrir, ler, gravar e fechar. Idealmente, qualquer mudança de estado de um arquivo deveria acontecer exclusivamente pela leitura ou gravação de dados sobre ele. Mesmo que seja um pseudo-arquivo, que na verdade representa um dispositivo.

Mas o mundo não é ideal. Há situações em que é preciso "ler" ou "gravar" metadados, e isto precisa acontecer através de um canal separado dos dados comuns.

Por exemplo, considere o pseudo-arquivo /dev/dsp, que representa a placa de som. Gravar dados emite áudio pelo alto-falante, ler dados é captação do microfone. Como então configurar o volume, a taxa de amostragem, etc.? Outro exemplo: uma porta serial e.g. /dev/ttyUSB0 tem parâmetros como velocidade, start bit, stop bit, paridade, etc.

Para estes casos, o Unix oferece a chamada ioctl(). Ela é a válvula de escape para qualquer coisa que não caiba na metáfora abrir-ler-gravar-fechar. Diferentes tipos de arquivo suportam diferentes parâmetros de ioctl(). Obviamente, uma placa de som tem configurações bem diferentes de uma porta serial.

Usar ioctl() é pouco prático, por ser uma interface demasiado genérica. A API Sockets possui as funções getsockopt() e setsockopt() que suprem a mesma lacuna — ler e gravar metadados e configurações de um socket — de forma um pouco mais ergonômica.

Momento nostalgia

Ainda sobre placas de som: já faz muito tempo que o ALSA é o subsistema de áudio default do Linux. Arquivos como /dev/dsp e /dev/mixer pertencem ao obsoleto OSS, embora o ALSA seja capaz de emular a interface OSS.

Eu pessoalmente gostava do OSS por ser bem "raiz" e semelhante ao que encontrávamos em workstations Unix tradicionais tipo SGI. Mas é fato que a metáfora de arquivo não casa bem com áudio de baixa latência gerado em real-time.

Outro exemplo: a maioria dos Unixes tradicionais atribui um dispositivo em /dev para as interfaces de rede, embora não sejam dispositivos nem de bloco nem de caractere, de modo que é meio inútil possuir um /dev/eth0 pois escrever dados nele não teria como ser traduzido para a transmissão de um pacote de rede.

O Linux pulou essa etapa inteiramente, e nunca expôs interfaces de rede como pseudo-arquivos. (É, eu sou conservador e fiquei puto ao descobrir isso, quando comecei a usar Linux em 1998.)

A caminho da programação assíncrona

Como vimos, a metáfora de arquivo abrir-ler-gravar-fechar não casa muito bem com programação de rede. Um programa de rede "de verdade" dificilmente implementa as operações numa sequência linear, bonitinha.

Na verdade, na verdade, nem mesmo ler um arquivo do disco é bonitinho nos bastidores de um computador. No caso do disco, o sistema operacional ainda consegue proporcionar uma ilusão de linearidade. No caso de rede, não consegue, porque teria de combinar com os russos, na outra ponta da linha. Por exemplo:

f = open("bla.txt")
print(f.read(1024))
f.close()

Parece um programa simples, síncrono e linear. Mas por trás da cortina, o que acontece é altamente não-linear. Analisando apenas o que acontece em read():

Veja que é um processo totalmente assíncrono. É na verdade análogo ao que acontece numa requisição de rede. A grande diferença é que o disco é uma contraparte mais confiável que um site russo, e isso nos permite fazer de conta que operações de arquivo são síncronas. (Essa ilusão já começa a esfarelar quando nossos arquivos estão num volume de rede, tipo NAS.)

Olhando por este prisma, frameworks como o Node.js, onde até leituras de arquivo são operações assíncronas, não são mais tão ridículas ou alienígenas. Elas na verdade casam muito melhor com o que realmente acontece dentro de um computador.

(continua...)