Работаете с сайтом с высокой посещаемостью и думаете, какую потоковую модель вам следует использовать? Эта статья должна помочь.

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

Давайте посмотрим, что происходит внутри: -

// Dummy code(linux based) to represent functionality,Actual apis    may differ
int server = socket();
    bind(server, 80) // binded to a port
    listen(server) // ready to accept connection
 // Creating another socket descriptor/file descriptor, connection refers to socket/file descriptor id
// accept(server) opens another socket which then receives data
    while(int connection = accept(server)) {
      pthread_create(perform, connection) // or choose a thread from threadpool
    }
/* 
*/
    void perform(int connection) {
      char buf[4096];
      while(int size = read(connection, buffer, sizeof buf)) {
        write(connection, buffer, size);
      }

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

В системах Linux все является файлом. Межпроцессное взаимодействие (IPC) осуществляется с помощью сокетов. Сокет - это число (целое число), которое возвращает системный вызов Socket (); он называется дескриптором сокета или дескриптором файла.

Сокеты указывают на объекты в ядре с виртуальным «интерфейсом» (чтение / запись / пул / закрытие / и т. Д.).

Системные сокеты работают как TCP-сокеты: они конвертируют данные в буфер и только потом отправляют.

Таким образом, всякий раз, когда запрос поступает на сетевой порт, сокет соединения выравнивается с запросом и возвращается дескриптор сокета. Итак, мы создаем поток ОС (или выбираем из предопределенного пула потоков), соответствующий дескриптору сокета. обмен для этого конкретного запроса выполняется соответствующим сокетом.

Итак, эта модель хороша до тех пор, пока у нас не будет высокопроизводительной системы транзакций. Создание потока не является проблемой, современный процессор может их обрабатывать, но проблема заключается в количестве памяти, прикрепленной к потоку ОС. Поток ОС в большинстве случаев занимает минимум 1 МБ памяти, и, учитывая тысячи запросов, эту модель сложно масштабировать.

Большинство веб-приложений стремятся к низкой задержке и высокой доступности, поэтому большинство из них на самом деле не выполняет задачи с интенсивным использованием ЦП во время выполнения. Все, что нужно вычислить, хранится в хранилище данных и извлекается по требованию. работы связаны с вводом-выводом.

Предположим, у нас есть 100qps трафика, и ему нужно вызвать api, который отвечает за 2 секунды. Давайте попробуем спроектировать эту систему.

Создание пула потоков, асинхронно, но с блокировкой

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

Давайте рассмотрим фиксированный пул потоков размером, скажем, 1000. Предполагая, что высокие qps системы и время api составляют 2 секунды. Через несколько секунд вы увидите, что все потоки находятся в состоянии ожидания, что приводит к снижению пропускной способности системы.

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

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

Цикл событий, идеальное решение, но так ли это?

Поскольку каждая операция ввода-вывода, независимо от того, открывает ли ее сеть или база данных сокет, который поддерживается дескриптором сокета / файла, можем ли мы использовать API ядра для их отслеживания? Имеет ли ядро ​​некоторую функцию, которая фактически уведомляет, когда какое-либо событие происходит в определенном сокете / fd.

Похоже, что есть. Итак, в Linux есть epoll_wait, который на самом деле ждет, когда произойдет событие в данных файловых дескрипторах. Точно так же во всех других ОС есть открытые API для этой задачи.

int server = ... // like before

    int eventfd = epoll_create1(0);
    struct epoll_event events[10];
    struct epoll_event ev = { .events = EPOLLIN, .data.fd = server };
    epoll_ctl(epollfd, EPOLL_CTL_ADD, server, &ev);

    // This *is* the "event loop", every pass is a "tick"
    while((int max = epoll_wait(eventfd, events, 10, -1))) {
      for(n = 0; n < max; n++) {
        if (events[n].data.fd.fd == server) {
          // Server socket has connection!
          int connection = accept(server);
          ev.events = EPOLLIN; ev.data.fd = connection;
          epoll_ctl(eventfd, EPOLL_CTL_ADD, connection, &ev);
        } else {
          // Connection socket has data!
          char buf[4096];
          int size = read(connection, buffer, sizeof buf);
          write(connection, buffer, size);
        }
     }}

По сути, мы получили сокет, соответствующий порту сервера, который мы связали с сервером, мы добавили его в epoll_wait, поэтому теперь всякий раз, когда в серверном сокете происходит какое-либо событие, мы будем получать событие. Затем, если какое-то событие происходит на сервере socket, это означает, что есть новый запрос на подключение, мы получаем этот сокет, соответствующий новому подключению. Мы добавляем этот сокет в epoll_wait, чтобы мы могли отслеживать и этот сокет. Это делается с помощью epoll_ctl (eventfd, EPOLL_CTL_ADD, connection, & ev) .Если мы получаем событие на сокете, которое отличается от сокета сервера, это означает, что соединение имеет некоторые данные и, соответственно, может быть выполнена обработка. Этот элемент управления передается конкретной языковой реализации.

Эта конкретная базовая настройка используется в программировании на основе событий, которое реализовано в node.js, java vertx, spring flux. Используемая концепция одинакова во всех операционных системах. Есть некоторые события, такие как чтение с диска, разрешение DNS, которые блокируют и должен выполняться в отдельном пуле потоков. Цикл потоков и событий передается через самопроверку.

Итак, все в порядке, в чем проблема.

Убедитесь, что вы не блокируете цикл обработки событий.

Задачи, интенсивно использующие ЦП, не должны выполняться в цикле событий и должны быть частью отдельного пула потоков.

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

Хорошо, хорошо, это выглядит великолепно. Так в чем проблема ? Очень сложно отлаживать проблемы, также очень сложно понять код, так что здесь это своего рода жесткая отладка.

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

Итак, что дальше?

Таким образом, проблема заключалась в том, что этот поток был заблокирован некоторым событием ввода-вывода и оставался в состоянии ожидания до завершения ввода-вывода. Таким образом, до сих пор языки зависели от операционной системы для создания и планирования потоков. Что, если бы мы могли имеют виртуальные потоки (называемые волокном в java) как часть приложения, и когда приложение распознает связанное с вводом-выводом событие (каждое связанное с вводом-выводом событие внутренне открывает сокет) в функции, оно планирует другую задачу в том же потоке. Это может фактически увеличить пропускную способность потока, чтобы он проводил минимальное время в состоянии ожидания.

Когда встречается блокирующий вызов, он фактически приостанавливает выполнение задачи, сохраняет текущее состояние в памяти и планирует другую задачу в том же потоке. А когда ввод-вывод завершен, он возобновляет эту конкретную задачу. / O задача обычно создается сокет, с помощью которого языки могут идентифицировать блокирующий вызов.

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

Такая концепция реализована с помощью сопрограмм. В Kotlin и go он уже назван coroutine и goroutine соответственно.

На приведенной выше диаграмме, как вы можете видеть, есть только один поток ОС и 2 волокна были выполнены на одном и том же. Когда в волокне 1 возникает некоторая задача ввода-вывода, вызывается метод yield, который сохраняет стек, выполненный до сих пор, и волокно 2 планируется в том же потоке. Затем, когда ввод-вывод волокна 1 завершен, он готов к планированию, и любой свободный поток назначается этому волокну.

В Java идет разработка этого материала (Project Loom). Виртуальные потоки известны как волокна в случае Java. Там они стараются лучше всего использовать реализацию в текущем api, чтобы внести минимальные изменения в код. Но у нас есть библиотека, которая может выполнять такие задачи на java (квазар). Quasar использует инструментарий байтового кода. подробности можно найти в его документации.

В будущих версиях я буду углубляться в детали. Надеюсь, это поможет вам решить, какая модель подходит для вашего приложения!