Вы когда-нибудь задумывались, почему Node.js использует один поток? Параллелизм и параллелизм, условия гонки, взаимоблокировка, голодание, блокировка (мьютекс), семафор, атомарность, CAS сравнения и замены и многое другое.

Что такое многопоточность?

(Для лучшего понимания я рекомендую краткий обзор того, что такое поток и процесс, из этой статьи: Разница между процессом и потоком)

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

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

Мотивация использования многопоточного процесса

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

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

Выгода

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

Параллелизм против параллелизма

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

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

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

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

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

… Это переключение контекста обычно происходит достаточно часто, чтобы пользователи воспринимали потоки или задачи как работающие параллельно (для популярных серверных/настольных операционных систем максимальный квант времени потока, когда другие потоки ожидают, часто ограничивается 100–200 мс).
(Тред (вычисления) — википедия)

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

Дополнительный вопрос:
Как Node.js может обрабатывать одновременные запросы в одном потоке?

Давайте немного отвлечемся и спросим себя. Почему при всех преимуществах многопоточности Node.js использует один поток и тот факт, что он обрабатывает только один запрос за раз?

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

user do an action
       │
       v
 application start processing action
   └──> make database request
          └──> do nothing until request completes
 request complete
   └──> send result to user

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

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

При отображении многопоточного веб-сервера у нас есть 100 одновременных запросов, и веб-сервер создает 100 потоков для каждого запроса. Хотя этим потокам, возможно, придется ждать 100 ответов от базы данных, активное время ЦП равно нулю. Параллелизм ввода-вывода не связан потоками, поэтому 100 потоков так же эффективны, как и 1 поток. Вместо этого параллелизм ввода-вывода ограничивается устройствами ввода-вывода, например: количеством сетевых карт. Поэтому, если мы увеличим количество потоков, производительность может не увеличиться, как мы ожидали.

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

Что насчет части IO? Вы можете спросить. Что ж, внутренняя библиотека Node.js под названием libuv будет обрабатывать ввод-вывод. Под капотом libub на самом деле является пулом потоков, каждый запрос ввода-вывода обрабатывается потоком в пуле потоков.

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

Проблемы с многопоточными программами

В многопоточных программах несколько потоков могут выполнять один и тот же фрагмент кода одновременно или параллельно. Его можно запустить в «поточно-безопасном» или «непоточно-ориентированном» режиме. Далее мы разберемся, что это за проблемы и как их избежать.

Безопасность потока

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

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

Условия гонки

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

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

Выход в состоянии гонки трудно предсказать и непостоянен.

Блокировка (мьютекс)

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

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

Например, поток A выполняется в строке 12, он пытается и успешно получает блокировку, затем переходит в строку 17 и создает одноэлементный объект подключения к базе данных. Между тем, когда поток B работает в строке 12, он должен ждать, чтобы получить блокировку, поскольку поток A удерживает ее. После того, как поток A возвращается в строке 20, он освобождает полученную блокировку в строке 13 (ключевое слово defer переместит выполнение инструкции в самый конец внутри функции).

До тех пор поток B может успешно получить блокировку в строке 12 и проверить, является ли переменная instance значением nil, поскольку она уже была назначена потоком A, он не будет снова инициализировать одноэлементный объект. Затем он освобождает полученную блокировку.

В Golang есть более чистый способ, использующий библиотеку sync.Once:

См. также: Реализация sync.Once
Мне было очень интересно прочитать, почему они не делают сравнение и обмен (CAS), чтобы проверить, было ли действие завершено или нет. О CAS мы поговорим ниже.

Тупик

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

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

Например, многопоточной программе нужно писать в 2 ресурса. Поток 1 записывает в Ресурс 1 и блокирует другие потоки от записи в Ресурс 1, затем он хочет записать в Ресурс 2, но его блокирует Поток 2. Тем временем Поток 2 ожидает освобождения Ресурса 1, но Поток 1 ожидание освобождения ресурса 2 и возникновение взаимоблокировки.

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

Нехватка ресурсов

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

семафор

Второй способ избежать состояния гонки — Semaphore.

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

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

В этом примере приемная представляет собой счетный семафор, комнаты — ресурсы, а студенты — процессы/потоки. Значение семафора в этом примере изначально равно 10, все комнаты пусты. Семафор, который ограничен значениями 0 и 1 (или заблокирован/разблокирован, недоступен/доступен), называется двоичным семафором.

Чтобы избежать голодания, семафор может реализовать очередь FIFO. Когда процессы/потоки имеют разные приоритеты, очередь может быть упорядочена по приоритету, так что процесс с наивысшим приоритетом берется из очереди первым.

См. также: Семафор против мьютекса

Дополнительный вопрос к вам:
Мьютекс или двоичный семафор?

Атомная операция

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

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

Сравнение и замена (CAS)

Большинство процессоров предоставляют инструкцию атомарного сравнения и замены (CAS), которая считывает из области памяти, сравнивает значение с «ожидаемым» значением, предоставленным пользователем, и записывает «новое» значение, если они совпадают, возвращая значение обновление прошло успешно.

Вот быстрая площадка: https://go.dev/play/p/RTEv3UtGBYx

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

Помимо CAS, мы также можем использовать Загрузить и Сохранить значение указателя.
См. также: документация sync/atomic.

Блокирующие/блокирующие и неблокирующие/неблокирующие алгоритмы

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

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

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

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

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

См. также: Блокирующая и неблокирующая очередь от ByteByteGo.

Заключение

В этой статье мы рассмотрели множество тем многопоточности:

  • Плюсы/минусы многопоточных программ
  • Разница между параллелизмом и параллелизмом
    плюс причина, по которой Node.js является однопоточным
  • Проблемы с многопоточными программами: Race Conditions, Deadlock, Starvation
  • Как добиться потокобезопасности: блокировка (мьютекс), семафор, атомарная операция, например. сравнение и замена (CAS)
  • Блокирующие/блокирующие и неблокирующие/неблокирующие алгоритмы

Если вы дошли до этого места, спасибо за чтение. Надеюсь, эта статья была вам полезна, и вы узнали что-то новое!

Рекомендации

Спасибо, что прочитали эту статью! Оставьте комментарий, если у вас есть какие-либо вопросы или отзывы. Если вы нашли эту статью полезной, пожалуйста, удерживайте кнопку хлопка, чтобы другие могли найти это. Обязательно подпишитесь на мою рассылку ниже или подпишитесь на меня на Medium, чтобы получать больше подобных статей. ☝️👏 🤗