Загадочные исключения при выполнении множества одновременных запросов от urllib.request к HTTPServer

Я пытаюсь выполнить эту криптографическую задачу Matasano, которая включает в себя атаку по времени на сервер с искусственно замедленным функция сравнения строк вниз. В нем говорится об использовании «веб-фреймворка по вашему выбору», но мне не хотелось устанавливать веб-фреймворк, поэтому я решил использовать класс HTTPServer, встроенный в http.server< /а> модуль.

Я придумал что-то, что работало, но работало очень медленно, поэтому я попытался ускорить его, используя (плохо документированный) пул потоков, встроенный в multiprocessing.dummy. Это было намного быстрее, но я заметил одну странность: если я делаю 8 или меньше запросов одновременно, все работает нормально. Если у меня больше этого, это работает какое-то время и выдает мне ошибки в, казалось бы, случайное время. Ошибки кажутся непоследовательными и не всегда одинаковыми, но обычно в них есть Connection refused, invalid argument, OSError: [Errno 22] Invalid argument, urllib.error.URLError: <urlopen error [Errno 22] Invalid argument>, BrokenPipeError: [Errno 32] Broken pipe или urllib.error.URLError: <urlopen error [Errno 61] Connection refused>.

Есть ли ограничение на количество подключений, которые может обработать сервер? Я не думаю, что количество потоков само по себе является проблемой, потому что я написал простую функцию, которая выполняла замедленное сравнение строк без запуска веб-сервера и вызывала ее с 500 одновременными потоками, и она работала нормально. Я не думаю, что проблема заключается в простом выполнении запросов из такого количества потоков, потому что я создал поисковые роботы, которые использовали более 100 потоков (все выполняли одновременные запросы к одному и тому же веб-сайту), и они работали нормально. Похоже, что HTTP-сервер не предназначен для надежного размещения рабочих веб-сайтов с большим объемом трафика, но я удивлен, что его так легко вывести из строя.

Я пытался постепенно удалять из своего кода то, что не имело отношения к проблеме, как я обычно делаю, когда диагностирую подобные загадочные ошибки, но в данном случае это не очень помогло. Казалось, что по мере того, как я удалял, казалось бы, несвязанный код, количество соединений, которые сервер мог обработать, постепенно увеличивалось, но не было ясной причины сбоев.

Кто-нибудь знает, как увеличить количество запросов, которые я могу сделать за один раз, или, по крайней мере, почему это происходит?

Мой код сложный, но я придумал эту простую программу, которая демонстрирует проблему:

#!/usr/bin/env python3

import os
import random

from http.server import BaseHTTPRequestHandler, HTTPServer
from multiprocessing.dummy import Pool as ThreadPool
from socketserver import ForkingMixIn, ThreadingMixIn
from threading import Thread
from time import sleep
from urllib.error import HTTPError
from urllib.request import urlopen


class FancyHTTPServer(ThreadingMixIn, HTTPServer):
    pass


class MyRequestHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        sleep(random.uniform(0, 2))
        self.send_response(200)
        self.end_headers()
        self.wfile.write(b"foo")

    def log_request(self, code=None, size=None):
        pass

def request_is_ok(number):
    try:
        urlopen("http://localhost:31415/test" + str(number))
    except HTTPError:
        return False
    else:
        return True


server = FancyHTTPServer(("localhost", 31415), MyRequestHandler)
try:
    Thread(target=server.serve_forever).start()
    with ThreadPool(200) as pool:
        for i in range(10):
            numbers = [random.randint(0, 99999) for j in range(20000)]
            for j, result in enumerate(pool.imap(request_is_ok, numbers)):
                if j % 20 == 0:
                    print(i, j)
finally:
    server.shutdown()
    server.server_close()
    print("done testing server")

По какой-то причине приведенная выше программа работает нормально, если в ней не более 100 потоков или около того, но мой реальный код для задачи может обрабатывать только 8 потоков. Если я запускаю его с 9, я обычно получаю ошибки подключения, а с 10 я всегда получаю ошибки подключения. Я попытался использовать concurrent.futures.ThreadPoolExecutor, concurrent.futures.ProcessPoolExecutor и multiprocessing.pool вместо multiprocessing.dummy.pool, и ни один из них не помог. Я попытался использовать простой объект HTTPServer (без ThreadingMixIn), но это только замедлило работу и не решило проблему. Я пытался использовать ForkingMixIn, и это тоже не помогло.

Что мне с этим делать? Я использую Python 3.5.1 на MacBook Pro конца 2013 года под управлением OS X 10.11.3.

EDIT: я попробовал еще несколько вещей, включая запуск сервера в процессе вместо потока, как простой HTTPServer, с ForkingMixIn и с ThreadingMixIn. Ни один из них не помог.

EDIT: Эта проблема более странная, чем я думал. Я попытался создать один скрипт с сервером, а другой — с большим количеством потоков, делающих запросы, и запускать их на разных вкладках в моем терминале. Процесс с сервером прошел нормально, но тот, который делал запросы, завис. Исключения составляли сочетания ConnectionResetError: [Errno 54] Connection reset by peer, urllib.error.URLError: <urlopen error [Errno 54] Connection reset by peer>, OSError: [Errno 41] Protocol wrong type for socket, urllib.error.URLError: <urlopen error [Errno 41] Protocol wrong type for socket>, urllib.error.URLError: <urlopen error [Errno 22] Invalid argument>.

Я попробовал это с фиктивным сервером, подобным приведенному выше, и если я ограничил количество одновременных запросов до 5 или меньше, он работал нормально, но с 6 запросами клиентский процесс зависал. Были некоторые ошибки с сервера, но это продолжалось. Клиент аварийно завершал работу независимо от того, использовал ли я потоки или процессы для выполнения запросов. Затем я попытался установить функцию замедления на сервер, и он смог обработать 60 одновременных запросов, но произошел сбой при 70. Похоже, что это может противоречить доказательствам того, что проблема связана с сервером.

РЕДАКТИРОВАТЬ: я пробовал большинство вещей, которые я описал, используя requests вместо urllib.request, и столкнулся с аналогичными проблемами.

EDIT: Сейчас я использую OS X 10.11.4 и сталкиваюсь с теми же проблемами.


person Elias Zamaria    schedule 18.03.2016    source источник
comment
Вы уверены, что закрываете неиспользуемые клиентские соединения?   -  person Cory Shay    schedule 18.03.2016
comment
@ Кори Шей, я пытался сделать x = urlopen(whatever), а затем x.close(), и это, похоже, не помогло.   -  person Elias Zamaria    schedule 18.03.2016
comment
Я должен признать, что причина, которую я указал, не обязательно является причиной возникновения этой проблемы. Потенциально могут быть и другие. Но несколько вопросов, которые могут помочь в расследовании: что произойдет, если вы выдадите ulimit -r $(( 32 * 1024 )) ? и что на выходе из netstat -anp|grep SERVERPROCESSNAME?   -  person Dmitry Rubanovich    schedule 11.04.2016


Ответы (3)


Вы используете значение невыполненной работы по умолчанию listen(), что, вероятно, является причиной многих этих ошибок. Это не количество одновременных клиентов с уже установленным соединением, а количество клиентов, ожидающих в очереди прослушивания, прежде чем соединение будет установлено. Измените класс вашего сервера на:

class FancyHTTPServer(ThreadingMixIn, HTTPServer):
    def server_activate(self):
        self.socket.listen(128)

128 - разумный предел. Возможно, вы захотите проверить socket.SOMAXCONN или somaxconn вашей ОС, если хотите увеличить его еще больше. Если у вас по-прежнему возникают случайные ошибки при большой нагрузке, вам следует проверить настройки ulimit и при необходимости увеличить их.

Я сделал это с вашим примером, и у меня нормально работает более 1000 потоков, поэтому я думаю, что это должно решить вашу проблему.


Обновить

Если это улучшилось, но все еще происходит сбой при 200 одновременных клиентах, то я почти уверен, что вашей основной проблемой был размер невыполненной работы. Имейте в виду, что ваша проблема заключается не в количестве одновременных клиентов, а в количестве одновременных запросов на подключение. Краткое объяснение того, что это значит, не слишком углубляясь во внутренности TCP.

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind((HOST, PORT))
s.listen(BACKLOG)
while running:
    conn, addr = s.accept()
    do_something(conn, addr)

В этом примере сокет теперь принимает соединения на указанном порту, и вызов s.accept() будет блокироваться до тех пор, пока не подключится клиент. У вас может быть много клиентов, пытающихся подключиться одновременно, и в зависимости от вашего приложения вы, возможно, не сможете вызывать s.accept() и отправлять клиентское соединение так же быстро, как клиенты пытаются подключиться. Ожидающие клиенты ставятся в очередь, и максимальный размер этой очереди определяется значением BACKLOG. Если очередь заполнена, клиенты завершатся с ошибкой Connection Refused.

Многопоточность не помогает, потому что класс ThreadingMixIn выполняет вызов do_something(conn, addr) в отдельном потоке, чтобы сервер мог вернуться к основному циклу и вызову s.accept().

Вы можете попробовать еще больше увеличить отставание, но будет момент, когда это не поможет, потому что, если очередь станет слишком большой, некоторые клиенты прекратят работу по тайм-ауту до того, как сервер выполнит вызов s.accept().

Итак, как я уже сказал выше, ваша проблема заключается в количестве одновременных попыток подключения, а не в количестве одновременных клиентов. Возможно, 128 достаточно для вашего реального приложения, но вы получаете ошибку в своем тесте, потому что пытаетесь подключиться ко всем 200 потокам одновременно и переполняете очередь.

Не беспокойтесь о ulimit, если только вы не получите ошибку Too many open files, но если вы хотите увеличить невыполненную работу за пределы 128, изучите socket.SOMAXCONN. Это хорошее начало: https://utcc.utoronto.ca/~cks/space/blog/python/AvoidSOMAXCONN

person Pedro Werneck    schedule 05.04.2016
comment
Я сделал это, и это работает даже со 150 потоками! Он вылетает на 200, но 150 может быть достаточно для моих целей, а если нет, по крайней мере, у меня может быть какое-то представление, что с этим делать. Я не знаю, что делает эта штука listen(), или что такое somaxconn или ulimit, поэтому я хочу изучить все это, попробовать разные числа и, возможно, подождать, чтобы увидеть, получу ли я какой-то лучший ответ, прежде чем присуждать награду, но ваш ответ был очень полезен. Спасибо. - person Elias Zamaria; 06.04.2016
comment
@EliasZamaria Проверьте мой обновленный ответ. Я дал более подробное объяснение, так как вы немного заблудились. - person Pedro Werneck; 06.04.2016
comment
Спасибо за объяснение. Этот материал TCP более низкого уровня, чем я обычно имею дело, и я мало что знаю об этом. Я поиграю с этим еще немного, когда у меня будет время, и опубликую здесь, если у меня возникнут еще какие-либо проблемы, с которыми я не могу легко справиться сам. - person Elias Zamaria; 08.04.2016
comment
Я просматриваю документацию для socket.listen. В нем говорится, что если [отставание] не указано, выбирается разумное значение по умолчанию. Вы знаете, что это за разумное значение? Я попытался найти в источнике socket.py и не смог найти. Я заметил строку, которая говорит from _socket import *, так что, вероятно, используется какой-то скомпилированный код. Я пытался найти источник для этого, но я не мог найти его. Кстати, это все далеко за пределами моей области знаний. - person Elias Zamaria; 11.04.2016
comment
Кажется, я нашел то, что искал: github.com/ python/cpython/blob/master/Modules/. Мне просто нужно выяснить, что такое SOMAXCONN. - person Elias Zamaria; 11.04.2016
comment
@EliasZamaria По умолчанию для socket.listen используется значение min(socket.SOMAXCONN, 128), но значение по умолчанию для HTTPServer, которое вы используете, равно 5. - person Pedro Werneck; 11.04.2016
comment
Откуда у вас цифра 5? Единственное, что мне удалось найти, это github. com/python/cpython/blob/master/Modules/, и это не похоже на что-то конкретное для класса HTTPServer. - person Elias Zamaria; 11.04.2016
comment
comment
Спасибо. Я как-то проглядел это. Я предполагаю, что переопределение request_queue_size в моем подклассе HTTPServer будет иметь тот же эффект, что и переопределение server_activate, и, возможно, будет немного более читабельным, поэтому я сделаю это. - person Elias Zamaria; 11.04.2016
comment
Я думаю, что вещь request_queue_size приемлемо решила мою проблему. В идеале я хотел бы, чтобы запрос обрабатывался сразу, а не ставился в очередь, поскольку для этого очень важно точное время, хотя это может быть нереалистично, учитывая, что я делаю так много запросов одновременно к серверу, который намеренно спроектирован медленно обращаться с ними. Я не уверен, сколько еще усилий я готов потратить в ближайшем будущем, пытаясь понять детали предела, с которым я сталкиваюсь. - person Elias Zamaria; 12.04.2016
comment
Невозможно избежать очереди прослушивания с помощью TCP. Можно попробовать переиспользовать HTTP-соединение с keep-alive, чтобы с очередью страдал только первый запрос, но и тогда у вас не будет реальных одновременных запросов. Откровенно говоря, если время настолько критично для вашего приложения, вам не следует делать это на Python. - person Pedro Werneck; 12.04.2016
comment
Я забыл спросить тебя об одном. У вас есть идеи, почему по умолчанию только 5? Есть ли какой-то недостаток в том, чтобы установить его на что-то намного более высокое, например 128? - person Elias Zamaria; 12.04.2016
comment
@EliasZamaria Понятия не имею. Значение по умолчанию для модуля socketserver равно 5, по крайней мере, начиная с Python 1.5.2. Я предполагаю, что тогда это было принято как разумное значение по умолчанию, и никто никогда не удосужился обновить его, когда значение по умолчанию для socket.listen изменилось на min(socket.SOMAXCONN, 128). - person Pedro Werneck; 12.04.2016

Я бы сказал, что ваша проблема связана с некоторой блокировкой ввода-вывода, поскольку я успешно выполнил ваш код на NodeJs. Я также заметил, что и сервер, и клиент не могут работать по отдельности.

Но можно увеличить количество запросов с помощью нескольких модификаций:

  • Определите количество одновременных подключений:

    http.server.HTTPServer.request_queue_size = 500

  • Запустите сервер в другом процессе:

    server = multiprocessing.Process(target=RunHTTPServer) server.start()

  • Используйте пул соединений на стороне клиента для выполнения запросов

  • Используйте пул потоков на стороне сервера для обработки запросов

  • Разрешить повторное использование соединения на стороне клиента, установив схему и используя заголовок «keep-alive».

Со всеми этими изменениями мне удалось запустить код с 500 потоками без каких-либо проблем. Итак, если вы хотите попробовать, вот полный код:

import random
from time import sleep, clock
from http.server import BaseHTTPRequestHandler, HTTPServer
from multiprocessing import Process
from multiprocessing.pool import ThreadPool
from socketserver import ThreadingMixIn
from concurrent.futures import ThreadPoolExecutor
from urllib3 import HTTPConnectionPool
from urllib.error import HTTPError


class HTTPServerThreaded(HTTPServer):
    request_queue_size = 500
    allow_reuse_address = True

    def serve_forever(self):
        executor = ThreadPoolExecutor(max_workers=self.request_queue_size)

        while True:
          try:
              request, client_address = self.get_request()
              executor.submit(ThreadingMixIn.process_request_thread, self, request, client_address)
          except OSError:
              break

        self.server_close()


class MyRequestHandler(BaseHTTPRequestHandler):
    default_request_version = 'HTTP/1.1'

    def do_GET(self):
        sleep(random.uniform(0, 1) / 100.0)

        data = b"abcdef"
        self.send_response(200)
        self.send_header("Content-type", 'text/html')
        self.send_header("Content-length", len(data))
        self.end_headers()
        self.wfile.write(data)

    def log_request(self, code=None, size=None):
        pass


def RunHTTPServer():
    server = HTTPServerThreaded(('127.0.0.1', 5674), MyRequestHandler)
    server.serve_forever()


client_headers = { 
    'User-Agent' : 'Mozilla/5.0 (Windows NT 6.1; Win64; x64)',
    'Content-Type': 'text/plain',
    'Connection': 'keep-alive'
}

client_pool = None

def request_is_ok(number):
    response = client_pool.request('GET', "/test" + str(number), headers=client_headers)
    return response.status == 200 and response.data == b"abcdef"


if __name__ == '__main__':

    # start the server in another process
    server = Process(target=RunHTTPServer)
    server.start()

    # start a connection pool for the clients
    client_pool = HTTPConnectionPool('127.0.0.1', 5674)

    # execute the requests
    with ThreadPool(500) as thread_pool:
        start = clock()

        for i in range(5):
            numbers = [random.randint(0, 99999) for j in range(20000)]
            for j, result in enumerate(thread_pool.imap(request_is_ok, numbers)):
                if j % 1000 == 0:
                    print(i, j, result)

        end = clock()
        print("execution time: %s" % (end-start,))

Обновление 1:

Увеличение request_queue_size просто дает вам больше места для хранения запросов, которые не могут быть выполнены в данный момент, чтобы их можно было выполнить позже. Таким образом, чем длиннее очередь, тем выше дисперсия времени отклика, что, как мне кажется, противоположно вашей цели. Что касается ThreadingMixIn, то он не идеален, поскольку создает и уничтожает поток для каждого запроса и требует больших затрат. Лучший способ уменьшить очередь ожидания — использовать пул многократно используемых потоков для обработки запросов.

Причиной запуска сервера в другом процессе является использование другого процессора для сокращения времени выполнения.

Для клиентской стороны использование HTTPConnectionPool было единственным способом поддерживать постоянный поток запросов, поскольку у меня было какое-то странное поведение с urlopen при анализе соединений.

person Florent B.    schedule 11.04.2016
comment
Я попробовал request_queue_size, что эквивалентно self.socket.listen, предложенному Педро, и, похоже, это решило мою проблему. - person Elias Zamaria; 12.04.2016
comment
Я не знаю, что должен делать http.server.HTTPServer.allow_reuse_address = True. Похоже, что значение по умолчанию для этого равно 1. См. https://hg.python.org/cpython/file/3.5/Lib/http/server.py#l134 - person Elias Zamaria; 12.04.2016
comment
Как упоминалось в редактировании моего вопроса, я попытался запустить сервер в процессе, а не в потоке, и это не помогло. - person Elias Zamaria; 12.04.2016
comment
Я не уверен, что пул потоков стоит того. Я уже использую ThreadingMixIn. Будет ли пул потоков менее вероятно вызывать проблемы? - person Elias Zamaria; 12.04.2016
comment
Я объяснил немного больше о выборе. Кстати, я не смог запустить ваш код в старой конфигурации. Но не верьте мне на слово и попробуйте. - person Florent B.; 12.04.2016
comment
Использование пула потоков — неплохая идея, но вы путаете allow_reuse_address с поддержкой HTTP. allow_reuse_address просто позволяет сокету привязываться к порту, используемому другим сокетом, в течение TIME_WAIT. - person Pedro Werneck; 12.04.2016
comment
Я думал о сохранении жизни, но, похоже, сложно обойтись без сторонних модулей или вещей более низкого уровня, которые я плохо понимаю в http.client. Это для моего собственного опыта обучения, поэтому я готов пожертвовать скоростью и эффективностью в обмен на то, над чем мне будет легче и веселее работать. Но спасибо за предложение. - person Elias Zamaria; 12.04.2016
comment
@Pedro Werneck, спасибо за информацию, я ошибся насчет allow_reuse_address, здесь это не имеет значения. - person Florent B.; 12.04.2016
comment
@Elias Zamaria, я вижу, что вы указываете, и удачи в вашем испытании. - person Florent B.; 12.04.2016

Нормой является использование столько потоков, сколько ядер, следовательно, требуется 8 потоков (включая виртуальные ядра). Поточную модель проще всего заставить работать, но на самом деле это бесполезный способ. Лучшим способом обработки нескольких подключений является использование асинхронного подхода. Хотя это сложнее.

С помощью вашего метода многопоточности вы можете начать с изучения того, остается ли процесс открытым после выхода из программы. Это будет означать, что ваши темы не закрываются и, очевидно, вызовут проблемы.

Попробуй это...

class FancyHTTPServer(ThreadingMixIn, HTTPServer):
    daemon_threads = True

Это обеспечит правильное закрытие ваших потоков. Это вполне может произойти автоматически в пуле потоков, но, вероятно, в любом случае стоит попробовать.

person Cameron    schedule 10.04.2016
comment
Во-первых, вы бы использовали столько потоков, сколько ядер, если задача привязана к процессору, а не к вводу-выводу. Во-вторых, потоки Python выполняются только в одном потоке за раз из-за GIL. - person Pedro Werneck; 11.04.2016