Предлагаем вашему вниманию отличное новогоднее чтение для программистов :) Статью Александра Чистякова ( alexclear ), которую тот написал, вдохновившись тезисами доклада Mons Anderson ( codesign ) на HighLoad++ 2017.

Александр Чистяков

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

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

Осторожно, под катом жёсткий хардкор!

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

Асинхронная парадигма


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

Планировщик ОС


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

При смене процесса на ядре процессора необходимо выполнить переключение контекста, сохранив все текущие регистры процессора в память и восстановив из памяти регистры процессора, соответствующие процессу или потоку, предназначенному к исполнению. Эта операция занимает около 200 процессорных тактов для современных процессоров (что довольно дорого).

Операции ввода/вывода осуществляются с использованием механизма DMA, при этом CPU не участвует в процессе. Несмотря на то, что в Linux процессы, осуществляющие ввод-вывод, формально эккаунтятся как назначенные на CPU, фактически такие процессы не занимают процессор и попадают в состояние uninterruptible sleep (D-state). В момент осуществления ввода-вывода одним процессом планировщик назначает на процессор другой процесс.

С точки зрения планировщика ОС потоки ОС (по крайней мере, в Linux) являются просто процессами — каждый из них имеет собственный независимый контекст, блокирование на операциях ввода/вывода одного из потоков не отражается на работе других потоков.

Таким образом, снятие потока ОС с исполнения на процессоре может происходить в двух случаях:
  1. закончился квант времени, отведенный на исполнение;
  2. поток выполнил синхронную операцию ввода-вывода (такую, которая обязана дождаться своего завершения).


Java


Классическая (как в современной Java) модель синхронной параллельной обработки ориентируется на использование потоков операционной системы в качестве потоков рантайма (виртуальной машины) языка. Строго говоря, рантайм и виртуальная машина — разные понятия, но для нас это не важно.

Если наша программа обслуживает клиентские подключения, то каждый клиент потребует отдельный поток операционной системы для поддержания соединения. При этом количество переключений контекста между потоками операционной системы будет пропорционально количеству клиентских соединений (смотрите «эффективность» выше).
Допустим, нам необходимо обслужить 10000 клиентских соединений — нам придется породить 10000 потоков операционной системы, технически это возможно.

Perl


Такая модель многопоточности, как в Java, не работает в большинстве интерпретаторов современных языков (Python, Ruby (потоки в Ruby вообще не являются потоками операционной системы)) из-за GIL. Стандартная многопоточность в Perl реализована по тем же принципам, что и в Java — поток Perl является потоком ОС, но при этом каждый поток исполняется своим собственным интерпретатором (смотрите «эффективность» еще раз — это гораздо менее эффективно, чем потоки в Java).
Допустим, нам надо обслужить те же 10000 соединений — теперь нам придется породить 10000 отдельных копий интерпретатора Perl, что невозможно технически.

N:1


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

В этом случае «потоки» существуют только внутри процесса интерпретатора и планировщик ОС о них ничего не знает. Поэтому переключение между такими потоками должен делать сам интерпретатор, и делает он это в двух случаях: поток в явном виде выполняет команду yield или аналогичную, передающую управление в другой поток, либо поток выполняет операцию ввода-вывода. Такая модель многопоточности называется «N:1» — нескольким потокам уровня интерпретатора соответствует один поток уровня ядра ОС.

Тем не менее, если операция ввода-вывода синхронна, поток уровня ОС попадет в D-state и будет снят с исполнения на процессоре до момента окончания операции ввода-вывода. Это приведет к тому, что все N потоков, исполняющихся в данном потоке ОС, будут заблокированы до окончания операции ввода-вывода в одном из них (смотрите «эффективность»).

Коллбеки


К счастью, в ядре предусмотрен асинхронный (с некоторыми оговорками) механизм ввода-вывода, при использовании которого вызывающий поток уровня ОС не ожидает конца выполнения операции ввода-вывода, а продолжает исполнение. При этом в момент окончания операции I/O будет вызван зарегистрированный пользователем callback.

Для использования асинхронного ввода-вывода достаточно перевести сокет в асинхронный режим при помощи системного вызова fcntl(2).
Представим, что нам необходимо обслужить 10000 подключений — для этого нужно выполнить команду чтения на 10000 открытых сокетах.

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

Для того, чтобы гарантировать константную алгоритмическую сложность поиска нужных сокетов, были реализованы механизмы kqueue (FreeBSD) и epoll(7) (Linux).

При использовании epoll поток исполнения уровня ОС занят регистрацией/удалением открытых файловых дескрипторов и подготовкой асинхронных вызовов I/O, а также обработкой сработавших коллбэков. Если ваша программа не использует yield, то становится критически важным не допускать CPU-интенсивных вычислений между операциями ввода-вывода, так как это нарушит справедливое распределение ресурса процессора между потоками уровня интерпретатора (или рантайма).

Golang и Node.JS


Мы только что описали механизм работы рантайма языка Golang. Отличие только в том, что механизм многопоточности в Golang не N:1, а N:M, где M — число ядер процессора. Рантайм Golang может автоматически переключать горутины не только в моменты ввода-вывода, но и в моменты вызова других функций (при этом бесконечный цикл утилизирует 100% процессорного времени в соответствующем потоке ОС и никогда не будет прекращен рантаймом).

Интерпретатор Node.JS также построен вокруг epoll (точнее говоря, вокруг кода из nginx), только он использует модель N:1 и далее одного ядра не масштабируется.

В некоторых случаях планировщик, подобный планировщику рантайма Golang имплементирован в виде библиотеки или транспайлера (например, Coro в Perl или async/await в JS при помощи Babel), что позволяет использовать корутины в языках, в которых отсутствует их поддержка на уровне интерпретатора.

Попытка классификации


Исходя из вышеизложенного, я бы предложил следующую классификацию многопоточных схем:
  1. Классическая имплементация потоков рантайма через потоки ОС;
  2. Имплементация корутин вида N:1 или N:M;
  3. Низкоуровневая работа с асинхронным вводом/выводом путем ручной регистрации коллбэков и написания соответствующей лапши (не забудьте завести где-то хэшмэп для контекстов).

Классификация Mons'а вокруг обработки HTTP-запросов
Теперь к классификации Монса. Насколько я понимаю, она построена вокруг задачи обработки HTTP-запросов и использует классическую терминологию веб-сервера Apache.

Видимо, single process server — это просто синхронно работающий сервер, который может обрабатывать только один запрос в один момент времени.

Forking server — это сервер, который для каждого обрабатываемого запроса порождает отдельный процесс (смотрите «эффективность», в Linux fork(2) использует механизм CoW, а то было бы еще хуже).

Preforking server — это классика мира Apache, создание рабочих процессов заранее в заданном количестве, обработка все еще синхронна.

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

Что такое async prefork — видимо, это имплементация механизма N:M, когда запущено M рабочих процессов.

Что такое async + worker мне неведомо, потому что worker от prefork в мире Apache отличается, насколько я помню, тем, что у worker схемы вместо рабочих процессов порождаются рабочие потоки (с точки зрения ОС никакой разницы нет, есть разница с точки зрения shared state, а mutable shared state — это причина, по которой вас сперва депремируют, а потом и уволят).

Что такое multithreaded async? По моей (она не моя, я сам, ленивый и грешный, ничего не изобретал) классификации это опять N:M, не знаю, зачем три названия для одного и того же.

Мы так и не определили, что такое «эффективность». Не понадобилось.


P.S.: Кстати, доклад тогда так и не прозвучал (доклад был не готов, хотя мы его очень хотели), но мы надеемся услышать его на HighLoad++ Junior этой весной. Где и продолжим нашу дискуссию :)

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


  1. olegbunin Автор
    02.01.2018 18:13
    -1

    Парни, накидайте alexclear кармы, на мой взгляд, Хабру очень пригодится такой автор!


    1. olegbunin Автор
      03.01.2018 14:56

      Да уже понял, понял :)


  1. shai_hulud
    02.01.2018 18:37

    начало бодрое, а конец скомканый, вывод я не понял (может я тупой).


    1. alexclear
      02.01.2018 20:57

      Вывод был примерно такой: «Классификации могут быть разные, в зависимости от того, какие признаки выбрать для их построения».


      1. shai_hulud
        02.01.2018 22:07

        А то я читал с мыслью о том что более приземленные темы вот-вот раскроют. А в конце оказалась только альтернативная классификация.


  1. AndreyRubankov
    02.01.2018 19:41

    Java

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

    Ваша информация о Java уже лет 5-10 как устарела :(
    Не стоит вводить читателей в заблуждение.


    1. alexclear
      02.01.2018 20:53

      Вы имеете в виду NIO и NIO.2?


      1. 1nd1go
        02.01.2018 23:54

        ну да, netty тому пример


      1. AndreyRubankov
        03.01.2018 01:41

        Да, речь про nio, который доступен с Java 1.4, релиз которой был в 2002 году.

        Почти 16 лет (!!) уже прошло, как в java нативно поддерживает асинхронный IO, а люди по прежнему несут бред про «в Java 10k клиентов = 10k потоков» (facepalm).

        ps: Netty вообще имеет возможность работать с kqueue / epoll напрямую, если нужно.


        1. ageres
          03.01.2018 04:46

          Братюня! Истово плюсую!
          Почему-то считается, что Java — это тупо блокирующее IO.
          Ребята, почитайте доки, вы сильно удивитесь.


          1. alexclear
            03.01.2018 08:32

            Почему-то считается, что Java — это тупо блокирующее IO.

            Кем считается?
            Про NIO написано непосредственно в статье про Java в википедии, я не знаю, куда еще можно было бы написать — на руку, на лоб?


        1. alexclear
          03.01.2018 04:48

          Это прекрасно, но я не понял — я что, в каком-то месте это пытаюсь опровергнуть?
          Байндинги к epoll есть в любом современном языке программирования. Статья была не про это.


          1. AndreyRubankov
            03.01.2018 08:11

            Вы сделали акцент (выделили отдельным блоком и подкрасили цветом), что Java – это всегда блокирующее IO. Вы то же самое сделали в отношении Perl.

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

            ps: я так же понимаю, что этой строкой Вы хотели подогреть статью и подцепить на крючек неокрепшие умы, которые где-то прочитали/услышали, что Java – это плохо. Но Вы же инженер! (я надеюсь). Не нужно так =)


            1. alexclear
              03.01.2018 08:25

              Вы сделали акцент (выделили отдельным блоком и подкрасили цветом), что Java – это всегда блокирующее IO. Вы то же самое сделали в отношении Perl.

              Я? Вот оригинал статьи: telegra.ph/K-voprosu-o-principah-raboty-asinhronnyh-reshenij-10-01
              Что в нем выделено отдельным блоком? O_O
              Там даже абзац начат со слова «классическая», потому что речь идет о классической модели, есть и другие. Не понимаю, где там написано, что Java — это всегда блокирующее IO. Не понимаю, где там написано «не используйте Java». Вдвойне забавен тот факт, что статья датирована октябрем 2017-го, а, начиная с мая 2017-го мы с коллегой писали сервис на базе netty для хранения бинарных данных в Cassandra (что, некоторым образом, мешает мне утверждать, что Java — это всегда блокирующее IO).

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

              Я рад, что у статьи был глубокий смысл (какой?) и рад, что по-прежнему есть люди умнее меня.

              ps: я так же понимаю, что этой строкой Вы хотели подогреть статью

              А с какой предположительной целью?

              и подцепить на крючек неокрепшие умы, которые где-то прочитали/услышали, что Java – это плохо.

              Чтобы дальше сделать с ними что?
              Ваш пойнт в чем? В том, что Вы лучше меня? Да пожалуйста, я к подобному давно привык, здесь много людей, которые лучше меня, это же Хабр.
              В том, что мне не надо писать тексты? Странно, учитель литературы говорил другое.
              В том, что написанные тексты надо показывать коллегам? Так я это сделал в фейсбуке еще в октябре — но, вероятно, мои коллеги недостаточно умны. :(


              1. AndreyRubankov
                03.01.2018 09:10

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

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

                Под грязными приемами я имею ввиду такой:

                Классическая (как в современной Java) модель синхронной параллельной обработки ориентируется на использование потоков операционной системы… Допустим, нам необходимо обслужить 10000 клиентских соединений — нам придется породить 10000 потоков операционной системы, технически это возможно.

                В этом фрагменте есть ключевые поинты:
                — отсылка на «старые подходы» (классические)
                — на конкретную современную платформу, в которой эти старые подходы до сих пор используются
                — и самое вкусное в конце: «10k соединений = 10k потоков», – это же ужасно!!!

                Любой неокрепший ум воспринимает такой текст как «Вот эту платформу ни в коем случае нельзя использовать – она нерационально использует ресурсы!».

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


                1. alexclear
                  03.01.2018 09:44

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

                  Я так и не понял — а с какой целью-то?

                  В самом начале топика написано, что Вы автор этой статьи. Никаких ссылок на оригинал и что авторство не ваше, нету.

                  Давайте по-порядку.
                  Первое: Вы пишете, что я что-то выделил отдельным блоком и подкрасил цветом.
                  Второе: я даю Вам ссылку на, собственно, исходный текст статьи (своей! да, это моя статья, написанная в октябре 2017-го по просьбе коллег с телеграм-канала про язык Perl), где ничего не выделено и не подкрашено.
                  Третье: Вы мне пишете фразу, которую я не могу понять.
                  Разговор зашел в тупик.
                  Я попробую еще раз: я ничего не выделял и не подкрашивал, это требует дополнительных пояснений?

                  на конкретную современную платформу, в которой эти старые подходы до сих пор используются

                  Вы не поверите, они в ней до сих пор используются!
                  Прочитайте, пожалуйста, целиком:
                  «Классическая (как в современной Java) модель синхронной параллельной обработки ориентируется на использование потоков операционной системы в качестве потоков рантайма» — а что, классическая модель СИНХРОННОЙ ПАРАЛЛЕЛЬНОЙ обработки в современной Java работает как-то иначе?
                  В чем суть Вашей претензии, я не пойму?

                  Любой неокрепший ум воспринимает такой текст как «Вот эту платформу ни в коем случае нельзя использовать – она нерационально использует ресурсы!».

                  У меня жена — психолог, можно, я про неокрепшие умы буду разговаривать с ней, а не с Вами? Спасибо!

                  Этот фрагмент написан в таком виде умышленно, почему и зачем – это хорошие вопросы, автору виднее.

                  Я, как автор, нижайше прошу Вас перестать приписывать мне реплики и утверждения из своей головы. На каком основании Вы считаете меня сперва идиотом, а потом — желтым журналистом? Я Вам повода не давал. Давайте, я Вас начну считать некомпетентным только на основании того, что Вы работаете в EPAM — Вам приятно будет?

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

                  Действительно, в октябре 2017-го я предвидел, что Олег в январе 2018-го разместит статью на Хабре, и принял меры.
                  Как хорошо, что Вы меня разоблачили!
                  Тысяча подписчиков в фейсбуке, среди которых есть, например, Яша Сироткин (это исторически первый координатор русской JUG) — не смогли, а Вы — смогли!

                  Очевидно, Хабру не очень пригодится такой автор, как я, но я и не рвался, дамы и господа! Я много лет пишу тексты, в том числе, и технические, если бы я всерьез собирался писать на Хабр — я бы давно начал, но я не собираюсь и не начну, почему — должно быть понятно из комментариев. Я много лет вообще молчал на этой площадке, видя, что происходит с текстами и мнениями моих друзей, и могу только процитировать себя же: «мы заложники пассивно-агрессивного стада, с которым, тем не менее, приходится коммуницировать по бизнес-нужде, и мне стыдно, что мне тоже пришлось в этом участвовать».


  1. dimas09
    02.01.2018 19:53

    а про erlang/elixir чего не упомянули


  1. GarudaJI
    02.01.2018 19:56

    Самое интересное это как распределяется CPU между внутренним процессами. Также как-то забыли про Эрланг, в котором N:M было реализовано задолго до появления Go.


  1. rfq
    02.01.2018 20:10

    «Что такое multithreaded async? По моей… классификации это опять N:M»
    multithreaded async — это работа с коллбэками, исполняющимися на пуле потоков. Например, как в Java NIO.2.


  1. Zanak
    02.01.2018 23:30
    -1

    Допустим, нам надо обслужить те же 10000 соединений — теперь нам придется породить 10000 отдельных копий интерпретатора Perl, что невозможно технически.

    Интересно, а автор с перлом дело имел? Ни кто, и ни когда не станет такие задачи решать методом грубой силы.


  1. DistortNeo
    03.01.2018 00:59

    А где тут, собственно жёсткий хардкор?

    1. Почему колбэки и select/epoll сидят в одном разделе? Последние не работают с колбэками.
    И вообще, колбэки на уровне ОС есть только в Windows, да и не очень-то они эффективны из-за переключения контекста.

    2. Где различия между stackless и stackfull реализациями?
    Внешне они выглядят однаково, но внутреннее устройство различно. Первое — просто синтаксический сахар над колбэками (async/await в js, c#), второе — многопоточность в пользовательском режиме (go), называемая кооперативной многозадачностью.


  1. unsafePtr
    03.01.2018 01:02

    Странно упомянуть async/await и не упомянуть родоначальника этого паттерна, а именно платформу .net


    1. andreylartsev
      03.01.2018 01:24

      Если мне память не изменяет, эти ключевые слова были представлены MS как синтаксический сахар в компиляторе C++ в тулките для разработки под новое асинхронное API WinRT которое должно было заменить Win32. И это API было никак не связано с .NET. Это особо подчеркивалось, как мне помнится. .net и C# позже подтянулись. Так что первым языком в котором появились эти ключевые слова все же C++ ) правда не в ISO стандарте а в очередной интерпретации MS.


      1. unsafePtr
        03.01.2018 01:37

        Если память не изменяет то первым языком с поддержкой async/await был F#. Там толпа энтузиастов. Ну а компилятор преобразует это в CLR, и там это выглядит в виде машины состояния с двумя состояниями, и переход между ними выполняется с помощью goto оператора. Ну а в CLR самые узкие места написаны на C/C++.


  1. andreylartsev
    03.01.2018 01:27

    Я так понял статья все ещё не дописана) будет все же интересно прочитать ее полную версию, таки с какими то выводами)


  1. zgmnkv
    03.01.2018 01:54

    Почему вы пишете, что в Node.JS используется модель N:1? Там же нет никаких потоков даже в userspace.


    1. DistortNeo
      03.01.2018 04:36

      Типичная ошибка человека, который увидел код с async/await, который внешне выглядит как синхронный.


  1. Zanak
    03.01.2018 14:08

    Автор определенно смешал понятия асинхронности и паралеллизма. Асинхронность слабо, или вообще ни как не связана с ОС и ядрами процессора.
    Пример раз:
    Встраиваемая программа в цикле опрашивет датчики освещенности, темпиратуры, ну и пусть будет влажности. Как только показатели выходят за границы, программа включает/выключает соответствующий прибор.
    Пример два:
    Всем известный веб сервер apache. В режиме prefork первичный процесс мониторит количество потомков и иагрузку, по мере необходимости выполняя fork нужное число раз. Обработка самих запросов идет асинхронно, относительно родительского процесса.