Site menu A telegrafia dos BSD Sockets - parte 2

A telegrafia dos BSD Sockets - parte 2

Continuando nossa saga, vamos abordar mais alguns aspectos do BSD Sockets. O texto anterior já ficou mega longo e mesmo assim não conseguimos falar nem metade do básico do assunto...

Conexão meio-aberta, meio-fechada ou meia-boca

O protocolo de rede TCP tem um recurso curioso: o fechamento parcial de uma conexão, para envio e/ou para recebimento, com shutdown().

Suponha um protocolo bem simples, onde o cliente envia uma única mensagem, recebe uma única resposta, e fecha a conexão. O cliente poderia fechar a conexão no sentido de envio ao final da requisição. Feito isso, ele não pode enviar mais nada, mas ainda pode receber a resposta.

Mas por que fazer isso? Porque, no caso de um protocolo simples, o shutdown() ajuda a delimitar a mensagem. Lembra do que falamos no artigo anterior, que o TCP não entrega mensagens atomicamente, e que isso adiciona complexidade ao protocolo de aplicação?

Pois bem, quando o cliente invoca shutdown(), o servidor recebe zero bytes ao invocar recv(). Isto indica que o cliente não enviará mais nada. É uma forma "telegráfica", porém inequívoca, de sinalizar ao servidor que a requisição está completa.

Em geral, um protocolo de aplicação que depende desse recurso é considerado amador. Nem mesmo o HTTP 1.0 usava esse truque; o delimitador de requisição HTTP 1.x é uma linha vazia. Além disso, alguns provedores e roteadores NAT interferem em conexões meio-fechadas, presumindo que elas vão ser completamente fechadas logo em seguida. Isto se traduz em problemas intermitentes, "misteriosos" e difíceis de debugar no mundo real.

A função shutdown() aceita um parâmetro que permite especificar se o fechamento é na direção de envio, do recebimento, ou ambas. Note que fechar a conexão em ambas as direções é diferente de close(); este último efetivamente destrói o socket, enquanto o primeiro destrói apenas a conexão TCP, mantendo o socket válido.

No Unix, é possível compartilhar um mesmo socket entre processos diferentes. Se um processo chamar close(), o socket é destruído apenas para ele, mas continua existindo normalmente para os demais, sem alteração do estado da conexão subjacente. Por outro lado, se qualquer processo invocar shutdown() com fechamento bidirecional, a conexão (que é uma só) está fechada para todo mundo.

O servidor TCP

No artigo anterior, dissecamos um cliente TCP extremamente simples, lembrando que "cliente" TCP é quem toma a iniciativa da conexão. Nesta seção, vamos ver como se monta um servidor TCP, ou seja, um programa que espera por uma conexão.

Como de costume, vamos começar com um servidor bem porco, estudar em que pontos ele é fundamentalmente diferente do cliente, e depois criticar o que houver de errado:

listensk = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
listensk.bind(("0.0.0.0", 12345))
listensk.listen(5)

while True:
    clientsk, addr = listensk.accept()
    print("Conexao de ", addr)

    linha = clientsk.recv(1500)
    print("Recebido ", linha)
    linha = [b for b in linha][:-1]
    linha.reverse()
    linha.append(ord('\n'))
    clientsk.send(bytearray(linha))
    clientsk.close()

O servidorzinho acima implementa um protocolo extremamente simples: recebe uma string terminada por LF (\n) e devolve a mesma string invertida.

A principal diferença do servidor TCP em relação ao cliente TCP, é que ele possui um socket dedicado a "escutar" conexões novas — listensk. A conexão TCP em si, que trafega dados, é representada pelo socket clientsk, produzido pela chamada accept().

Antes do accept(), o socket de escuta listensk precisa de no mínimo dois passos de configuração: bind() e listen().

A chamada bind() atrela o socket a uma interface de rede e uma porta TCP. No caso acima, o endereço especial 0.0.0.0 significa "todas as interfaces de rede". A porta de escuta é 12345.

Podemos rodar um servidor TCP sem chamar bind()? Podemos, mas então ele ouvirá numa porta aleatória. Não é usual, e normalmente não faz sentido.

A chamada listen() é o passo crucial, que coloca o socket em modo de escuta. O parâmetro (5, no caso) é o tamanho máximo da fila de conexões TCP entrantes. (A fila contém conexões estabelecidas no nível de rede, mas ainda não aceitas pelo accept(). Enquanto a fila estiver lotada, novas conexões ficam sem resposta.)

Finalmente, a chamada accept() recebe um socket de conexão TCP, que pode ser usado para trafegar dados. No Python, o método também retorna o endereço do cliente TCP, o que é bastante conveniente.

Para testar esse servidor, você pode usar este cliente:

sk = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sk.connect(("127.0.0.1", 12345))
sk.sendall(b"abcdef\n")
retorno = b""
while True:
    data = sk.recv(1000)
    if not data:
        break
    retorno += data
print(retorno)
sk.close()

Dissemos antes que o servidor tinha vários erros de implementação. Vamos a deles:

Segue o que seria uma implementação mais correta, fazendo uso de threads.

LF = ord('\n')

def tratador(sk):
    linha = b''
    while LF not in linha:
        try:
            data = sk.recv(1500)
            print("dados recebidos")
        except socket.error:
            break
        if not data:
            break
        linha += data

    if LF not in linha:
        sk.close()
        return

    linha = linha[0:linha.index(LF)]
    print("Recebida linha ", linha)
    linha = [b for b in linha]
    linha.reverse()
    linha.append(LF)

    try:
        sk.sendall(bytearray(linha))
    except socket.error:
        pass
    sk.close()

listensk = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
listensk.bind(("0.0.0.0", 12345))
listensk.listen(5)

while True:
    try:
        clientsk, addr = listensk.accept()
    except socket.error:
        print("exceção em accept()")
        continue
    print("Conexao de ", addr)
    t = Thread(target=tratador, args=(clientsk,))
    t.start()

A complexidade do código aumentou consideravelmente, porém ainda é um código que podemos ler "de cima para baixo", o que é uma vantagem ergonômica.

O programa acima consegue atender inúmeros clientes simultaneamente, e trata corretamente a "telegrafia" de envios e recebimentos. Mesmo que recv() e sendall() bloqueiem, isto não é problema pois apenas a thread daquela conexão é bloqueada; as demais threads continuam livres para trabalhar.

Address already in use

Se você tentou rodar o servidor TCP várias vezes seguidas, principalmente se clientes tiverem feito conexão, pode ter encarado o erro Address already in use. Depois de uns 2 minutos, o erro desaparece. O que é isso?

Uma conexão TCP recém-fechada não desaparece imediatamente; ela fica no estado FIN_WAIT. Caso o terminal remoto ainda tente mandar pacotes para essa conexão, recebe a resposta RST. Sem esse tempinho refratário, esses pacotes poderiam ser entendidos como a abertura de conexão nova.

Porém, enquanto houverem conexões FIN_WAIT, o sistema operacional mantém a porta (no caso, 12345) reservada ao processo já morto, e um novo servidor não pode "bindar" na mesma porta.

A solução, adotada por quase todo servidor TCP, é usar algumas configurações "mágicas" no socket, antes do bind():

listensk.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
listensk.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)

A distinção entre SO_REUSEADDR e SO_REUSEPORT é sutil e varia conforme o sistema operacional. Na dúvida, use ambas. Note que elas são efetivas quando o processo morto fez uso delas, permitindo que a próxima encarnação reutilize a mesma porta; então pode não surtir o efeito desejado na primeira vez.

Servidor baseado em select()

Claro que esta discussão não ficaria completa sem mostrar uma versão mínima de um servidor TCP assíncrono baseado em select().

Uma vez que ela é bem mais complicada, vamos abordar em partes, começando pelo loop de eventos.

listensk = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
listensk.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
listensk.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
listensk.bind(("0.0.0.0", 12345))
listensk.listen(5)

clientes = {}

while True:
    crd, cwr, cex = [listensk], [], []
    for _, cliente in clientes.items():
        if cliente.quer_receber:
            crd += [cliente.sk]
        if cliente.quer_enviar:
            cwr += [cliente.sk]

    rd, wr, ex = select.select(crd, cwr, cex)

    if rd:
        if rd[0] is listensk:
            aceita_conexao(listensk)
        else:
            clientes[id(rd[0])].recebimento()
    elif wr:
        clientes[id(wr[0])].envio()

A primeira parte é familiar. Em seguida, temos o dicionário clientes que contém todos os clientes conectados ao nosso servidor. A classe que encapsula cada cliente será analisada mais adiante.

Antes de executar select(), coletamos todos os sockets de clientes que desejam receber ou enviar dados, com base nos atributos cliente.quer_receber e cliente.quer_enviar. O socket listensk, por onde chegam conexões novas, sempre é selecionado para leitura.

Quando select() retorna, pode ter selecionado vários sockets prontos para comunicação. Vamos tratar apenas um; isto é permitido, e os demais sockets serão novamente selecionados da próxima vez que select() é invocado, então nada se perde.

Dependendo de qual socket foi selecionado, invocamos a) um método do cliente, ou b) a função aceita_conexao():

def aceita_conexao(listensk):
    try:
        clientsk, addr = listensk.accept()
    except socket.error:
        print("exceção em accept()")
        return 
    Cliente(clientsk, addr)

A função acima aceita a conexão nova e encapsula o respectivo socket num objeto Cliente, cuja classe vamos estudar a seguir.

LF = ord('\n')

class Cliente:
    def __init__(self, sk, addr):
        print("Conexao de ", addr)
        self.sk = sk
        self.addr = addr
        self.linha = b''
        self.quer_receber = True
        self.quer_enviar = False
        clientes[id(sk)] = self

    ...

Quando o objeto Cliente é instanciado, ele adiciona a si mesmo ao dicionário de clientes ativos, usando o próprio objeto de socket como chave. No estado inicial, um cliente quer apenas receber dados.

Quando select() detecta que o socket do cliente recebeu dados, invoca o método cliente.recebimento():

    ...

    def recebimento(self):
        try:
            data = self.sk.recv(1500)
            print("%s: recebidos %d bytes: %s" % \
                    (str(self.addr), len(data), data))
        except socket.error:
            self.destroi()
            return

        self.linha += data

        if (not data) or (LF in self.linha):
            self.formula_resposta()

    ...

Quando recebimento() detecta que uma linha completa foi recebida, ou que a conexão foi fechada, o método passa a bola para Cliente.formula_resposta():

    ...

    def formula_resposta(self):
        if LF not in self.linha:
            self.destroi()
            return

        self.linha = self.linha[0:self.linha.index(LF)]
        print("%s: recebida linha completa " % \
              str(self.addr), self.linha)

        self.linha = [b for b in self.linha]
        self.linha.reverse()
        self.linha.append(LF)

        self.quer_receber = False
        self.quer_enviar = True

    ...

O método acima formula a linha de resposta, e coloca o cliente em estado de envio de dados. Quando select() detecta que o socket está pronto para gravação, invoca o método a seguir:

    ...

    def envio(self):
        if not self.linha:
            # envio completado
            self.destroi()
            return

        try:
            enviado = self.sk.send(bytearray(self.linha[0:3]))
        except socket.error:
            self.destroi()
            return

        if enviado <= 0:
            # conexão fechada
            self.destroi()
            return

        print("%s: enviados %d bytes: %s" % \
               (str(self.addr), enviado,
                bytearray(self.linha[0:enviado])))
        self.linha = self.linha[enviado:]

    ...

O método acima faz a telegrafia usual com send(), com o detalhe que enviamos apenas 3 caracteres de cada vez, só para verificar que o esquema funciona.

Finalmente, quando o envio está completo, ou caso tenha acontecido um erro em qualquer fase anterior, é invocado o método de destruição:

    ...

    def destroi(self):
        if id(self.sk) in clientes:
            del clientes[id(self.sk)]

        try:
            self.sk.close()
        except socket.error:
            pass

        print("%s: fechada" % str(self.addr))

O método acima não destrói realmente o objeto, mas sim fecha o socket (provocando o fechamento da conexão), e remove o cliente do dicionário de clientes ativos.

Em tese, bastaria pular fora do dicionário clientes; o coletor de lixo do Python encarregar-se-ia de destruir o cliente e o respectivo socket. Porém, sockets e conexões TCP são recursos do sistema operacional, que devem ser tratados como escassos e liberados tão logo quanto possível.

Também existe um limite de sockets por processo. Num servidor TCP ocupado, esse limite poderia ser atingido antes do coletor de lixo dignar-se a fazer seu trabalho. Além disso, a conexão TCP permaneceria aberta nesse interim, obrigando-nos a pelo menos invocar shutdown() para liberar o cliente. Então é melhor chamar close() logo de uma vez.

Como você deve ter notado, a implementação como um todo ficou bem mais elaborada, e completamente orientada a eventos. As instâncias da classe Cliente precisam implementar um comportamento bem defensivo, pois, como está na Bíblia, "não sabeis o dia nem a hora".

(continua...)