Site menu select(): a base da programação assíncrona

select(): a base da programação assíncrona

É, eu sei, já foram escritos milhões de artigos sobre esse assunto. Mas este aqui é o meu :) e tem uma dose de nostalgia e reminiscências, então pode divertir mesmo se você conhece o assunto de cor e salteado.

O jeito Unix de fazer as coisas

Meu primeiro contato com Unix e a filosofia "tudo é um arquivo" remonta a 1993. Como eu não desenvolvia em C na época, nem havia Internet, não percebia o real e longo alcance dessa filosofia.

O primeiro contato com programação assíncrona aconteceu via desenvolvimento para interface gráfica Windows. Diferente de um programa MS-DOS, um programa GUI Windows fica inativo a maior parte do tempo, sendo acordado apenas quando a fila de eventos recebe eventos do sistema. Essa fila é usada para receber todo tipo de evento, não apenas os relacionados à GUI.

Na segunda metade dos anos 1990, thread era a palavra da moda. As threads eram a solução definitiva para problemas de programação assíncrona que surgiam com a popularização da Internet. A então novíssima linguagem Java tinha suporte embutido a threads. Windows NT também suportava threads muito bem, deixando o velho Unix comendo poeira. Linux só recebeu suporte (incipiente) a threads em 1996.

Apesar das raízes Unix (Solaris) do Java, ele não suportava programação assíncrona chez Unix até 2002 (*). A solução Java para um servidor Internet atender múltiplos clientes simultâneos era criar uma thread por conexão. Noves fora que usar threads abre uma caixa de Pandora (mesmo no Java), criar uma thread por conexão funciona quando as conexões contam-se às dezenas. Isto não escala.

(Minha teoria da conspiração particular é que a Sun promovia este modelo para vender hardware. O processador SPARC chaveava entre threads muito mais rapidamente que o x86 Intel, o que casava bem com a destreza do Java com threads. Se o Java se tornasse ubíquo, pelo menos os casos de uso que exigissem alta performance adotariam servidores Sun SPARC.)

Em 1998, comecei a mexer com Linux. Já era um sistema popular para serviços de Internet. Mas como é o Linux se virava, com threads e SMP fracos (na época)? A resposta veio no livro "Beginning Linux Programming" da Wrox Press, onde conheci o venerável select().

Para quem conhece programação de baixo nível Windows, o select() é conceitualmente equivalente ao WaitForMultipleObjects(). A diferença é o segundo aceita objetos de muitos tipos, enquanto o primeiro só aceita arquivos.

Só que no Unix, tudo é arquivo, inclusive as conexões de rede. Aí todas as peças se encaixaram, e tive aquele momento "eureka". E afeiçoei-me a esse modelo, que me pareceu imediatamente o mais razoável, apesar de ser o mais antigo.

Desde então, a popularidade da programação assíncrona só fez crescer. É o aspecto mais protuberante do Node.js. Todas as linguagens relevantes incorporam as palavras-chaves async/await.

O select() é um tanto obsoleto; existem chamadas de sistema mais eficientes e/ou mais práticas de usar, como poll(), epoll, kqueue, etc. Mas o paradigma não muda. Em Python, existe o módulo selectors que usa automaticamente a chamada mais eficiente disponível no seu sistema.

Programação assíncrona raiz!

O jeito racional de desenvolver um sistema assíncrono hoje seria usar uma biblioteca de alto nível. No Python seria o asyncio. Em C/C++ existe o libuv, base do Node.js.

Porém, eu acabo reinventando essa roda de vez em quando. Em parte por pura diversão, em parte porque sempre acho aborrecidas as bibliotecas que tentam abstraí-la. (Agora que escrevi isto, decidi que vou criar uma biblioteca.)

Postei recentemente no GitHub dois projetos muito simples, o alarme-intelbras e o VMonitor. Cada um embute uma reescrita do loop de eventos assíncronos.

Os trechos de código a seguir baseiam-se mais no projeto do alarme, pois é um servidor TCP que potencialmente pode atender múltiplas centrais simultaneamente, O VMonitor usa pacotes UDP. O primeiro faz um exemplo mais didático.

O coração de um programa assíncrono é um loop infinito bem simples:

while True:
    rd, wr, ex = select.select(sockets, [], [])

    if serverfd in rd:
        # aceita conexao nova
    elif rd:
        # lê dados dos sockets contidos na lista "rd"

A função select() recebe três listas de arquivos: candidatos a leitura, candidatos a gravação, e candidatos a exceções.

Ele também retorna três listas de arquivos: aqueles prontos para leitura (rd), prontos para escrita (wr), e que apresentam condição excepcional (ex).

No exemplo acima, enviamos a lista sockets contendo todas as conexões TCP, como candidatas à leitura. Assim que uma ou mais conexões receberem dados, o select() devolve a lista de sockets com dados, que podemos ler usando read() ou recv().

Por que não podemos chamar read() para todos os sockets logo de uma vez? Porque, quando se trata de pipes e sockets, read() é uma chamada bloqueante; ela só retorna quando houver dados. Enquanto isso, seu programa dorme e deixa de tratar outras conexões ou eventos.

Note como isso é diferente de ler ou gravar um arquivo convencional, em que read() e write() são síncronas, ou seja, retornam tão rapidamente quanto possível.

Uma solução seria criar uma thread por conexão. Se uma thread chama read() para uma conexão, ela dorme, mas as outras podem continuar trabalhando.

Podemos ainda usar I/O não bloqueante, neste caso recv() retornaria imediatamente se não houvesse dados. Porém, se nosso programa nunca dorme, ele consome 100% da CPU, sem fazer nada útil.

Usando select(), nosso programa também dorme, porém é despertado assim que qualquer socket da lista tiver movimento. Assim, podemos chamar read() apenas sobre os soquetes com dados, que sabemos que não vão bloquear.

Vou copiar o exemplo novamente, para discutir mais alguns detalhes.

while True:
    rd, wr, ex = select.select(sockets, [], [])

    if serverfd in rd:
        # aceita conexao nova
    elif rd:
        # lê dados dos sockets contidos na lista "rd"

O socket serverfd não é uma conexão. Ele representa uma porta TCP de servidor, que recebe conexões. Quando ele está pronto para "leitura", significa que uma conexão nova foi aberta. (Vou evitar me aprofundar no assunto BSD Sockets neste artigo. Olhe o código-fonte do projeto de alarme se quiser ver como esse socket é criado.)

Note que também passamos duas listas vazias para o select(). Por ser um servidor simples, não usei select() para detectar conexões prontas para envio de dados.

A rigor, isto é errado; chamar write() ou send() para um socket pode bloquear se o buffer de transmissão estiver cheio! No caso, eu sei que isto não vai acontecer, pois as respostas sempre cabem num único datagrama, e o protocolo da central é estilo half-duplex.

Também não usei select() para detectar exceções. Fiz isso porque meu caso de uso não exigiu.

IMPORTANTE: A lista ex é usada para detectar condições excepcionais, não erros! Passar um arquivo inválido (por exemplo, um socket já fechado) ao select() é um erro, e ele retornará imediatamente, lançando uma Exception em Python.

Mas o que é uma "condição excepcional"? Isso varia conforme o tipo de arquivo, o sistema operacional, até mesmo conforme a versão do sistema. É um daqueles cantinhos obscuros do padrão Unix...

Em sockets TCP, o único caso é comunicação OOB (out-of-band). Mas, fora o Telnet, nenhum protocolo TCP em uso corrente usa OOB. Considerando que o caso de uso mais comum de select() é implementar protocolos TCP e UDP, quase nunca precisamos usar esse recurso "excepcional".

No nosso exemplo, select() só monitora conexões de rede. Mas poderiam ser pipes, teclados, mouses, portas seriais, placas de som, câmeras de vídeo — em resumo, o select() é a fila de eventos do seu programa, seja texto ou gráfico.

Faltou uma coisa na lista acima: timeouts. Por "timeout" entenda uma tarefa agendada para um momento futuro. O select() também provê recursos para implementar isso, basta passar um quarto parâmetro numérico:

rd, wr, ex = select.select(sockets, [], [], timeout)

Suponha que timeout seja igual a 5. Neste caso, select() dorme por no máximo 5 segundos. Se nenhuma conexão se mexer nesse tempo, ele retorna listas vazias.

Mesmo que seu código não precise de timeouts, é útil passar um timeout para que o select() retorne a cada poucos segundos e imprima uma mensagem qualquer, para fins de depuração (i.e. para ver se seu programa não está bloqueado em outro canto).

Uma versão mais completa do loop de eventos ficaria assim:

while True:
    timeout = proximo_timeout()
    rd, wr, ex = select.select(sockets, [], [], timeout)

    if serverfd in rd:
        # Aceita conexão nova
    elif rd:
        # Lê dados dos sockets contidos na lista rd
    else:
        trata_timeouts()

A chamada select() só aceita um valor de timeout. Isto quer dizer que, se tivermos diversas tarefas agendadas, precisamos determinar qual delas apresenta o timeout mais curto, e passar esse valor para select().

Implementar a mecânica desta lista de timeouts é a parte mais divertida, ou mais chata, ao reescrever um loop de eventos do zero. É fácil meter os pés pelas mãos nos casos fortuitos. Os dois projetos citados antes implementam timeout de formas diferentes, embora parecidas; e ambos demandaram bastante depuração para funcionar direito.

E se acontecer do loop de eventos ficar ocioso, sem nenhum timeout de tarefa futura, nem nenhum arquivo ou soquete para ler ou gravar? Na minha visão, isto só pode significar uma coisa: seu programa não tem mais nada para fazer, e pode encerrar.

(*) Não sou Javeiro profissional. Até onde pesquisei, o equivalente assíncrono no Java é o NIO, introduzido com o J2SE 1.4 de 2002. Se alguém tem conhecimento de outro recurso de programação assíncrona Java (sem threads) anterior a 2002, favor avisar para corrigirmos o texto.