вторник, 24 января 2017 г.

Простой веб-сервер на С++ - вид изнутри

Я вновь возвращаюсь к уже написанному коду и причина в этом следующая, как уже отметил одни мой хороший знакомый, ну написал ты веб сервер и что. Поэтому в этой статье я подробно опишу то как я пришёл к текущей реализации веб сервера, что послужило для меня поводом тех или иных решений, а также много разного попутного материала.
Как уже отмечалось в прошлой статье мой веб сервер базируется на достаточно просто для данного класса программ коде, однако уже содержит компоненты серьёзных систем - такие как зависимая очередь задач, потоки исполнения у каждого из которых есть своя очередь и набор асинхронных команд которые собраны в едином модуле - function_wrapper.h. Итак начнём.

Небольшая предистория:
Было это в начале декабря, выполнял я задания на stepic.org по курсу от mail.ru - многопоточное программирование на С/С++, где в качестве итогового задания слушил многопоточный веб сервер статики с поддержкой HTTP/1.0, тип файлов text/html. Поэтому я по привычке написал наивную реализацию с использование epoll и std::thread с которой я и прошёл итоговый тест.
В целом реализация была рабочей, хоть и результируящая выдача её не превышала 13 - 14 тысяч запросов в секунду(Все показатели если не указано иное идут для вкомпилированной в код сервера страницы). Да и многопоточность в ней была по сути фейковая - мы создавали поток на каждый сокет пользователя. То есть если бы на нас обрушилось более 1000 запросов в секунду система скорее бы всего замерла на создании потоков. Да и разбор сообщения был написан с использованием класса строки std::string из стандартной библиотеки, что никуда не годилось, но об этом позднее. А пока у нас относительно неправильная реализация многопоточности которая уступает даже своему однопоточному варианту.

Больше не значит лучше:
Поэтому первая мысль была о том каким образом оптимизировать производительность, но для этого необходимо было разбить проект на составные части - итог первой оптимизации. Когда проект не монолитный очень легко понять в каких частях происходит как сбой, так и утрата машинного времени. Поэтому не забывайте о том что грамотное разделение проекта на модули и подсистемы поможет вам в будущем. Да и разрабатывать функции с 10 - 50 строками кода гораздо проще чем со 100, 200 и тем более 500 строками кода.
После полного рефакторинга основного проекта было решено заменить создание потоков на соединение на заранее созданный пул потоков которые бы синхронизировались через очередь сообщений. Это позволило немного увеличить производительность, попутно сместив узкое место с создания потока (эта кстати самая трудозатратная операция с ОС Linux) в простой его в очереди, что опять же вызвало некоторое ожидание. 
Попутно с этим была изменена обработка сообщений, были убраны контейнеры std::string и std::iostream, как наиболее тяжёлые элементы системы, на которых терялось очень много производительности. Это позволило увеличить производительность ещё на пару тысяч запросов в секунду, в целом. Но мы пока не достигли и однопоточного приложения.

Разделение должно быть не только в коде но и в данных:
Первая здравая мысль после чтения одной из глав книги Concurrency in Action, была о том что очередь теперь самое узкое место в нашей системе, поэтому мы сделаем её максимально разделяемой. Наличие двух узлов блокировки позволило нам немного ускорить общий темп работы, но не более. Поэтому дальнейшие пути были подбор количества рабочих потоков, разбор системы на компоненты и уменьшение операций копирования.

Абстрагирование и системная иерархия тоже являются основной деталью в производительности:
Следующий шаг был в выделение пула потоков в отдельную сущность. Это позволило сделать ещё немного шагов (1.5 - 2 тысячи запросов) в сторону более производительного приложения.
Также был ряд шагов по оптимизации перемещения данных и создания объектов. Итого это позволило достичь в отдельные моменты производительности в 22 - 23 тысячи запросов в секунду что весьма неплохо для самописного кода. Также подобное позволило безопасно изолировать выполнение кода обработчика сообщений и завершение системы в случае непредвиденных ошибок.

Меняем тип системы сообщений:
Так как мы используем относительно новую версию ядра значит можно переложить с каким-то успехом часть работы на него, создав несколько объектов epoll, по одному на каждый поток, что собственно и было сделано - появилась версия в которой система ожидания сменилась на систему движимую сообщениями. То есть у нас каждый поток мог независимо создавать клиентский сокет и добавлять его в свою очередь для дальнейшего обслуживания. Теперь критическая часть перенеслась целиком в пространство ядра и легла на мутексы в очередях сообщений. В итоге мы получили более гибкую систему что позволило установить среднее значение производительности с 18 тысяч до 21 тысячи запросов в секунду. Максимальная в целом так и оставалась на 23 тысячах запросов.

Переносим блокировки в пространство пользователя:
Так как переключение контекста из пространства пользователя в пространство ядра и обратно, а также ожидание потоков в очереди выполнения. Поэтому я заменил в очереди потока мьютекс на спинлок аналог с применением атомарного флага. Это, а также разделение типа ожидания его разблокировки на пробное, позволило увеличить производительность, и снизить простой потоков. Далее было изменено ожидание в цикле на однократное. Что несомненно сказалось на динамику системы в целом. Нельзя не отметить то что также был изменён способ преобразования размера сообщения из числа в строку - более быстрым сопособом, который позднее был исправлен.

Меньше действий в ядре - быстрее код:
Этот принцип работает для любого из типов кода, включая код драйверов! Поэтому я решил сократить количество операция записи до одной, храня промежуточный результат в буфере динамической памяти. Заодно был изменён тип обработчика сообщений с функции на объект, что позволило развязать ещё более его в создании внутри пула сообщений. Заодно после подобных операций всплыло новое узкое место системы - большое количество операций с памятью.

Меньше создания временных объектов и объектов в динамической памяти - меньше проблем с непостоянной производительностью:
Следующий шаг в сторону более производительной системы стал перенос объекта обработки сообщения в статическую память потока. Так как обработка объекта принадлежит каждому потоку то соответственно пусть из его памяти он и берётся. Таким образом мы сократили выделение памяти под создание входного буфера. А что же с выходным - а выходной буфер мы создаём небольшим в начале, и увеличиваем по мере надобности. Заодно была добавлена опция разбора и подстановки типа контента. В последствии было изменено выделение памяти под путь до файла. Что добавило ещё немного производительности (примерно до 2 тысяч в пике).

Ненужный код порождает ненужные проблемы:
И итоговым на момент написания статьи стал рефакторинг неиспользуемого кода в очереди потоков, что позволило сменить тип мьютекса на спинлок. Также немного поправил число соединений в пуле и время их ожидания. На этом мы достигли некоторого предела производительности что в отладочном режиме даёт в среднем 24 тысячи запросов в секунду для предкомпиленного файла и 16 тысяч запросов для файла находящегося на диске:

Document Path:          /
Document Length:        101 bytes

Concurrency Level:      100
Time taken for tests:   4.027 seconds
Complete requests:      100000
Failed requests:        0
Total transferred:      18600000 bytes
HTML transferred:       10100000 bytes
Requests per second:    24832.01 [#/sec] (mean)
Time per request:       4.027 [ms] (mean)
Time per request:       0.040 [ms] (mean, across all concurrent requests)
Transfer rate:          4510.50 [Kbytes/sec] received
Document Path:          /index.html
Document Length:        28914 bytes

Concurrency Level:      100
Time taken for tests:   6.198 seconds
Complete requests:      100000
Failed requests:        0
Total transferred:      2900100000 bytes
HTML transferred:       2891400000 bytes
Requests per second:    16133.59 [#/sec] (mean)
Time per request:       6.198 [ms] (mean)
Time per request:       0.062 [ms] (mean, across all concurrent requests)
Transfer rate:          456923.96 [Kbytes/sec] received

Для рабочей версии производительности 25 тысяч и 19 тысяч соответственно. 

Document Path:          /
Document Length:        101 bytes

Concurrency Level:      100
Time taken for tests:   3.970 seconds
Complete requests:      100000
Failed requests:        0
Total transferred:      18600000 bytes
HTML transferred:       10100000 bytes
Requests per second:    25188.27 [#/sec] (mean)
Time per request:       3.970 [ms] (mean)
Time per request:       0.040 [ms] (mean, across all concurrent requests)
Transfer rate:          4575.21 [Kbytes/sec] received
Document Path:          /index.html
Document Length:        28914 bytes

Concurrency Level:      100
Time taken for tests:   5.252 seconds
Complete requests:      100000
Failed requests:        0
Total transferred:      2900100000 bytes
HTML transferred:       2891400000 bytes
Requests per second:    19040.36 [#/sec] (mean)
Time per request:       5.252 [ms] (mean)
Time per request:       0.053 [ms] (mean, across all concurrent requests)
Transfer rate:          539247.49 [Kbytes/sec] received

С учётом того что теперь почти не создаётся временных объектов (за исключением объектов задачи в очередях рабочих потоков), то это вполне себе отличная производительность, для данной системы.

Вместо эпилога:
Вот мы кратко пробежались по истории этого проекта, упомянув в кратце то с чем удалось столкнуться мне в ходе разработке этого примера выросшего в простейший веб сервер для статики. Рабочий код не является идеальным но и не запутан и вполне походит в качестве обучающего. Огромное спасибо Владимиру Смирнову за тематическую помощь и советы по оптимизации сего творения.
Надеюсь мой материал по теме был полезен читателю.