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

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

Объективные факты для перемен

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

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

В качестве примера возьмем HTTP-сервер. Когда он получает запрос, он его обрабатывает, требуя внимания процессора. Когда наш сервер не занят обработкой запросов, он простаивает. Нет смысла планировать такой процесс, если он абсолютно ничего не делает, что подводит нас к финальному пункту:

Использование ресурсов естественно. Бесполезное использование ресурсов – это плохо. Не слишком удачный вариант; мы все еще говорим об объективных фактах. Не имеет значения, работает ли ваше приложение хорошо в 100% случаев — если оно напрасно потребляет системные ресурсы, оно потенциально может помешать другим процессам получить их.

На этом позвольте мне представить три стадии горя, связанного с использованием процессора.

Этап 1: Отрицание (название основных проблем)

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

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

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

Плотные петли

Вот фрагмент кода программы, которая снова и снова выполняет вычисления:

"""
Simplest tight loop which does absolutely nothing.
"""


def do_work():
    _ = 1 + 1


def problem():
    while True:
        do_work()

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

# runs the snippet in a container
docker run --rm --name cpu-hogging -it vladpbr/cpu-hogging:python -c "from samples.tight_loop import problem; problem()"

# displays resource statistics for the container (run in a separate terminal)
docker stats cpu-hogging

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

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

И наоборот, поскольку мы используем Python, я солгал вам в лицо. Чтобы вернуть ваше доверие, я объясню, почему: из-за Global Interpreter Lock Python настоящий параллелизм никогда не произойдет в процессе Python, поскольку один интерпретатор может управляться только одним потоком одновременно. Даже если бы мы использовали несколько потоков, они никогда не выполнялись бы одновременно. Вот в чем разница между следующими случаями:

"""
Tight loop in two separate threads/processes.
"""

from multiprocessing import Process
from threading import Thread


def do_work():
    _ = 1 + 1


def problem():
    while True:
        do_work()


def spawn(items):
    for item in items:
        item.start()

    for item in items:
        item.join()


def spawn_threads():
    spawn([Thread(target=problem) for _ in range(2)])


def spawn_processes():
    spawn([Process(target=problem) for _ in range(2)])
docker run --rm --name cpu-hogging -it vladpbr/cpu-hogging:python -c "from samples.tight_loop_concurrent import spawn_threads; spawn_threads()"

docker run --rm --name cpu-hogging -it vladpbr/cpu-hogging:python -c "from samples.tight_loop_concurrent import spawn_processes; spawn_processes()"

В надежде еще больше искупить свою вину, вот две темы на Golang:

package main

func do_work() {
  for true {}
}

func main() {
  go do_work()
  do_work()
}
docker run --rm --name cpu-hogging -it vladpbr/cpu-hogging:golang run samples/tight_loop.go

Вывод таков: наш процесс получает все процессорное время, которое может получить в свои жадные руки. Глядя на код, становится очевидным: наша программа выполняет чисто вычислительную работу, никогда не спит и не ждет ввода-вывода. Это называется жестким циклом — «цикл кода, который выполняется без освобождения каких-либо ресурсов для других программ или операционной системы». Однако, как и в случае с людьми, наличие титула не делает их лучше.

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

def solution():
    import time
    while True:
        do_work()
        time.sleep(0.001)
docker run --rm --name cpu-hogging -it vladpbr/cpu-hogging:python -c "from samples.tight_loop import solution; solution()"

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

Занят, ждет

Давайте поговорим о том, почему решение time.sleep работает, а следующий фрагмент — нет:

"""
Loop which uses a custom `sleep` function.
"""

from datetime import datetime, timedelta


def do_work():
    _ = 1 + 1


def sleep(seconds: float):
    end_time = datetime.now() + timedelta(seconds=seconds)

    while datetime.now() < end_time:
        pass


def problem():
    while True:
        do_work()
        sleep(0.001)

Мы реализовали нашу собственную функцию sleep, которая вычисляет точное время, необходимое для «засыпания», а затем находится в цикле, пока время не истечет. Вот как это работает:

docker run --rm --name cpu-hogging -it vladpbr/cpu-hogging:python -c "from samples.busy_wait import problem; problem()"

Если это не было сразу очевидно, наша пользовательская функция sleep сама по себе представляет собой замкнутый цикл. Это так называемое ожидание занятости — цикл, который не позволяет коду выполняться до тех пор, пока не будет выполнено условие. Так что же делает функция time.sleep такого особенного, благодаря чему все это работает?

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

Это отличается от ожидания занятости — time.sleep уведомляет операционную систему о том, что наш поток неактивен. Вот фрагмент из статьи в Википедии о системном вызове «sleep»:

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

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

Другие способы высвобождения ресурсов

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

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

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

На этот раз не просто верьте мне на слово; вместо этого позвольте поведению следующего фрагмента убедить вас:

def do_disk_io_work():
    result = 1 + 1
    with open("temp.txt", "w") as _file:
        _file.write(str(result))


def disk_io():
    while True:
        do_disk_io_work()
docker run --rm --name cpu-hogging -it vladpbr/cpu-hogging:python -c "from samples.tight_loop import disk_io; disk_io()"

То же самое касается сетевого ввода-вывода, как вы можете сказать:

def do_network_io_work():
    import http.client
    client = http.client.HTTPSConnection("google.com")
    client.request("GET", "/")
    client.getresponse()


def network_io():
    while True:
        do_network_io_work()
docker run --rm --name cpu-hogging -it vladpbr/cpu-hogging:python -c "from samples.tight_loop import network_io; network_io()"

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

Чтобы констатировать очевидное, это не полное решение. У вас может возникнуть соблазн оставить этот код как есть, поскольку он не так сильно потребляет процессорное время, но основная проблема все еще существует. Тот факт, что цикл «более свободный», не означает, что его нельзя улучшить.

Как избежать перегрузки процессора в других распространенных сценариях

При использовании queues у вас может возникнуть соблазн сделать следующее:

"""
Waiting for results from a queue.
"""

from time import sleep
from threading import Thread
from queue import Queue
from random import choice

queue = Queue(maxsize=1)


def producer_thread():
    while True:
        queue.put(choice(["apple", "banana", "cherry"]))
        sleep(1)


def problem():
    Thread(target=producer_thread).start()
    while True:
        if not queue.empty():
            print(queue.get())

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

docker run --rm --name cpu-hogging -it vladpbr/cpu-hogging:python -c "from samples.queue import problem; problem()"

В очередях для этого есть специальный механизм — по умолчанию get() является блокирующим вызовом, то есть он не вернется, пока элемент не будет добавлен в очередь. Базовый механизм уведомления о прибытии элемента основан на блокировках и не тратит драгоценное время процессора.

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

Этап 2. Торг (чего не следует делать)

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

Вы всегда можете установить жесткий предел использования процессора процессом. Такие решения обычно не являются встроенными, но существуют внешние — BES для Windows и cpulimit для Linux, и это лишь некоторые из них. В Docker есть возможность установить точное ограничение ЦП для любого контейнера через cgroups:

docker run --cpus 0.5 --rm --name cpu-hogging -it vladpbr/cpu-hogging:python -c "from samples.tight_loop import problem; problem()"

Две проблемы с этим «решением»:

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

Этап 3. Приемка (законные проблемы, связанные с ЦП)

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

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

Вы можете возразить, что, как и установка жесткого ограничения ЦП, это функция инфраструктуры и, следовательно, не может быть жизнеспособным решением на уровне приложения. Я не согласен. Мы говорим о конкретном случае использования, когда инфраструктура, так сказать, «виновата» из-за нехватки ресурсов. Когда дело доходит до дела, разработчики приложений — единственные люди, которые могут принять решение о важности и приоритете своих процессов.

Приоритет процесса на практике

Допустим, наш образец с плотным циклом действительно работает. Предполагая, что вы используете Linux, вот как вы запускаете процесс с пользовательским приоритетом (в Linux значения приоритета находятся в диапазоне от 19 до -20, где значение имеет более низкий приоритет, а значение по умолчанию — 0):

nice -n 15 python3.11 -c "from samples.tight_loop import problem; problem()" &

Давайте посмотрим, как это работает:

top -p 34376

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

top

Ответ superuser.com выразил это довольно элегантно:

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

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

Не забудьте завершить игру с помощью этого кода:

kill -9 34376

Приоритет процесса в среде с ограниченным количеством ЦП

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

Возможно, это немного выходит за рамки этой статьи, но вот как вы можете получить Python и соответствующий код в TinyCore:

tce-load -wi python3.9 git
git clone https://github.com/vlad-pbr/cpu-hogging.git
cd cpu-hogging
  • TinyCore поставляется с собственными инструментами управления пакетами, которые можно использовать для установки предварительно упакованного программного обеспечения через tce-load.
  • На момент написания самая высокая версия Python, доступная в официальных репозиториях пакетов TinyCore, — 3.9.
  • TinyCore также не поставляется с установленным ssh, поэтому подойдет клонирование https.

Для начала давайте посмотрим, как ведут себя два тесных цикла при выполнении с одинаковым приоритетом:

python3.9 -c "from samples.tight_loop import problem; problem()" &
python3.9 -c "from samples.tight_loop import problem; problem()" &
top

Теперь давайте понизим приоритет для одного из наших узких циклов:

renice -n 5 2591
top

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

kill -9 2592 2591

Заключение

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

  • Дал названия плохим практикам кодирования
  • Изучили параллелизм Python
  • Вник в механику планирования процессов.
  • Запускал Docker-контейнеры и смотрел их статистику
  • Немного узнал о Linux и некоторых распространённых командах bash.
  • Загрузил всю виртуальную машину и имитировал нехватку процессорного времени!?

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

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