Введение

Совсем недавно, 25 января 2022 года вышел новый релиз Nginx - 1.21.6, в котором исправлена проблема неравномерного распределения входящих соединений между несколькими worker процессами в дефолтной конфигурации на Linux системах. Если конкретнее - use epoll, accept_mutex off, reuseport выключен.

В данной конфигурации при определенном характере нагрузки большинство входящих в Nginx соединений обрабатывается лишь одним worker процессом. 

Насколько я понимаю, эта проблема существует уже более пяти лет и берет начало в версии 1.11.3 (Jul 2016), когда в Nginx по умолчанию отключили accept_mutex, а вместо него стали полагаться на флаг EPOLLEXCLUSIVE, появившийся в ядре Linux 4.5.

Стоит заметить, что проблема балансировки входящих соединений при использовании механизма epoll и флага EPOLLEXCLUSIVE более глобальна и касается не только Nginx, а любых сетевых приложений, работающих по аналогичной схеме.

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

Появление EPOLLEXCLUSIVE

Начать стоит с 2015 года, когда Jason Baron предложил патч ядра Linux, в котором добавлялась поддержка двух новых флагов - EPOLLEXCLUSIVE и EPOLLROUNDROBIN для системного вызова epoll_ctl.

Первый из них предназначался для решения так называемой Thundering herd problem - ситуации, когда при наступлении события, которого ожидало множество процессов на одном и том же файловом дескрипторе, система пробуждала их все, но в итоге, обработать событие мог лишь один из этих процессов. Подобная ситуация характерна для случая, когда множество worker процессов заблокированы на одном и том же listening сокете в ожидании входящих соединений, используя вызов epoll_wait. При поступлении нового соединения возникает событие EPOLLIN и операционная система пробуждает все ожидающие worker процессы, однако, принять соединение вызовом accept сможет лишь один из них, остальные же при попытке вызова accept получат ошибку EAGAIN. На серверах это приводило к созданию лишней нагрузки и пустой трате ресурсов. 

Введение нового флага EPOLLEXCLUSIVE позволяло решить эту проблему - из множества процессов с данным флагом, ожидающих события на общем дескрипторе, при его наступлении разблокирован должен быть лишь один из процессов. Исходя из реализации, этим процессом был первый процесс, добавивший дескриптор функцией epoll_ctl с флагом EPOLLEXCLUSIVE, и ожидающий события в epoll_wait. 

Но это влекло за собой другую проблему - если после пробуждения, процесс достаточно быстро обрабатывал событие и вновь возвращался в очередь ожидания вызовом epoll_wait, то при возникновении следующего аналогичного события для его обработки ядро разблокирует тот же самый процесс. Данное поведение по своей сути не является ошибочным, а как писали в комментариях к патчу - в общем случае является желаемым и способствует cache locality процессоров, так как обработкой будет заниматься уже "прогретый" процесс. Однако, для определенных программ, таких как Nginx, это создает неравномерное распределение входящих соединений между worker процессами, так как большинство соединений будут обрабатываться одним и тем же процессом.

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

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

В конечном итоге, патч с флагом EPOLLEXCLUSIVE был включен в ядро 4.5, а затем использован в Nginx начиная с версии 1.11.3, в которой по умолчанию был отключен accept_mutex служивший для выполнения аналогичной задачи (в каждый конкретный момент принимать соединения на общем сокете мог лишь один из воркеров - первый, кому удалось захватить мьютекс). После принятия соединения, воркер отпускал мьютекс и он мог быть захвачен уже другим процессом, таким образом осуществлялась балансирока запросов между worker процессами:

Changes with nginx 1.11.3                                        26 Jul 2016

    *) Change: now the "accept_mutex" directive is turned off by default.

    *) Feature: now nginx uses EPOLLEXCLUSIVE on Linux.

Как можно прочитать в документации

There is no need to enable accept_mutex on systems that support the EPOLLEXCLUSIVE flag (1.11.3) or when using reuseport.

О проблеме, возникшей после этих изменений уже упоминалось на хабре в переводе статьи сотрудника Cloudflare Почему один процесс NGINX берёт на себя всю работу?

Хочу заметить, что автор не совсем верно указал механизм, лежащий в основе данного поведения:

В случае epoll-and-accept алгоритм другой: Linux, кажется, выбирает процесс, который был добавлен в очередь ожидания новых соединений последним, т.е. LIFO.

Насколько я понял из чтения исходного кода патча и epoll, а также комментария в новом релизе Nginx, выбирается как раз первый из процессов, добавивших дескриптор вызовом epoll_ctl, и который в данный момент ожидает события в epoll_wait.

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

Fix

В новом релизе Nginx данный баг наконец-то исправили:

Changes with nginx 1.21.6                                        25 Jan 2022

    *) Bugfix: when using EPOLLEXCLUSIVE on Linux client connections were
       unevenly distributed among worker processes.

Ниже приведен код новой функции для обеспечения балансировки входящих соединений между worker процессами:

static void
ngx_reorder_accept_events(ngx_listening_t *ls)
{
    ngx_connection_t  *c;

    /*
     * Linux with EPOLLEXCLUSIVE usually notifies only the process which
     * was first to add the listening socket to the epoll instance.  As
     * a result most of the connections are handled by the first worker
     * process.  To fix this, we re-add the socket periodically, so other
     * workers will get a chance to accept connections.
     */

    if (!ngx_use_exclusive_accept) {
        return;
    }

#if (NGX_HAVE_REUSEPORT)

    if (ls->reuseport) {
        return;
    }

#endif

    c = ls->connection;

    if (c->requests++ % 16 != 0
        && ngx_accept_disabled <= 0)
    {
        return;
    }

    if (ngx_del_event(c->read, NGX_READ_EVENT, NGX_DISABLE_EVENT)
        == NGX_ERROR)
    {
        return;
    }

    if (ngx_add_event(c->read, NGX_READ_EVENT, NGX_EXCLUSIVE_EVENT)
        == NGX_ERROR)
    {
        return;
    }
}

Как можно видеть, ребалансировка осуществляется путем удаления и повторного добавления отслеживания событий на listening сокете в worker процессах после каждых 16 принятых соединений. В результате исчезает ситуация, когда при поступлении новых соединений, система постоянно пробуждает один и тот же (первый добавленный) worker процесс, и теперь входящие соединения действительно распределяются достаточно равномерно. 

Вобщем, если кто-то ранее уже сталкивался с данной проблемой и еще не в курсе, то похоже пришло время обновиться. 

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

Комментарии (23)


  1. INSTE
    06.02.2022 12:20

    Скорее всего разработчики конечно сделали профилировку, но вообще интуиция говорит что переустановка события каждые 16 коннектов — это очень часто. Да, качество «размывания» это улучшит, но я бы поставил хотя бы 64 на более-менее нагруженный сервер.


  1. DistortNeo
    06.02.2022 12:21
    +1

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


    1. VBart
      06.02.2022 20:59
      +1

      Лимиты и `worker_connections` то можно подкрутить. Но тогда зачем вообще другие рабочие процессы нужны, если все один обрабатывает.

      Как раз основная проблема - это неравномерное распределение нагрузки по рабочим процессам и, как следствие, ухудшение масштабируемости по ядрам.


  1. edo1h
    06.02.2022 14:14
    +1

    Если конкретнее — use epoll, accept_mutex off, reuseport выключен.

    а есть причины, по которым стоит выключать reuseport?


    1. VBart
      06.02.2022 21:20
      +1

      Как минимум потому, что у reuseport есть своя проблема с распределением нагрузки по рабочим процессам. Там соединения раскидываются просто в режими round-robin и это неплохо работает, когда все соединения абсолютно одинаковы и дают одинаковую нагрузку. В реальности же часто бывает так, что соединения и запросы пользователей в них могут быть сильно разными. Могут быть соединения в которых вообще нет никаких запросов - их браузер может открывать "про запас", а могут быть тяжелые соединения, создающие большую нагрузку на рабочий процесс. Одни соединения долгоживущие, а другие короткоживущие. В итоге процесс, которому попались тяжелые долгоживущие соединения - будет перегружен, а те, в которых больше легких и короткоживущих - недогружены.

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


      1. VBart
        06.02.2022 21:57
        +3

        Erratum: коллеги поправили, там не round-robin, а хэш от ip и порта сервера и клиента. Что ещё хуже, т.к. теоретически можно попытаться умышленно обмануть механизм и упростить DoS-атаку, если всё это смотрит в интернет.


        1. edo1h
          06.02.2022 23:18

          хэш от ip и порта сервера и клиента

          то есть если nginx стоит за прокси, то вся нагрузка ляжет на один воркер?


          1. VBart
            06.02.2022 23:27
            +2

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


            1. edo1h
              06.02.2022 23:46

              упс, я ерунду написал, конечно )


  1. Mingun
    06.02.2022 15:35
    +2

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


    1. VBart
      06.02.2022 21:03
      +1

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


      1. Mingun
        06.02.2022 22:43
        +1

        Погодите… что значит "перегружены"? Процесс же успевает справляться с работой, тогда о какой перегрузке идет речь? В моем понимании "перегрузка" — это когда вся работа наваливается на один процесс, он ее выполнять не успевает, а соседние процессы эту работу не берут, но ведь такое невозможно. Если загруженный процесс не может взять работу, ее возьмет следующий в очереди, просто потому, что в этот момент он будет первый свободный в ней.


        Я могу гипотетически предположить, что есть такая работа, когда Задача 1 что-то загрузила в кеш, затем Задача 2 загрузила что-то свое в кеш, что привело в вытеснению данных от Задачи 1, а затем Задача 3 потребовала в кеше то же самое, что Задача 1. Тогда да, имеет смысл, чтобы на первом ядре выполнялись только задачи 1 и 3, а задача 2 — на втором. Но автоматически это же не сделается, это само приложение должно сказать о таком паттерне работы.


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


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


        1. VBart
          06.02.2022 23:11
          +5

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

          Попробую объяснить максимально просто. Представьте, что у вас есть 12 соединений, на которых случились события. Предположим, что все эти соединения оказались в одном процессе. Значит этот процесс будет обрабатывать эти события следующим образом: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 последовательно. В результате наибольшая задержка будет при обработке 12-го события и она будет равна суммарному времени затраченному на обработку всех предыдущих 11 событий.

          А теперь представьте, что у нас есть 4 ядра и запущено 4 рабочих процесса и соединения были распределены равномерно по этим процессам (по 3 соединения на процесс).

          Тогда для каждого из процессов обработка всех событий будет выглядеть так:
          CPU1: 1, 2, 3.
          CPU2: 1, 2, 3.
          CPU3: 1, 2, 3.
          CPU4: 1, 2, 3.

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

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


          1. edo1h
            06.02.2022 23:53

            Предположим, что все эти соединения оказались в одном процессе

            так изначальный посыл Mingun, как я его понял, был в том, что по мере увеличения нагрузки на воркер (и увеличения задержек на нём) его шансы «подхватить» новое соединение падают.


            1. VBart
              07.02.2022 00:08
              +1

              К моменту, когда эти шансы упадут заметно - нагрузка на воркер и задержки также возрастут заметно. А именно этого хотелось бы избежать.


              1. jerry_ru
                07.02.2022 07:16

                Я ещё чуть больше учтною вопрос. Воркер нгинкса (уже внутри себя) тоже использует конкурентность? Тоесть сначала через еполл натягивает себе запросов, а потом одновременно их "крутит" ?


                1. VBart
                  07.02.2022 10:59

                  Один рабочий процесс имеет один поток. Поэтому в один момент времени он может из очереди брать и обрабатывать только одно событие. А сколько событий будет в очереди - зависит от того, сколько вернуло их ядро в одном вызове epoll_wait(). По умолчанию один вызов может вернуть до 512 событий.


                  1. DistortNeo
                    07.02.2022 12:03

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


          1. Mingun
            07.02.2022 07:32
            +1

            Предположим, что все эти соединения оказались в одном процессе.

            Если они оказались в одном процессе, это значит, что соединение 2 пришло после того, как соединение 1 обработалось. Т.е. что в одном процессе, что в разных никакой параллельности не будет, так как сами соединения приходят последовательно, а не одновременно — параллельно обрабатывать просто нечего.


            Вот если бы все 12 соединений пришли одновременно и все почему-то оказались в первом процессе… но с чего бы? Первый процесс заберет первое соединение и пойдет его обрабатывать, второе заберет второй и так далее.


            1. nick1612 Автор
              07.02.2022 08:40
              +2

              Мне кажется, что недоразумение возникает из-за того, что Вы путаете понятия "принял соединение" и "обработал запрос". Воркер может принять соединение и опять уйти в epoll_wait, если в данный момент по этому соединению еще не пришли все данные от клиента или от бекенда ( если он проксирует запрос) и продолжать принимать новые соединения. По ходу обработки одного запроса, воркер может множество раз уходить в ожидание и принимать новые соединения. Но все принятые соединения продолжают на нем висеть, и когда на них появятся данные, воркеру придется их все обрабатывать. Более того, после обработки HTTP запроса, соединение продолжит висеть на воркере в keepalive и соответственно последующие запросы по этому соединению также будут поступать к нему.


            1. VBart
              07.02.2022 11:13
              +2

              Если они оказались в одном процессе, это значит, что соединение 2 пришло после того, как соединение 1 обработалось. Т.е. что в одном процессе, что в разных никакой параллельности не будет, так как сами соединения приходят последовательно, а не одновременно — параллельно обрабатывать просто нечего.

              Обработка одного соединения - это не одно событие. Первое событие - лишь о том, что у нас вообще есть новое соединение. Рабочий процесс получает уведомление об этом событии, делает на соединении accept(), добавляет дескриптор нового соединения в ядро для мониторинга и далее может ждать новых событий. С этого момента все события на этом соединении будут обрабатываться только в данном рабочем процессе. А событий таких может быть тысячи: получены новые данные - событие, освободился буфер отправки - событие, случилась ошибка - событие, закрыли соединение - событие.

              Более того. Ядро одновременно сообщает о множестве событий. Т.е. одним вызовом рабочий процесс может получить сразу сотни новых событий.

              Так работают асинхронные сервера. Один рабочий процесс nginx может обрабатывать события на миллионах соединений.

              Вот если бы все 12 соединений пришли одновременно и все почему-то оказались в первом процессе… но с чего бы? Первый процесс заберет первое соединение и пойдет его обрабатывать, второе заберет второй и так далее.

              Первый процесс может забрать все 12 соединений. Обработка соединения сводится к вызову accept(). Я даже не уверен, что ядро вообще разбудит другие процессы, если все 12 соединений были им приняты за один такт обработки сетевого трафика. Но это не точно.

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

              А нынче с HTTP/2 и веб-сокетами у нас часто преобладают долгоживущие соединения. Поэтому событие установки нового соединение - это одно сравнительно редкое событие. Зато каждое такое соединение затем порождает тысячи событий в процессе его обслуживания.


              1. DistortNeo
                07.02.2022 12:43
                +2

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

                Соответственно, как только приходит новое событие, процесс его моментально отработает без всяких задержек. Даже если вдруг придёт сразу много событий, их обработка не займёт настолько много времени, чтобы это существенно повлияло на задержку. Сервер nginx способен обрабатывать сотни тысяч событий в секунду (т.к. обработка связана не с вычислительными затратами, а с операциями ввода-вывода), и лаг даже при 90% загрузке потока будет составлять доли миллисекунды.


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


                1. Mingun
                  07.02.2022 12:52

                  Вот. Вот это вот объяснение, которого очень не хватает в статье. Что проблема то не просто в том, что один поток забирает все входящие соединения. А в том, что он забирает соединения "с долгом" — и самый первый ждущий накапливает себе долги на обработку, и потом не справляется с ними. Причем набрать "долгов" можно быстро, а "расплачиваться" с ними приходится медленно