Многие опытные инженеры видят подобные ошибки при использовании языков на основе JVM:
[error] (run-main-0) java.lang.OutOfMemoryError: unable to create native thread:
[error] java.lang.OutOfMemoryError: unable to create native thread:
[error] at java.base/java.lang.Thread.start0(Native Method)
[error] at java.base/java.lang.Thread.start(Thread.java:813)
...
[error] at java.base/java.lang.Thread.run(Thread.java:844)
скопировать код
ну, это вызвано потокамиOutOfMemory
. Эта ошибка возникает после того, как при запуске Linux на моем ноутбуке создается только 11500 потоков.
Если вы сделаете то же самое в Go, запустив горутины, которые будут спать вечно, вы увидите совсем другие результаты. На своем ноутбуке я смог создать 70 миллионов горутин, прежде чем мне стало по-настоящему скучно. Так почему же горутины могут превосходить количество потоков? Чтобы раскрыть ответ на вопрос, нам нужно пройти весь путь вниз по ОС. Это не просто академический вопрос, он имеет реальное значение для того, как вы разрабатываете программное обеспечение. В производственной среде я много раз сталкивался с ограничениями потоков JVM, некоторые из-за плохих потоков с утечкой кода, а некоторые из-за того, что инженеры не понимают JVM. лимит потока.
Так что же такое нить?Термин «нить» может использоваться для описания многих различных вещей. В этой статье я буду использовать его для ссылки на логический поток. То есть: серия операций в линейном порядке, логический путь выполнения. Каждое ядро ЦП может действительно одновременно выполнять только один логический поток в одно и то же время [1]. Это создает неотъемлемую проблему: если потоков больше, чем ядер, то некоторые потоки должны быть приостановлены, чтобы позволить другим потокам выполнять работу, и когда наступает их очередь выполняться снова, задача возобновляется. Для поддержки приостановки и возобновления потоку необходимы как минимум две следующие вещи:
-
Некоторый тип указателя инструкций. То есть, когда я делаю паузу, какую строку кода я выполняю?
-
стек. То есть каков мой текущий статус? Стек содержит локальные переменные и указатели на кучу, в которой размещены переменные. Все потоки в одном процессе используют одну и ту же кучу [2].
Для двух вышеупомянутых пунктов система имеет достаточно информации при планировании потоков на ЦП, чтобы приостановить поток, разрешить выполнение других потоков, а затем снова возобновить исходный поток. Эта операция обычно полностью прозрачна для потока. С точки зрения многопоточности он работает непрерывно. Единственный способ, которым поток узнает о перепланировании, — это измерение времени между последовательными операциями [3].
Вернемся к нашему первоначальному вопросу: почему у нас так много Горутин?
JVM использует потоки операционной системыХотя это и не требуется спецификацией, все современные JVM общего назначения, насколько я знаю, делегируют потоки потокам операционной системы платформы. В дальнейшем я буду использовать «поток пользовательского пространства» для обозначения потоков, запланированных языком, а не потоков, запланированных ядром/ОС. Потоки, реализованные в ОС, имеют два свойства, которые сильно ограничивают их количество; любое решение, отображающее языковые потоки и потоки ОС 1:1, не может поддерживать массовый параллелизм.
В JVM стек фиксированного размера Использование потоков ОС приведет к фиксированной и большей стоимости памяти на поток.Еще одна серьезная проблема с потоками операционной системы заключается в том, что каждый поток ОС имеет стек фиксированного размера. Хотя этот размер настраивается, в 64-разрядной среде JVM выделяет 1 МБ стека для каждого потока. Вы можете установить меньшее пространство стека по умолчанию, но вам нужно компрометировать использование памяти, так как это увеличивает риск переполнения стека. Чем больше рекурсии в вашем коде, тем выше вероятность того, что вы получите переполнение стека. Если вы сохраните значение по умолчанию, то 1000 потоков будут использовать 1 ГБ ОЗУ. Хотя оперативная память в наши дни намного дешевле, почти никто не собирается готовить терабайты оперативной памяти для запуска миллионов потоков.
Как Go ведет себя по-другому: стеки с динамическим размеромGolang использует хитрый трюк, чтобы предотвратить нехватку памяти в системе, запуская большой (в основном неиспользуемый) стек: стек Go имеет динамический размер, увеличивается и уменьшается в зависимости от объема хранимых данных. Это не простой вопрос, и его конструкция прошла несколько итераций [4]. Я не собираюсь вдаваться во внутренности (есть много постов в блогах и других материалов, посвященных этому очень подробно), но вывод таков, что каждая вновь созданная горутина имеет всего около 4 КБ стека. Каждый стек имеет размер всего 4 КБ, поэтому на 1 ГБ ОЗУ у нас может быть 2,5 миллиона горутин по сравнению с Java. 1 МБ на поток, что является огромным улучшением.
В JVM: задержка переключения контекста С точки зрения переключения контекста могут быть только десятки тысяч потоков, использующих потоки операционной системы.Поскольку JVM использует потоки операционной системы, она полагается на ядро операционной системы для их планирования. Операционная система имеет список всех запущенных процессов и потоков и пытается назначить им «справедливое» время работы ЦП [5]. Когда ядро переключается с одного потока на другой, ему предстоит проделать большую работу. Новые запущенные потоки и процессы должны абстрагироваться от того факта, что другие потоки также выполняются на том же ЦП. Я не буду вдаваться в подробности здесь, но вы можете прочитать больше материала, если вам интересно. Здесь важно то, что переключение контекстов занимает от 1 до 100 микросекунд. Это не кажется большим количеством времени, относительно реалистичная ситуация составляет 10 микросекунд на коммутатор, и если вы хотите планировать каждый поток по крайней мере один раз в секунду, вы можете выполнять только около ок. 100 000 потоков. На самом деле это не дает потоку времени для выполнения полезной работы.
Как Go ведет себя по-другому: запуск нескольких горутин в одном потоке ОСGolang реализует собственный планировщик, который позволяет запускать множество горутин в одном потоке ОС. Несмотря на то, что Go будет использовать те же переключатели контекста, что и ядро, он экономит много времени, не переключаясь на кольцо-0 для запуска ядра, а затем переключаясь обратно. Однако это только анализ на бумаге. Чтобы поддерживать миллионы горутин, Go нужно делать более сложные вещи.
Даже если JVM помещает потоки в пространство пользователя, она не может поддерживать миллионы потоков. Предположим, что в этой новой системе проектирования переключение между новыми потоками занимает всего 100 наносекунд. Даже если все, что вы делаете, это переключение контекста, если вы хотите планировать каждый поток десять раз в секунду, вы будете запускать только около миллиона потоков. Что еще более важно, для этого нам необходимо максимально использовать ЦП. Для поддержки действительно большого параллелизма требуется еще одна оптимизация: планировать поток только тогда, когда вы знаете, что он может выполнять полезную работу. Если вы запустите много потоков, только небольшое количество потоков будет выполнять полезную работу. Go делает это, интегрируя каналы и планировщики. если Горутина ожидает на пустом канале, тогда планировщик увидит это и не будет запускать горутину. Go идет еще дальше и помещает большинство простаивающих потоков в потоки своей ОС. Таким образом, активные горутины (количество которых, как ожидается, будет намного меньше) планируются для выполнения в одном и том же потоке, в то время как миллионы спящих горутин обрабатываются индивидуально. Это помогает уменьшить задержку.
Если в Java не будут добавлены языковые функции, позволяющие планировщику наблюдать, невозможно поддерживать интеллектуальное планирование. Однако вы можете создать планировщик времени выполнения в «пространстве пользователя», который определяет, когда поток готов к выполнению работы. Это формирует основу для такого типа фреймворка, как Akka, который способен поддерживать миллионы Актеров[6].
В заключениеПереход между моделями потоков операционной системы и упрощенными моделями потоков в пользовательском пространстве продолжается и, вероятно, продолжится в будущем [7]. Для сценариев с большим числом параллельных пользователей это единственный вариант. Однако он имеет значительную сложность. Если бы Go решил использовать потоки ОС вместо собственного планировщика и модели инкрементного стека, они могли бы сэкономить тысячи строк кода во время выполнения. Это действительно лучшая модель для многих вариантов использования. Разработчики языков и библиотек могут абстрагироваться от сложности, чтобы инженеры-программисты могли писать массово параллельные программы.
Дополнительные материалы-
Гиперпоточность удваивает эффект ядра. Конвейерная обработка инструкций также может увеличить параллелизм ЦП. Но пока это по-прежнему O(numCores).
-
Могут быть какие-то особые сценарии, когда это утверждение не соответствует действительности, думаю, кто-нибудь мне об этом напомнит.
-
На самом деле это атака. JavaScript может обнаруживать тонкую разницу во времени, вызванную прерыванием клавиатуры. Вредоносные сайты используют его для прослушивания счетчика, а не кнопки прослушивания. См.: https://mlq.me/download/keystroke_js.pdf.
-
Golang впервые принял модель сегментированного стека, в которой стек фактически расширяется в отдельные области памяти, которые отслеживаются с помощью очень умного ведения журнала. Последующие реализации улучшили производительность в определенных сценариях, используя непрерывный стек вместо разделения стека, подобно изменению размера хэш-таблицы, выделению нового большего стека и передаче некоторых очень сложных операций с указателями, все можно аккуратно скопировать в новый, больший стек. куча.
-
Потоки могут вызывать
nice
(видетьman nice
), чтобы отметить приоритеты, что позволяет лучше контролировать, как часто они планируются. -
Актеры достигают той же цели, что и горутины для Scala/Java, поддерживая массовый параллелизм. Как и в случае с горутинами, планировщик акторов может видеть, какие акторы имеют сообщения во входящих, и, таким образом, запускает только тех акторов, которые могут выполнять действительно полезную работу. У нас даже может быть больше Актеров, чем горутин, потому что Актерам не нужны стеки. Однако это также означает, что планировщик заблокируется, если актор не сможет быстро обработать сообщение (поскольку актор не имеет собственного стека, он не может его обработать). останавливается при обработке сообщения). Блокирующий планировщик означает, что сообщения не могут быть обработаны, и система быстро выходит из строя. Это компромисс.
-
В Apache каждый запрос обрабатывается потоком ОС, что ограничивает возможность Apache эффективно обрабатывать тысячи одновременных подключений. Nginx выбрал другую модель, в которой один поток ОС может обрабатывать сотни или даже тысячи одновременных подключений, что обеспечивает более высокую степень параллелизма. Erlang использует аналогичную модель, которая позволяет одновременно выполнять миллионы акторов. Gevent добавляет в Python гринлеты (потоки пользовательского пространства), которые обеспечивают более высокую степень параллелизма, чем когда-либо прежде (потоки Python — это потоки ОС).
Оригинальная ссылка:
https://rcoh.me/posts/why-you-can-have-a-million-go-routines-but-only-1000-java-threads
Рекомендация курса
Многие люди слышали, что непрерывная поставка может повысить эффективность, но когда дело доходит до того, насколько качественной и тщательной является реализация, предполагается, что многие люди будут смотреть друг на друга. на моем личном многолетнем практическом опыте.
Скидка 45 долларов в течение ограниченного времени, последние 2 дня!
Geek Times отметит эту подпискуАпплеты