Привязываем процесс к процессору с помощью taskset

Данный простой пример запускает два Java-процесса на Linux-машине. Один процесс посылает сообщения, а второй получает эти сообщения через UDP-протокол через сетевой loopback-интерфейс. Пример призван показать, что привязка процесса к ядру процессора (affinity) позволяет увеличить производительность процесса, уменьшить разброс (jitter) значений latency. В конце статьи я объясняю, почему это происходит.

Исходники примеров взяты из статьи Матрина Томпсона. Статья написана в 2011 году. Мартин позже поменял исходники, а текст статьи не поменял, пришлось чуть-чуть помучаться с настройками и с исходным кодом, чтобы все заработало.

Для демонстрации данного примера требуется один многоядерный компьютер, который даже может быть не подключен к сети. Ниже приведенные шаги проверены на стандартном дистрибутиве CentOS 8, запущенном на виртуальной машине в VirtualBox, созданном с помощью Vagrant. Тестирование проводилось на рядовом десктопном 4-х ядерном процессоре Intel Core i5-6400T @ 2.20GHz. Для сборки проекта использовались OpenJDK 11.0.9.1 2020-11-04 LTS и Gradle 6.3.

Настройки сетевого интерфейса

Проверяем, что loopback-интерфейс есть, и что он настроен. Раньше для этого использовалась команда ifconfig, но она входит в пакет net-tools, который с недавних пор уже не устанавливается в CentOS по умолчанию. В качестве замены используется команда ip addr show:

# ip addr show
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host
       valid_lft forever preferred_lft forever
2: enp0s3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
    link/ether 08:00:27:68:19:7b brd ff:ff:ff:ff:ff:ff
    inet 10.0.2.15/24 brd 10.0.2.255 scope global dynamic noprefixroute enp0s3
       valid_lft 86160sec preferred_lft 86160sec
    inet6 fe80::d2b6:49e0:19bb:b5d9/64 scope link noprefixroute
       valid_lft forever preferred_lft forever

Из результатов исполнения команды видим, что у нас на компьютере присутсвует loopback-интерфес и его имя — lo.

Запуск программ и замеры

Запускаем первый класс – MultiCastReceiver (Получатель). Передаем ему в качестве параметра только имя dummy-интерфейса lo:

# java MultiCastReceiver lo

Получатель находится в ожидании и говорит, что пока ничего не получил:

Received 0 messages in 1112ms
Received 0 messages in 1016ms
Received 0 messages in 1005ms
Received 0 messages in 1008ms
Received 0 messages in 1001ms
Received 0 messages in 1004ms
Received 0 messages in 1023ms
Received 0 messages in 1002ms...

Запускаем второй класс – MultiCastSender (Отправитель). Передаем ему в качестве параметра имя loopback-интерфейса lo и сколько сообщений надо отправить:

# java MultiCastSender lo 20000000

Отправитель, начинает отправлять сообщения и выводит отчеты:

Sent 82044 messages in 1001ms
Sent 85544 messages in 1002ms
Sent 80738 messages in 1016ms
Sent 69781 messages in 1023ms
Sent 74374 messages in 1001ms
Sent 88204 messages in 1002ms
...

Теперь получатель должен показывать, что он получает сообщения:

Received 63650 messages in 1009ms
Received 49127 messages in 1001ms
Received 49191 messages in 1011ms
Received 54849 messages in 1000ms
Received 56022 messages in 1001ms
Received 55658 messages in 1002ms
Received 62746 messages in 1039ms
...

Запускаем те же программы через taskset, привязывая каждый процесс к определенному CPU. Счет CPU ведется с 0:

# taskset -c 0 java MultiCastReceiver lo
# taskset -c 1 java MultiCastSender lo 20000000

С помощью pidstat убедимся, что каждый процесс теперь работает на своем отдельном CPU (ядре):

# pidstat -t -p 3451
CPU  Command
  0  java
  0  |__java
  0  |__java
  0  |__VM Thread
  0  |__Reference Handl
  0  |__Finalizer
  0  |__Signal Dispatch
  0  |__C2 CompilerThre
  0  |__C1 CompilerThre
  0  |__Sweeper thread
  0  |__Service Thread
  0  |__VM Periodic Tas
  0  |__Common-Cleaner
  0  |__Thread-0

pidstat -t -p 3438
CPU  Command
  1  java
  1  |__java
  1  |__java
  1  |__VM Thread
  1  |__Reference Handl
  1  |__Finalizer
  1  |__Signal Dispatch
  1  |__C2 CompilerThre
  1  |__C1 CompilerThre
  1  |__Sweeper thread
  1  |__Service Thread
  1  |__VM Periodic Tas
  1  |__Common-Cleaner
  1  |__Thread-0

Собираем статистику и…

Для оценки результатов достаточно скопировать из консоли вывод программы MultiCastReceiver в Excel и построить график по второму столбцу. А потом свести два графика в один.

График розового цвета показывает количество сообщений, обработанное без утилиты taskset, синий график — с помощью taskset. Обратите внимание на разброс значений (jitter) в розовом графике и на практически постоянное значение (за редким исключением) в синем графике. Хочу отметить, что здесь мы не оцениванем производительность, мы оценивем разброс производительности. Чем объясняется столь разительная разница в результатах?

… размышляем

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

Лучше всего, если поток будет запущен на том же самом ядре, на котором он выполнялся до прерывания своей работы. Почему?

  • Если в кэше процессора (cache hierarchy) все еще находятся инструкции и данные этого потока, поток может быстро продолжить свою работу, не ожидая поступления инструкций и данных из кэша другого уровня или из памяти.
  • Подсистема предсказания переходов (branch prediction) все еще может зранить информацию о предыдущих исполнениях этого потока. Значит, когда этот же поток снова начинает исполняться на этом же ядре, процессору не придется собирать эту информацию с нуля.

Привязка процесса (потока) к определенному процессору или ядру называется affinity. Об этом приеме можно найти достаточно подробное описание в Википедии. Как видим из вышеприведенного примера с помощью утилиты taskset мы привязали каждую программу к определенному ядру (affinity) и тем самым добились уменьшения нестабильности времени выполнения нашей программы (jitter), а значит повысили предсказуемость ее поведения.

Привязывать поток (процесс) к ядру можно и программным способом. Помимо утилиты taskset более тонко affinity потоков приложения можно настроить с помощью утилит isocpu и cgroups, либо программно через JNI/C++: например, как это сделано в OpenHFT/ThreadAffinity.

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

Итоги

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

Что почитать еще

Добавить комментарий

Заполните поля или щелкните по значку, чтобы оставить свой комментарий:

Логотип WordPress.com

Для комментария используется ваша учётная запись WordPress.com. Выход /  Изменить )

Google photo

Для комментария используется ваша учётная запись Google. Выход /  Изменить )

Фотография Twitter

Для комментария используется ваша учётная запись Twitter. Выход /  Изменить )

Фотография Facebook

Для комментария используется ваша учётная запись Facebook. Выход /  Изменить )

Connecting to %s