Exploring a possible implementation of non-blocking IO by writing a server on pure syscalls

WILD

Administrator
Staff member
ADMIN
SELLER
SUPREME
MEMBER
Joined
Jan 21, 2025
Messages
219
Reaction score
637
Deposit
0$
Как обычно пишут сервер, если производительность их не особо волнует? Программа запускается, затем начинает принимать входящие соединения от клиентов и запускает новый поток для каждого клиента, который занимается его обслуживанием. Если вы используете фреймворк, например Spring, Flask или Poco, то он делает что-то подобное внутри себя — единственное отличие в том, что потоки могут быть повторно использованы, то есть взяты из определенного пула. Всё это довольно удобно, но не слишком эффективно (и Spring тоже плох). Скорее всего, ваши потоки, обслуживающие клиентов, живут недолго и большую часть времени ждут либо получения данных от клиента, либо их отправки клиенту — то есть, ждут возврата каких-то системных вызовов. Создание потока ОС — довольно ресурсоемкая операция, как и переключение контекста между потоками ОС. Если вы хотите эффективно обслуживать большое количество клиентов, вам нужно придумать что-то другое. Например, коллбэки, но они довольно неудобны (хотя на этот счет существуют разные мнения).

Другой вариант — использование неблокирующего ввода-вывода в сочетании с какой-либо реализацией потоков пользовательского пространства (фибр). В этой статье я покажу вам, как всё это написать своими руками.

Весь код находится в моём репозитории . Там три ветки: good-old-one-thread-impl содержит оригинальную реализацию, hand-context — версию с ручным переключением контекста и реализацией локальных переменных, а две другие ветки содержат попытку заставить эту реализацию работать в нескольких потоках операционной системы. Весь код представлен только в качестве концептуального доказательства и содержит ошибки.

Несколько вводных пояснений.​

Что такое потоки пользовательского пространства? Это потоки, в переключении которых операционная система не участвует и о которых она ничего не знает. Все они могут работать в одном потоке ОС или в нескольких (как горутины в Go, виртуальные потоки в Java 19). Эти потоки реализуют идею кооперативной многозадачности: поток может быть переключен только по его запросу (точнее, Go уже не является по-настоящему кооперативным языком, но статья не об этом). В нашем случае поток будет выведен из выполнения, когда он ожидает какого-либо ввода или вывода — пока он ожидает этого, будут выполняться другие потоки.

Как устроен неблокирующий интерфейс ввода-вывода в Linux? Обычно для ввода и вывода используются системные вызовы чтения и записи. Существуют и другие, но мы рассмотрим эти, поскольку они наиболее распространены: их можно использовать для работы с файлами, сетью, каналами, для чтения сигналов (см. signalfd). Обычно такие системные вызовы блокируются до тех пор, пока данные не будут прочитаны или записаны, но можно настроить файловый дескриптор на неблокирующий режим — тогда системные вызовы чтения и записи не будут блокироваться, и если данные не могут быть прочитаны или записаны, возвращается ошибка EAGAIN. Остается как-то научиться понимать, какие файловые дескрипторы готовы к вводу-выводу, чтобы не пробовать их вслепую.

В Linux существует несколько подобных механизмов: select(2), poll(2), epoll(7). Каждый из них предоставляет возможность сообщить ядру ОС, какие файловые дескрипторы нас интересуют, и ядро сообщит нам, какие из них готовы к вводу-выводу. select(2) устарел, и я буду использовать poll(2). epoll(7) — более эффективное решение, но менять его уже поздно. В любом случае, poll(2) по-прежнему будет хорош в качестве способа представления.

Внедрение волокон​

Сначала мы будем использовать boost::context для реализации волокон, но затем от него откажемся. Библиотека boost::context позволяет переключать контексты. Остаётся написать некоторый оберточный код для контекста и простой планировщик потоков.

Контекст выполнения (представленный boost::context) — это состояние определенного выполнения, то есть значения регистров и стека.

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

FiberImpl

Наиболее интересный момент — это способ запуска фибера. При запуске фибера необходимо вызвать функцию `calcc` из `boost::context`, которая принимает лямбда-функцию и возвращает контекст, в котором выполняется переданная функция (функция `calcc` возвращает значение, когда лямбда-функция хочет переключиться в другой контекст). Лямбда-функция должна принимать контекст, из которого она была вызвана — с его помощью она может вернуться к нему.

Когда необходимо повторно запустить выполнение потока, достаточно выполнить метод resume контекста — он запустит выполнение этого контекста, а при желании вернуться к исходному состоянию функция вернет новое состояние этого контекста.

начало производства волокна

Реализация других методов FiberImpl в большинстве случаев тривиальна.

тривиальная реализация

Менеджер волоконно-оптических сетей должен иметь возможность хранить список волокон, готовых к выполнению, и участвовать в запуске этих волокон.

менеджер волокон


Класс FibberImpl не очень удобен в использовании: необходимо создать shared_ptr, функция должна быть типа void(void), а также нужно вызвать .start(). Давайте напишем простую обертку над ним.

обер
Осталось написать несколько примитивов синхронизации.

переменная условия


Второй момент, на который следует обратить внимание, заключается в том, что обычно ожидание срабатывания переменной условия осуществляется путем захвата мьютекса, после чего переменная условия освобождает его и снова захватывает. Однако в условиях кооперативной многозадачности нам вообще не нужен мьютекс. Кроме того, по сути, системный вызов futex, с помощью которого реализован мьютекс, является всего лишь своего рода переменной условия. Также, в отличие от std::condition_variable, здесь не может быть никаких ложных пробуждений.

Неблокирующий ввод-вывод​

Теперь нам нужно написать реализацию ожидания готовности файловых дескрипторов к вводу-выводу. Будет отдельный обработчик событий, который будет выполнять системный вызов poll для получения информации о готовности файлового дескриптора.

Поток, который ожидает готовности файлового дескриптора, создаст переменную условия, сохранит свой запрос на ожидание ввода-вывода и перейдет в спящий режим при достижении этой переменной условия.

выполнение


Написание сервера​

Теперь мы можем написать сервер. Он будет работать следующим образом: при запуске будет запущен фибер, который создаст сокет, свяжет его с портом и начнет принимать соединения от клиентов. При этом, перед вызовом функции accept для приема соединений, следует использовать Waiter для ожидания входящих соединений.

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

выполнение

Мы запускаем его — и он работает! Используется всего один поток излучателей систем, но мы обслуживаем множество клиентов параллельно и пишем код для увеличения потоков.

Многопоточность​

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

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

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

Избавление от boost::context​

Теперь пришло время научиться делать всё своими руками и избавиться от boost::context. Для этого нам нужно создать структуру, в которой мы будем хранить контекст, написать код для его создания и переключения между контекстами.

Локальные переменные волокна​

Изначально код сервера мог работать только под Linux. После отказа от Boost мы ограничились архитектурой x86/64. Теперь мы ограничим себя ещё больше — для работы кода потребуется процессор, поддерживающий инструкции fsgsbase, и достаточно новая версия Linux.

Я еще не до конца разобрался в работе локальных переменных потока, и приношу за это свои извинения.


Как работают локальные переменные потока? Я уже писал об этом немного подробнее в другой статье, которая также содержит код для добавления поддержки локальных переменных потока в образовательную ОС. Сейчас я просто скажу, что по умолчанию компилятор обращается к локальным переменным потока по некоторому смещению относительно значения сегментного регистра %fs. Соответственно, для их работы код, запускающий поток, должен выделить некоторую память для локального хранения данных потока, инициализировать её и записать адрес этой памяти в %fs.

Однако в первом приближении ничто не мешает нам сделать то же самое при запуске потока, чтобы все переменные, объявленные как thread_local, работали как локальные для потока.

На самом деле, в прошлом пользовательские программы не могли записывать данные в %fs напрямую — это могло делать только ядро, и для этого пользовательской программе приходилось вызывать системный метод. Затем в процессорах появились инструкции rdfsbase, wrfsbase, rdgsbase и wrfsbase, но для их работы ядро операционной системы должно было явно разрешать их использование, поэтому для их применения требовалось достаточно современное ядро.

Итак, нам нужно:

  1. Добавьте значение %fs в класс Context.
  2. Сохранение и восстановление %fs при переключении контекста в функции switch_context
  3. Прочитайте ELF-файл исполняемого файла, чтобы узнать размер TLS и параметры его инициализации.
  4. При создании контекста выделите память для TLS, инициализируйте её и запишите в %fs адрес этой памяти.
Следует, однако, отметить, что переменные thread_local используются не только нашим кодом, но и libstdc, поэтому нам необходимо детально разобраться, как поддерживать переменные fiber_local, чтобы они работали и в библиотеках, с которыми связан наш бинарный файл. Или было бы неплохо, если бы наши переменные fiber_local объявлялись не как thread_local, а как fiber_local, и компилятор использовал бы %gs вместо %fs для них — потому что переменные из libstdc вполне могут быть локальными для потока, а не для волокна.

Если вы внимательно выполните описанные выше шаги (или запустите код из моего репозитория после его корректной сборки), локальные переменные в Fiber будут работать. Разве это не чудо?
 
Top Bottom