Um rastreador da web usando corrotinas assíncronas

Autor deste artigo:

A. Jesse Jiryu Davis é engenheiro do MongoDB em Nova York. Ele escreveu o driver assíncrono MongoDB Python Motor e é o líder de desenvolvimento do driver MongoDB C e membro da equipe PyMongo. Ele também contribui para asyncio e Tornado e escreve em  http://emptysqua.re  .

Guido van Rossum é o criador da linguagem de programação Python. A comunidade Python o chama de BDFL (Benevolent Dictator For Life) - um título do curta-metragem Monty Python. Sua página inicial é  a página pessoal de Guido  .

introduzir

A ciência da computação clássica enfatiza algoritmos eficientes para concluir os cálculos o mais rápido possível. No entanto, o tempo de muitos programas de rede não é gasto em cálculos, mas na espera pela ocorrência de muitas conexões lentas ou eventos de baixa frequência. Esses programas expõem um novo desafio: como esperar eficientemente por um grande número de eventos de rede. Uma solução moderna é a E/S assíncrona.

Neste capítulo implementaremos um web crawler simples. Este rastreador é apenas um protótipo de aplicativo assíncrono porque espera por muitas respostas e faz apenas alguns cálculos. Quanto mais páginas da web ele rastreia de uma vez, mais rápido ele poderá concluir sua tarefa. Se ele iniciar um thread para cada solicitação dinâmica, à medida que o número de solicitações simultâneas aumenta, ele ficará sem memória ou sem recursos relacionados ao thread antes de ficar sem soquetes. O uso de E/S assíncrona pode evitar esse problema.

Mostraremos este exemplo em três etapas. Primeiro, implementaremos um loop de eventos e usaremos esse loop de eventos e retornos de chamada para delinear um rastreador da web. É eficaz, mas quando dimensionado para problemas mais complexos, leva a uma confusão incontrolável de código. Então, como as corrotinas do Python não são apenas eficientes, mas também escaláveis, implementaremos uma corrotina simples usando as funções geradoras do Python. Na etapa final, usaremos as corrotinas totalmente funcionais da biblioteca padrão "asyncio" do Python e concluiremos este rastreador da web por meio de uma fila assíncrona. (Na  PyCon 2013  , Guido apresentou a biblioteca assíncrona padrão, chamada na época de "Tulip".)

Tarefa

Os rastreadores da Web encontram e baixam todas as páginas de um site e talvez as arquivem e indexem. A partir do URL raiz, ele busca cada página da web, analisa links que não encontrou antes e os adiciona à fila. Ele para quando a página não possui links invisíveis e a fila está vazia.

Podemos acelerar esse processo baixando um grande número de páginas da web ao mesmo tempo. Quando o rastreador descobre um novo link, ele processa o novo link em paralelo usando um novo soquete, analisa a resposta e adiciona o novo link à fila. Quando a simultaneidade é grande, pode causar degradação do desempenho, portanto limitaremos o número de simultaneidades e manteremos essas conexões não processadas na fila até que algumas tarefas em execução sejam concluídas.

caminho tradicional

Como fazer um crawler simultâneo? A abordagem tradicional é criar um pool de threads, e cada thread usa um soquete para ser responsável pelo download de uma página da web dentro de um período de tempo. Por exemplo, baixe uma página da web do site xkcd.com:

 
 
  1. def fetch(url):
  2. sock = socket.socket()
  3. sock.connect(('xkcd.com', 80))
  4. request = 'GET {} HTTP/1.0\r\nHost: xkcd.com\r\n\r\n'.format(url)
  5. sock.send(request.encode('ascii'))
  6. response = b''
  7. chunk = sock.recv(4096)
  8. while chunk:
  9. response += chunk
  10. chunk = sock.recv(4096)
  11. # Page is now downloaded.
  12. links = parse_links(response)
  13. q.add(links)

As operações de soquete são bloqueadas por padrão: quando um thread chama um método como  connect e  recv , ele é bloqueado até que a operação seja concluída. (Mesmo  send pode ser bloqueado, por exemplo, quando o receptor demora para aceitar mensagens de saída e o cache de dados de saída do sistema está cheio). Portanto, para baixar várias páginas da web ao mesmo tempo, precisamos de muitos threads. Um aplicativo complexo amortizará o custo de criação de threads mantendo threads ociosos em um pool de threads. A mesma abordagem se aplica aos soquetes, usando pooling de conexões.

Até agora, o uso de threads tem sido caro, e o sistema operacional estabeleceu diferentes limites rígidos sobre quais threads podem ser usados ​​por um processo, um usuário e uma máquina. No sistema do autor Jesse, um thread Python requer 50K de memória e o início de dezenas de milhares de threads falhará. A sobrecarga por thread e as limitações do sistema são os gargalos dessa abordagem.

No influente artigo de Dan Kegel " The C10K problem ", ele propôs as limitações do multi-threading na simultaneidade de E/S. Ele escreveu no início,

Chegou a hora dos servidores web lidarem com milhares de clientes simultaneamente, não acha? Afinal, a web é enorme agora.

Kegel cunhou o termo "C10K" em 1999. Dez mil conexões parecem aceitáveis ​​hoje, mas o problema ainda existe, só que em tamanho diferente. Naquela época, era impraticável iniciar um thread por conexão para o problema do C10K. Agora esse limite cresceu exponencialmente. Na verdade, nosso rastreador de brinquedo funciona perfeitamente usando threads. Porém, para aplicações de grande escala com dezenas de milhões de conexões, a limitação ainda existe: consumirá todos os threads, mesmo que os soquetes sejam suficientes. Então, como podemos resolver esse problema?

assíncrono

A estrutura de E/S assíncrona conclui operações simultâneas em um thread. Vamos ver como isso é feito.

Estruturas assíncronas usam soquetes sem bloqueio . Em um rastreador assíncrono, definimos o soquete como não bloqueador antes de iniciar uma conexão com o servidor:

 
 
  1. sock = socket.socket()
  2. sock.setblocking(False)
  3. try:
  4. sock.connect(('xkcd.com', 80))
  5. except BlockingIOError:
  6. pass

Chamar um  connect método em um soquete sem bloqueio lançará imediatamente uma exceção, mesmo que funcione normalmente. Esta exceção replica o comportamento irritante da função C subjacente, que está configurada para  errno informar  EINPROGRESSque a operação foi iniciada.

Agora nosso rastreador precisa saber quando uma conexão é estabelecida para que possa enviar uma solicitação HTTP. Podemos simplesmente usar um loop para tentar novamente:

 
 
  1. request = 'GET {} HTTP/1.0\r\nHost: xkcd.com\r\n\r\n'.format(url)
  2. encoded = request.encode('ascii')
  3. while True:
  4. try:
  5. sock.send(encoded)
  6. break # Done.
  7. except OSError as e:
  8. pass
  9. print('sent')

Este método não apenas consome CPU, mas também não pode esperar efetivamente por vários soquetes. Antigamente, a solução alternativa do BSD Unix era  selectque esta era uma função C que esperava que um evento ocorresse em um soquete ou conjunto de soquetes sem bloqueio. Agora, a demanda por um grande número de conexões em aplicações de Internet fez com que   a implementação no BSD fosse substituída  select pelo   Linux  . Suas APIs são   semelhantes, mas podem fornecer melhor desempenho em um grande número de conexões.pollkqueueepollselect

Python 3.4  DefaultSelector usará as melhores  select funções de classe em seu sistema. Para registrar alertas para eventos de E/S de rede, criamos um soquete sem bloqueio e o registramos usando o seletor padrão.

 
 
  1. from selectors import DefaultSelector, EVENT_WRITE
  2. selector = DefaultSelector()
  3. sock = socket.socket()
  4. sock.setblocking(False)
  5. try:
  6. sock.connect(('xkcd.com', 80))
  7. except BlockingIOError:
  8. pass
  9. def connected():
  10. selector.unregister(sock.fileno())
  11. print('connected!')
  12. selector.register(sock.fileno(), EVENT_WRITE, connected)

Ignoramos o erro falso e chamamos  selector.register, passando o descritor do arquivo de soquete e uma expressão constante indicando qual evento queremos escutar. Para ser alertado quando uma conexão for estabelecida, usamos  EVENT_WRITE : que indica quando este soquete é gravável. Também passamos uma função Python  connectedque é chamada quando ocorre o evento correspondente. Tais funções são chamadas de retornos de chamada .

Em um loop, processamos lembretes de E/S quando o seletor os recebe.

 
 
  1. def loop():
  2. while True:
  3. events = selector.select()
  4. for event_key, event_mask in events:
  5. callback = event_key.data
  6. callback()

connected A função de retorno de chamada é armazenada  event_key.data e será recuperada para execução assim que o soquete sem bloqueio for conectado.

Ao contrário do nosso loop de giro rápido anterior, a chamada aqui  select faz uma pausa, aguardando o próximo evento de E/S e, em seguida, executa a função de retorno de chamada aguardando esses eventos. As operações inacabadas permanecerão pendentes até que o próximo loop de eventos seja inserido.

O que mostramos até agora? Mostramos como iniciar uma operação de E/S e chamar uma função de retorno de chamada quando a operação estiver pronta. Uma estrutura assíncrona que executa operações simultâneas em um único thread e é construída em dois recursos, soquetes sem bloqueio e loops de eventos.

Alcançamos aqui a “simultaneidade”, mas não o “paralelismo” no sentido tradicional. Ou seja, construímos um sistema minúsculo que pode realizar E/S sobrepostas, que pode iniciar uma nova operação enquanto outras operações ainda estão em andamento. Na verdade, ele não aproveita vários núcleos para realizar cálculos em paralelo. Este sistema foi projetado para resolver problemas de E/S com uso intensivo de E/S, e não problemas com uso intensivo de CPU. (O bloqueio de interpretador global do Python proíbe a execução paralela de código Python em um processo de qualquer forma. Paralelizar algoritmos com uso intensivo de CPU em Python requer vários processos ou portar o código para uma versão paralela C. Mas esse é outro tópico A.)

Portanto, nosso loop de eventos é eficiente em E/S simultânea porque não aloca recursos de thread para cada conexão. Mas antes de começarmos, precisamos esclarecer um equívoco comum: o assíncrono é mais rápido que o multithreading. Geralmente, esse não é o caso e, de fato, em Python, loops de eventos como o nosso são mais lentos que o multithreading ao lidar com um pequeno número de conexões muito ativas. Não há bloqueio global de intérprete no ambiente de tempo de execução e os threads terão melhor desempenho sob a mesma carga. A E/S assíncrona é realmente adequada para aplicações com poucos eventos e muitas conexões lentas ou inativas. (Jesse aponta onde o assíncrono funciona e não funciona em “ O que é assíncrono, como funciona e quando devo usá-lo? ”). Mike Bayer compara o assíncrono sob diferentes cargas de trabalho em “ Python assíncrono e bancos de dados”.

ligar de volta

Como podemos completar um web crawler usando a estrutura assíncrona que acabamos de estabelecer? Mesmo um simples programa de download da web é difícil de escrever.

Primeiro, temos uma coleção de URLs que ainda não foram recuperadas e uma coleção de URLs que foram analisadas.

 
 
  1. urls_todo = set(['/'])
  2. seen_urls = set(['/'])

seen_urls A coleção inclui  urls_todo URLs preenchidos. Inicialize-os com o URL raiz  / .

A obtenção de uma página da web requer uma série de retornos de chamada. O retorno de chamada é acionado quando a conexão do soquete é estabelecida  connected , o que envia uma solicitação GET ao servidor. Mas ele tem que esperar por uma resposta, então precisamos registrar outra função de retorno de chamada; quando esse retorno de chamada for chamado e ainda não conseguir ler a solicitação completa, ele registrará o retorno de chamada novamente e assim por diante.

Vamos colocar esses callbacks em um  Fetcher objeto, que precisa de uma URL, um soquete e um local para salvar os bytes retornados:

 
 
  1. class Fetcher:
  2. def __init__(self, url):
  3. self.response = b'' # Empty array of bytes.
  4. self.url = url
  5. self.sock = None

Nosso ponto de entrada é  Fetcher.fetch:

 
 
  1. # Method on Fetcher class.
  2. def fetch(self):
  3. self.sock = socket.socket()
  4. self.sock.setblocking(False)
  5. try:
  6. self.sock.connect(('xkcd.com', 80))
  7. except BlockingIOError:
  8. pass
  9. # Register next callback.
  10. selector.register(self.sock.fileno(),
  11. EVENT_WRITE,
  12. self.connected)

fetch O método começa conectando-se a um soquete. Mas esteja ciente de que este método retorna antes de a conexão ser estabelecida. Ele deve retornar o controle ao loop de eventos e aguardar o estabelecimento da conexão. Para entender por que fazemos isso, vamos supor que a estrutura geral do nosso programa seja a seguinte:

 
 
  1. # Begin fetching http://xkcd.com/353/
  2. fetcher = Fetcher('/353/')
  3. fetcher.fetch()
  4. while True:
  5. events = selector.select()
  6. for event_key, event_mask in events:
  7. callback = event_key.data
  8. callback(event_key, event_mask)

Quando  select a função é chamada, todos os lembretes de eventos serão processados ​​no loop de eventos, portanto, o  fetch controle deve ser entregue ao loop de eventos, para que nosso programa possa saber quando a conexão foi estabelecida, e então o loop chama  connected o retorno de chamada, que já está fetch registrado no método acima  .

Aqui está a implementação do nosso  connected método:

 
 
  1. # Method on Fetcher class.
  2. def connected(self, key, mask):
  3. print('connected!')
  4. selector.unregister(key.fd)
  5. request = 'GET {} HTTP/1.0\r\nHost: xkcd.com\r\n\r\n'.format(self.url)
  6. self.sock.send(request.encode('ascii'))
  7. # Register the next callback.
  8. selector.register(key.fd,
  9. EVENT_READ,
  10. self.read_response)

Este método envia uma solicitação GET. Uma aplicação real verificaria  send o valor de retorno caso todas as informações não fossem enviadas de uma vez. Mas nossos pedidos são pequenos e a aplicação não é complexa. Ele simplesmente liga  sende aguarda uma resposta. Claro, ele precisa registrar outro retorno de chamada e passar o controle para o loop de eventos. A próxima e última função de retorno de chamada  read_responsetrata da resposta do servidor:

 
 
  1. # Method on Fetcher class.
  2. def read_response(self, key, mask):
  3. global stopped
  4. chunk = self.sock.recv(4096) # 4k chunk size.
  5. if chunk:
  6. self.response += chunk
  7. else:
  8. selector.unregister(key.fd) # Done reading.
  9. links = self.parse_links()
  10. # Python set-logic:
  11. for link in links.difference(seen_urls):
  12. urls_todo.add(link)
  13. Fetcher(link).fetch() # <- New Fetcher.
  14. seen_urls.update(links)
  15. urls_todo.remove(self.url)
  16. if not urls_todo:
  17. stopped = True

Este retorno de chamada é chamado cada vez que  selector o soquete é legível . Existem duas condições para legibilidade: o soquete recebe dados ou é fechado.

Esta função de retorno de chamada lê dados 4K do soquete. Se for inferior a 4k, leia o máximo que puder. Se for superior a 4K, chunk apenas os dados de 4K serão incluídos e o soquete permanecerá legível, de modo que no próximo ciclo do loop de eventos esta função de retorno de chamada será retornada novamente. Quando a resposta for concluída, o servidor fecha o soquete, que chunk está vazio.

O método , não mostrado aqui  parse_links , retorna uma coleção de URLs. Iniciamos um buscador para cada novo URL. Observe um benefício do uso da programação de retorno de chamada assíncrona: não precisamos bloquear dados compartilhados, como  seen_urls quando adicionamos um novo link. Esta é uma multitarefa não preemptiva que não será interrompida em nenhum lugar do nosso código.

Adicionamos uma variável global  stoppede a usamos para controlar este loop:

 
 
  1. stopped = False
  2. def loop():
  3. while not stopped:
  4. events = selector.select()
  5. for event_key, event_mask in events:
  6. callback = event_key.data
  7. callback()

Depois que todas as páginas da web forem baixadas, o buscador interrompe o loop de eventos e o programa é encerrado.

Este exemplo traz à tona um problema de programação assíncrona: código espaguete.

Precisamos de alguma maneira de expressar uma série de cálculos e operações de E/S e ser capazes de programar várias dessas séries de operações para serem executadas simultaneamente. No entanto, você não pode escrever esta série de operações em uma função sem threads: quando a função inicia uma operação de E/S, ela salva explicitamente o estado futuro necessário e depois retorna. Você precisa pensar em como escrever esse código para salvar o estado.

Vamos explicar o que isso significa. Vejamos primeiro como é simples buscar uma página da web usando um soquete de bloqueio normal em um thread.

 
 
  1. # Blocking version.
  2. def fetch(url):
  3. sock = socket.socket()
  4. sock.connect(('xkcd.com', 80))
  5. request = 'GET {} HTTP/1.0\r\nHost: xkcd.com\r\n\r\n'.format(url)
  6. sock.send(request.encode('ascii'))
  7. response = b''
  8. chunk = sock.recv(4096)
  9. while chunk:
  10. response += chunk
  11. chunk = sock.recv(4096)
  12. # Page is now downloaded.
  13. links = parse_links(response)
  14. q.add(links)

Qual estado esta função lembra entre uma operação de soquete e a próxima? Possui um soquete, uma URL e um arquivo  response. Funções executadas em threads usam recursos básicos de linguagem de programação para armazenar esse estado temporário em variáveis ​​locais na pilha. Essa função também tem uma "continuação" - ela executa o código após a conclusão da E/S. O ambiente de execução lembra dessa continuação por meio do ponteiro de instrução do thread. Você não precisa se preocupar em como restaurar variáveis ​​locais e esta continuação após uma operação de E/S. As características da própria linguagem ajudam a resolvê-lo.

Mas ao usar uma estrutura assíncrona baseada em retorno de chamada, esses recursos de linguagem não fornecem nenhuma ajuda. Ao aguardar uma operação de E/S, uma função deve salvar explicitamente seu estado porque retornará e limpará o quadro de pilha antes que a operação de E/S seja concluída. Em nosso exemplo baseado em retorno de chamada, em vez de variáveis ​​locais, armazenamos   e sock como  response propriedades da instância do Fetcher  . selfEm vez do ponteiro de instrução, ele   salva sua continuação por meio de registro connected e  retornos de chamada. read_responseÀ medida que a funcionalidade do nosso aplicativo aumenta, aumenta também a complexidade dos retornos de chamada que precisamos salvar manualmente. Esse trabalho contábil complexo pode causar dores de cabeça aos programadores.

Pior ainda, o que acontece quando nossa função de retorno de chamada lança uma exceção? Suponha que não escrevemos  parse_links bem o método e ele gera uma exceção ao analisar HTML:

 
 
  1. Traceback (most recent call last):
  2. File "loop-with-callbacks.py", line 111, in <module>
  3. loop()
  4. File "loop-with-callbacks.py", line 106, in loop
  5. callback(event_key, event_mask)
  6. File "loop-with-callbacks.py", line 51, in read_response
  7. links = self.parse_links()
  8. File "loop-with-callbacks.py", line 67, in parse_links
  9. raise Exception('parse error')
  10. Exception: parse error

Este rastreamento de pilha mostra apenas que o loop de eventos chamou um retorno de chamada. Não sabemos o que causa esse erro. Os dois lados da corrente estão quebrados: ninguém sabe de onde veio e ninguém sabe para onde vai. Essa perda de contexto é conhecida como “extração de pilha” e geralmente resulta na incapacidade de analisar a causa. Também nos impede de configurar o tratamento de exceções para cadeias de retorno de chamada, ou seja, o tipo que encapsula chamadas de função e suas árvores de chamada com blocos "try/except". (Para uma solução mais complexa para este problema, consulte  http://www.tornadoweb.org/en/stable/stack_context.html  )

Assim, além do debate de longa data sobre o que é mais eficiente, multithreading ou assíncrono, há também um debate entre os dois: quem é mais propenso a se ajoelhar. Se ocorrerem erros de sincronização, é mais provável que os threads tenham problemas de corrida de dados e os retornos de chamada sejam muito difíceis de depurar devido a problemas de "extração de pilha".

Acho que você gosta

Origin blog.csdn.net/xiaoshun007/article/details/133433543
Recomendado
Clasificación