[Перевод] Поток на ядро
Я хочу разрешить спор, который охватил сообщество Rust в течение прошлого года или около того: выбор известных асинхронных «рантаймов» по умолчанию для использования многопоточных исполнителей, которые выполняют кражу работы, чтобы динамически сбалансировать работу между своими многочисленными задачами. Некоторые пользователи Rust недовольны этим решением, настолько недовольны, что используют выражения, которые я бы охарактеризовал как мелодраматические:
Первородный грех асинхронного программирования Rust это делать его многопоточным по умолчанию. Если преждевременная оптимизация является корнем всех зол, то это мать всех преждевременных оптимизаций, и она проклинает весь ваш код нечестивой функцией
Send + 'static
или, что еще хуже,Send + Sync + 'static
, которая просто убивает всю радость от непосредственного написания кода на Rust.
Меня всегда отталкивает, что заявления, написанные таким образом, могут быть восприняты всерьез как техническая критика, но наша отрасль довольно несерьезна.
Вместо этого эти люди выступают за альтернативную архитектуру, которую они называют «поток на ядро». Они обещают, что эта архитектура будет одновременно более производительной и простой в реализации. На мой взгляд, правда в том, что это может быть одно или другое, но не то и другое.
(Примечание: некоторые люди предпочитают вместо этого просто запускать однопоточные серверы, утверждая, что они в любом случае «привязаны к вводу-выводу». Под привязкой к вводу-выводу они подразумевают, что их система не использует достаточно работы для загрузки одного ядра, когда она написана на Rust.: если это так, то, конечно, напишите однопоточную систему. Здесь мы предполагаем, что вы хотите написать систему, которая использует более одного ядра процессорного времени.)
Поток на ядро
Одна из самых больших проблем с «потоком на ядро» — это его название. Все многопоточные исполнители, против которых выступают пользователи, также являются «потоком на ядро» в том смысле, что они создают поток ОС на каждое ядро, а затем планируют переменное количество задач (ожидается, что оно будет намного больше, чем количество ядер) по этим потокам. Как написал Пекка Энберг в ответ на мой комментарий о потоке на ядро:
Поток на ядро сочетает в себе три большие идеи: (1) параллелизм должен обрабатываться в пользовательском пространстве вместо использования дорогостоящих потоков ядра, (2) ввод-вывод должен быть асинхронным, чтобы избежать блокировки потоков на ядро, и (3) данные распределяются между ядрами процессора для устранения затрат на синхронизацию и перемещения данных между его кэшами. Трудно построить системы с высокой пропускной способностью без (1) и (2), но (3), вероятно, необходимо только на действительно больших многоядерных машинах.
Статья Энберга о производительности, которая называется «Влияние архитектуры потока на ядро на задержку хвоста приложения» (к которой я вернусь через мгновение), является источником использования термина «поток на ядро» в сообществе Rust. Его понимание определения потока на ядро, вероятно, здесь уместно. Он перечисляет три различных особенности архитектуры «поток на ядро», из которых, по его словам, только две абсолютно необходимы для высокой пропускной способности. Это полезно, потому что спор на самом деле идет только о третьем пункте, а не о первых двух; если вы используете асинхронный Rust, вы соответствуете обоим этим требованиям.
На самом деле делается различие между двумя оптимизациями, которые вы можете сделать, когда у вас есть архитектура «поток на ядро», и которые находятся в противоречии: задачи по краже работы между вашими потоками и разделение как можно меньшего состояния между ними.
Кража работы
Смысл кражи работы состоит в том, чтобы уменьшить задержку хвоста, гарантируя, что у каждого потока всегда есть работа.
Проблема, возникающая в реальных системах, заключается в том, что разные задачи требуют разного объема работы. Например, для обслуживания одного HTTP-запроса может потребоваться гораздо больше работы, чем для другого HTTP-запроса. В результате, даже если вы попытаетесь заранее сбалансировать работу между различными потоками, каждый из них может в конечном итоге выполнять разный объем работы из-за непредсказуемых различий между задачами.
При максимальной нагрузке это означает, что некоторым потокам будет запланировано больше работы, чем они могут выполнить, в то время как другие потоки будут простаивать. Степень этой проблемы зависит от того, насколько различается объем работы, выполняемой разными задачами. Кража работы является решением этой проблемы: потоки, которым нечего делать, «крадут» работу у других потоков, у которых слишком много работы, чтобы они не простаивали. tokio, async-std и smol реализуют кражу работы с целью уменьшить задержку хвоста и улучшить загрузку процессора.
Проблема с кражей работы заключается в том, что это означает, что задача может выполняться в одном потоке, приостанавливаться, а затем снова запускаться в другом потоке: именно это и означает кражу работы. Это означает, что любое состояние, которое используется в точке выхода в этой задаче, должно быть потокобезопасным. В API-интерфейсах Rust это выглядит как фьючерсы, которые необходимо Send
, что может быть затруднительно для людей с плохим представлением о состоянии своей системы, чтобы найти лучший способ обеспечить это. Вот почему говорят, что воровать работу «тяжелее».
В то же время, если состояние перемещается из одного потока в другой, это приводит к затратам на синхронизацию и промахам в кэше, нарушая принципы архитектуры «без общего доступа», в которой каждое ядро процессора имеет эксклюзивный доступ к состоянию, в котором он работает. Вот почему говорят, что кража работы «медленнее».
Без общего доступа
Суть принципа «без общего доступа» заключается в уменьшении задержки хвоста за счет хранения данных в более быстрых кэшах, принадлежащих одному ядру процессора, а не в более медленных кэшах, совместно используемых несколькими ядрами.
Я хочу вернуться к статье Энберга, которая демонстрирует улучшение производительности архитектуры без общего доступа по сравнению с архитектурой с общим состоянием путем сравнения нового хранилища «ключ-значение» (без общего доступа) с memcached (с общим состоянием). Энберг демонстрирует существенные улучшения в задержке хвоста между двумя архитектурами. Мне очень нравится эта статья, но я считаю, что то, как она была использована в сообществе Rust в качестве звуковой фразы («Улучшение производительности на 71%)!», поверхностно и бесполезно.
Чтобы создать архитектуру без общего доступа, хранилище «ключ/значение» Энберга разделяет пространство ключей по различным потокам с помощью хэш-функции и разделяет входящие TCP-соединения по потокам с помощью SO_REUSEPORT
. Затем он маршрутизирует запросы из потока, управляющего соединением, в поток, управляющий соответствующим разделом пространства ключей, используя каналы передачи сообщений. Напротив, в memcached все потоки совместно владеют пространством ключей, которое разделено, и каждый раздел защищен мьютексом.
В статье Энберга показано, что использование каналов вместо использования мьютексов может обеспечить меньшую задержку хвоста. Вероятно, это связано с меньшим количеством промахов в кэше, поскольку каждый раздел, к которому обращаются снова и снова, остается в кэше только одного ядра. Однако я вовсе не уверен, что архитектуру Энберга значительно проще реализовать, чем архитектуру memcached. Цель Энберга — использовать расширенные функции ядра и тщательно спланированную архитектуру, чтобы избежать перемещения данных. Мне трудно поверить, что это будет проще, чем обернуть данные внутри мьютекса.
Хранилище «ключ-значение» — практически идеальный вариант для архитектуры без общего доступа, поскольку разделить состояние приложения между различными потоками довольно просто. Но если ваше приложение более сложное и требует изменения состояния в нескольких разделах транзакционным или атомарным способом, для правильной реализации требуется гораздо больше внимания. Существует сильная аналогия между сторонниками архитектуры без общего доступа и шумихой по поводу создания баз данных, согласованных в конечном счёте, по сравнению с базами данных, которые обеспечивали сериализуемость десять лет назад. Да, это может повысить производительность, но за счет необходимости пристального внимания во избежание ошибок, возникающих из-за несогласованности данных.
Также важно отметить, что ни реализация Энберга, ни memcached не используют кражу работы. Это затрудняет связь основных заявлений Энберга о производительности с архитектурами Rust, использующих кражу работы. Интересно, какими будут результаты, если просто добавить кражу работы в архитектуру Энберга и memcached? По мнению Энберга, это несколько увеличит перемещение данных, но, возможно, таким образом, максимально увеличит загрузку процессора. Я не могу себе представить, чтобы это могло что-то сделать, кроме как помочь memcached.
Энберг тщательно разработал реализацию в статье, чтобы попытаться заранее равномерно распределить работу, используя сбалансированное разделение пространства ключей и SO_REUSEPORT
. Несмотря на это, на практике может возникнуть несколько источников динамического дисбаланса:
Горячие клавиши будут получать больше операций чтения и записи, что приведет к тому, что поток, управляющий пространством ключей, получит больше работы.
Некоторые соединения будут выполнять больше запросов, чем другие, что приводит к тому, что поток, управляющий этими соединениями, получает больше работы.
Насколько я понимаю в статье, фреймворк для бенчмаркинга не воспроизводит эти условия, которые могли бы возникнуть в реальном мире: каждое соединение выполняет постоянный объем работы, оперируя случайными ключами, поэтому позволяет избежать этих источников дисбаланса. Интересно, что бенчмарки, добавляющие кражу работы, покажут, если протестировать подобные виды динамического дисбаланса?
Можно представить себе другие способы проектирования системы без общего доступа, которая может смягчить эти формы дисбаланса (например, кэширование горячих клавиш на дополнительных разделах). И некоторая форма кражи работы может быть такой оптимизацией, даже если некоторые задачи остаются привязанными к определенным ядрам, чтобы избежать перемещения их состояния.
Никто не будет оспаривать, что тщательное проектирование вашей системы, позволяющее избежать перемещения данных между кэшами процессора, позволит добиться более высокой производительности, чем отсутствие этого, но мне трудно поверить, что кто-то, у кого больше всего жалоб на то, что добавление Send
ограничивает некоторые дженерики, занимается такого рода разработкой. Если вы все равно собираетесь использовать общее состояние, трудно представить, что «кража работы» не улучшит загрузку вашего процессора под нагрузкой.