В предыдущем посте я представил введение в gevent, чтобы продемонстрировать некоторые преимущества, которые ваше приложение может получить, используя gevent greenlets вместо потоков. Однако некоторые люди не согласились с моим кодом для тестирования, заявив, что многопоточный пример был придуман. В этом посте я постараюсь ответить на некоторые возражения.
(На самом деле оказывается, что в версии ab, которую я использовал для тестирования, была ошибка, поэтому я перезапустил тесты из предыдущего поста.)
Темы против Гринлетс
Изначально я предложил фиктивный веб-сервер, который обрабатывал входящие запросы, создавая поток и передавая связь этому потоку. Код под вопросом ниже:
def threads(port): s = socket.socket() s.bind(('0.0.0.0', port)) s.listen(500) while True: cli, addr = s.accept() t = threading.Thread(target=handle_request, args=(cli, time.sleep)) t.daemon = True t.start()
Когда я смог получить приведенный выше код, фактически не выполняя полный тест (что не часто происходило), он получал около 1300-1400 запросов в секунду. Версия Gevent выглядела очень похоже:
import gevent def greenlet(port): from gevent import socket s = socket.socket() s.bind(('0.0.0.0', port)) s.listen(500) while True: cli, addr = s.accept() gevent.spawn(handle_request, cli, gevent.sleep)
Этот код был способен обрабатывать около 1600 запросов в секунду. Возможно, мне следовало бы назвать это лучше, но тот факт, что версия gevent работала лучше, чем многопоточная версия, указывает на важный аспект gevent:
Гринлеты значительно легче, чем настоящие нити, особенно при их создании.
Тем не менее, люди возразили, указав, что вы просто не делаете этого с потоками. Никто не делает. Это глупый способ спроектировать сервер. Я согласен со всеми этими пунктами, хотя на самом деле это было не то, к чему я стремился. Я укажу на одну вещь:
Причина, по которой вы не проектируете многопоточные серверы, поэтому они разветвляют поток при каждом подключении, заключается в том, что потоки разветвляются дорого , в отличие от гринлетов.
Исправление теста
Так или иначе, чтобы «исправить» тест, чтобы он был немного более справедливым по отношению к потокам, мы будем использовать пул потоков, чтобы создать все потоки заранее, а затем использовать Queue.Queue для отправки работы им. Наше ядро сервера теперь выглядит так:
def threads(port, N=10): s = socket.socket() s.bind(('0.0.0.0', port)) s.listen(500) q = Queue() for x in xrange(N): t = threading.Thread(target=thread_worker, args=(q,)) t.daemon = True t.start() print 'Ready and waiting with %d threads on port %d' % ( N, port) while True: cli, addr = s.accept() q.put(cli) def thread_worker(q): while True: sock = q.get() handle_request(sock, time.sleep)
Если я теперь запусту это с пулом потоков из 200 потоков, я действительно смогу завершить тест (ApacheBench как ab -r -n 2000 -c 200 … примерно с 1300 запросами в секунду (чуть меньше, вероятно, из-за синхронизации). издержки очереди). Поэтому обновление эталонного теста для использования пула потоков не улучшило производительность . Эквивалентный код gevent использует gevent.pool.Pool:
def greenlet(port, N=10): from gevent.pool import Pool from gevent import socket, sleep pool = Pool(N) s = socket.socket() s.bind(('0.0.0.0', port)) s.listen(500) while True: cli, addr = s.accept() pool.spawn(handle_request, cli, sleep)
Запустив ab с такими же параметрами, я получаю … около 1200-1400 запросов в секунду.
Так зачем снова использовать gevent?
Так что да, если бы я разработал тест для полного исключения создания потоков / гринлетов, потоки и гринлеты действительно работают примерно одинаково. Большой выигрыш для гринлетов — это когда ваш пул потоков недостаточно велик для обработки одновременных соединений.
Оказывается, существует хитрая атака типа «отказ в обслуживании» на веб-серверы, называемая slowloris, которая быстро потребляет потоки из вашего пула потоков. Когда все потоки вашего сервера заняты обработкой медленных запросов, дальнейшая работа невозможна, и в результате вы получаете очень легко загруженный, но все еще не отвечающий сервер.
Чтобы проиллюстрировать это, мы можем попробовать запустить наш эталонный тест с пулом потоков, но запустить только 20 потоков в пуле, но изменив наш обработчик запросов на пять секунд для обработки запроса. Мы продолжим и изменим строку теста, чтобы дать больше времени для ответов:
$ ab -n 2000 -c 200 -r -t 60 http://127.0.0.1:...
Теперь наш многопоточный пример завершает тайм-аут соединений, поскольку он пытается обслуживать 200 одновременных соединений, каждое из которых занимает пять секунд, всего с 20 рабочими потоками. Однако, если мы вернемся к нашему наивному (не объединенному в пул) примеру gevent, мы сможем выполнить 47 запросов в секунду, что близко к теоретическому максимуму в 50 запросов в секунду при очень небольшой нагрузке на сервер.
Точка? Медленная атака сможет поглотить все потоки в вашем (конечном размере) пуле потоков, независимо от того, насколько велик этот пул. Появление гринлета каждый раз, когда вы получаете соединение, означает, что вы не тратите (почти) ресурсы, ожидающие ввода-вывода.
Вывод
Есть еще кое-что, о чем я хотел бы рассказать в следующих публикациях, но сейчас я хотел бы остановиться на следующих моментах:
- Вы не должны создавать что-то дорогое, как поток для каждого входящего соединения. Он съедает различные типы серверных ресурсов.
- Вы не должны полагаться на пулы потоков для защиты от истощения ресурсов, поскольку они могут стать жертвами медленной атаки.
- Гринлеты Gevent достаточно легки, чтобы вы могли создавать по одному для каждого соединения, и вам не нужно полагаться на пул (который может исчерпаться при атаке типа slowlois).
Так что ты думаешь? Я тебя убедил? Я хотел бы услышать вашу реакцию в комментариях ниже!