Skip to content

Instantly share code, notes, and snippets.

@codedokode
Last active December 2, 2018 20:16
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save codedokode/ffd520440a970c07c1c6 to your computer and use it in GitHub Desktop.
Save codedokode/ffd520440a970c07c1c6 to your computer and use it in GitHub Desktop.
Архитектура серверов

Процессор может выполнять параллельно столько потоков инструкций, сколько в нем ядер. На серверах например может стоять два 8-ядерных процессора. В древних компьютерах процессоры были однаядерные, а сейчас даже в смартфонах несколько ядер. Это по той причине, что наращивать скорость работы одного ядра уже не получается, приходится брать количеством.

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

В общем ОС может выполнять программы как бы параллельно даже на одном ядре.

Попробую еще объяснить другими словами. Каждая программа - это последовательность команд или поток инструкций. Они хранятся в памяти. "Возьми число из ячейки номер 1", "прибавь к нему 10", "Положи результат в ячейку 2". Ядро процессора - это механизм, который читает эти команды по очереди и выполняет. Сколько ядер, столько программ может выполняться одновременно. А специальная системная программа в ядре операционной системы - планировщик (schleduler) несколько десятков раз в секунду прерывает этот процесс, смотрит, сколько запущено программ, какой у них приоритет, сколько каждая из них потребила процессорного времени, какие из них готовы к выполнению (а какие ожидают какого-то события) и решает, что делать - будет ядро в следующий промежуток времени продолжать выполнять ту же программу или надо переключить его на выполнение другой. Процесс принудительного переключения ядра на другую программу называется "вытеснением" и он не позволяет зависшей программе занимать процессор 100% времени. Потому даже с одним ядром у пользователя может возникать ощущение, что программы работают параллельно. Под Windows увидеть список запущенных программ можно в Диспетчере Задач (Ctrl + Shift + Esc), под линукс - консольной командой top.

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

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

Создание нового потока это если проводить аналогии с PHP, вызов какой-то функции или include какого-то скрипта. То есть программа может взять функцию и запустить ее выполнение отдельным потоком параллельно с основным потоком. В PHP так не делают, PHP программы однопоточные, но так делают в других языках.

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

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

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


Соответственно когда мы делаем веб-сервер, например Апач или нгинкс, или другой сервер, например сервер mysql, в общем любой сервер который принимает запросы и дает на них ответы, перед нами встает вопрос, каким способом реализовать обработку запросов: одним потоком синхронно, многими процессами с 1 потоком каждый, многими потоками внутри одного процесса, одним потоком асинхронно.

Одним потоком синхронно — это выглядит так: сервер запускается и ждет запросов от клиентов. Получает запрос, обрабатывает, генерирует ответ, отдает ответ клиенту, отсоединяется, ждет следующего. Заметь что такой сервер может обрабатывать параллельно только 1 запрос, в это время другие клиенты которые пытаются подсоединиться и отправить свой запрос, ждут. Если обработка запроса по каким-то причинам затянется например на минуту, все остальные будут ждать минуту.

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

Ну и наконец если программа-сервер падает из-за ошибки, то запросы больше никто не обрабатывает, и ее надо перезапускать (впрочем, это можно автоматизировать).

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

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

Много однопоточных процессов

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

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

Если рабочий упал с ошибкой, процесс-менеджер запустит новый рабочий процесс на замену ему.

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

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

По такой схеме работает веб-сервер Апач в режиме mpm-prefork. Он создает определенное число рабочих процессов (пул воркеров - worker pool) и даже умеет создавать новые при повышении нагрузки (или при падении рабочего из-за ошибки) и убивать лишние при снижении. Аналогично работает PHP в сочетании с Апачом: каждый скрипт работает в отдельном процессе.

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

Допустим PHP-скрипт генерирует главную за 200 мс (вполне обычная цифра).

  • 1 рабочий обслуживает 5 запр/сек.
  • 10 рабочих = 50 запр/сек
  • 100 рабочих = 500 запр/сек.
  • 1000 рабочих - тяжело переключаться между ними, много памяти расходуется (впрочем в наше время дешевой памяти этим можно пренебречь), так что 5 000 мы можем и не увидеть, а увидим например 1000-3000.

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

В общем для веба она подходит, если это не совсем хайлоад.

Но в расчетах выше не учтен один небольшой подвох. Допустим, мы сгенерировали ответ клиенту за 200 мс. Можем ли мы попрощаться с этим клиентом и начать принимать запрос от следующего? Нет. Мы должны отправить клиенту нашу страницу по сети и дождаться, пока он полностью ее получит. Может где-то в лаборатории данные и передаются со скоростью в гигабайты в секунду, но не в реальном мире. А что, если этот клиент где-нибудь в поезде с медленным-медленным GPRS? Наша страница будет отправляться ему полминуты и эти полминуты рабочий будет ждать, пока данные не уйдут и не придет подтверждение, что они получены. То есть мы генерируем ответ за 200 мс и далее полминуты теряем время.

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

Если учесть реальные сетевые задержки на чтение запроса от клиента и на передачу, допустим, пусть это будет 300мс (среднее между быстрыми близкими клиентами с задержкой 10мс, клиентами на слабых компьютерах и клиентами на другом конце света, куда сигнал идет медленно), то рабочий может обслужить лишь 2 запроса в секунду, и наши 500 запросов в секунду быстро уменьшатся до сотни-другой. А уж 5000 нам не видать как своих ушей.

Потому такая схема не подходит для случаев когда у нас много параллельных слабоактивных (медленных) соединений. Например у нас сервер который раздает файлы с диска большому числу клиентов на не очень быстрых каналах. Ну к примеру отдача одной картинки занимает 3 секунды (так как у клиента медленный интернет), картинка весит 100 Кб, это значит что:

  • 1 рабочий может обслужить 1/3 запроса в секунду, трафик 33 кб/с (это значит что ты зря заплатил хостеру за гигабитный канал, он используется менее чем на 0.1% ).
  • 10 рабочих - 10/3 = 3.33 запроса в секунду, трафик 330 кб/с
  • 100 рабочих - 33.3 запроса в секунду, трафик 3.3 Мб/с
  • 1000 рабочих - 333 запр/сек., 33Мбита/с, но они будут есть много памяти и операционная система замучается переключать процессор между ними
  • 10 000 рабочих - 3333 запрс/сек мы не получим, получим условно говоря 1000 и 100 Мбит/с.

Смотри, какие печальные цифры: наш Апач на раздаче картинок загружает гигабитный канал лишь на 10% (значит нам надо купить еще 9 дорогущих серверов чтобы загрузить его полностью), жрет тонны памяти (10 000 рабочих × 10 Мб каждый = 100 Гб ), процессор расходуется в основном на работу планировщика и переключения контекста, быстрые клиенты не могут скачивать файл на полной скорости.

Сравнить многопроцессный сервер можно с супермаркетом где много касс: покупателей обслуживают быстро, но расходы на зарплату высокие.

Один многопоточный процесс

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

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

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

Проблему раздачи статики, описанную выше, это не решает.

Как это описать аналогией, я не знаю.

Один асинхронный процесс с 1 потоком

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

Асинхронный вызов работает по другому: поток просит ОС начать операцию ввода-вывода, и не блокируется, а продолжает выполняться, а позже может спрашивать у ОС каков статус операции и пришли ли данные. Очевидно что код для работы с асинхронными вызовами будет сложнее. Обычно он строится на так называемом событийном программировании (event-driven programming). То есть внутри программы ведется список выполняющихся операций и список функций которые надо вызвать по их завершении. Например, когда придет новый запрос от клиента, вызвать функцию processRequest. Когда прочитается в память файл X, вызвать функцию Y.

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

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

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

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

То есть требуется высокий уровень грамотности и ответственности разработчика.

Плюсы: расходы памяти на один зеленый тред могут быть в сотни раз меньше чем на поток/процесс. Также нет накладных расходов на переключение между ними. Это дает нам возможность иметь сотни тысяч или даже миллион зеленых потоков, хватило бы памяти (естественно при условии что каждый поток не использует много памяти). Используется 100% процессора.

По такой схеме работает веб-сервер nginx, кеш memcached, хранилище redis, платформа Node.JS (реализует веб-сервер на яваскрипте).

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

Заметь что все эти сервера (кроме node.js) не содержат в себе сложной логики, которая может долго выполняться и замедлять обработку запросов. nginx просто раздает файлы с диска по сети или проксирует данные из одного соединения в другое, мемкеш просто кладет небольшие кусочки данных в память или отдает их в сеть. А вот в случае с Node.js все зависит от умения разработчика, и там получить тормоза, утечки памяти довлоьно легко.

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

Эта архитектура стала ответом на проблему C10K - «как поддерживать 10 000 параллельных соединений».

Для PHP есть фреймворк - ReactPHP http://reactphp.org/ который позволяет на PHP реализовать асинхронную обработку большого числа запросов. Если ты хочешь помучаться с написанием правильного асинхронного кода, не прощающего ошибки.

Ссылочки для чтения:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment