Хабрахабр

Как правильно и неправильно спать

Не так давно мимо нас пробегала неплохая статья об ужасном состоянии производительности современного ПО (оригинал на английском, перевод на Хабре). Эта статья напомнила мне об одном антипаттерне кода, который встречается весьма часто и в общем кое-как работает, но приводит к небольшим потерям производительности то тут, то там. Ну, знаете, мелочь, пофиксить которую руки никак не дойдут. Беда лишь в том, что десяток таких «мелочей», разбросанных в разных местах кода начинают приводить к проблемам типа «вроде у меня последний Intel Core i7, а прокрутка дёргается».

Итак, что же такое Sleep? Я говорю о неверном использовании функции Sleep (регистр может отличаться в зависимости от языка программирования и платформы). Нельзя не отметить эстетическую красоту прототипа данной функции: Документация отвечает на этот вопрос предельно просто: это пауза в выполнении текущего потока на указанное количество миллисекунд.

void Sleep(DWORD dwMilliseconds);

Всего один параметр (предельно понятный), никаких кодов ошибок или исключений — работает всегда. Таких приятных и понятных функций очень мало!

Ещё большим уважением проникаешься к этой функции, когда читаешь, как она работает

Отдайте бедным!». Функция идёт к планировщику потоков ОС и говорит ему «мы с моим потоком хотели бы отказаться от выделенного нам ресурса процессорного времени, сейчас и ещё на вот столько-то миллисекунд в будущем. Красота!
Слегка удивлённый подобной щедростью планировщик выносит функции благодарность от имени процессора, отдаёт оставшийся кусок времени следующему желающему (а такие всегда найдутся) и не включает вызвавший Sleep поток в претенденты на передачу ему контекста выполнения на указанное количество миллисекунд.

Что же могло пойти не так? То, что программисты используют эту замечательную функцию не для того, для чего она предназначена.
А предназначена она для программной симуляции какого-то внешнего, определённого чем-то реальным, процесса паузы.

Корректный пример №1

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

Корректный пример №2

Мы пишем контроллер самогонного аппарата хлебопечки. Алгоритм работы задаётся одной из программ и выглядит примерно так:

  1. Перейти в режим 1.
  2. Проработать в нём 20 минут
  3. Перейти в режим 2.
  4. Проработать в нём 10 минут
  5. Выключиться.

Здесь тоже всё чётко: мы работаем со временем, оно задано технологическим процессом. Использование Sleep — приемлемо.

А теперь посмотрим на примеры неверного использования Sleep.

Его код ужасен настолько, что любой антипаттерн там точно найдётся, я об этом даже статью когда-то писал. Когда мне нужен какой-то пример некорректного кода на С++ — я иду в репозиторий кода текстового редактора Notepad++. Давайте посмотрим, как в нём используется Sleep. Не подвёл меня ноутпадик++ и в этот раз!

Плохой пример №1

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

if ((!isMultiInst) && (!TheFirstOne))
if (hNotepad_plus) { ... } ...
}

В этом случае второй процесс будет ждать создания окна «5 раз по 100 мс». Программист, писавший этот код, попытался найти окно уже запущенного Notepad++ и даже предусмотрел ситуацию, когда два процесса были запущены буквально одновременно, так что первый из них уже создал глобальный мьютекс, но ещё не создал окно редактора. В итоге мы или не дождёмся вообще, или потеряем до 100 мс между моментом реального создания окна и выходом из Sleep.

Мы ждём не наступление события, а «сколько-то миллисекунд, вдруг повезёт». Это и есть первый (и один из главных) антипаттернов использования Sleep. Да, пользователь может не заметить паузы в 100 мс при старте приложения. Ждём столько, чтобы с одной стороны не очень раздражать пользователя, а с другой стороны — иметь шанс дождаться нужного нам события. Здесь 100 мс, там ещё 50 мс, а здесь вот 200 мс — и вот у нас программа уже «почему-то тормозит несколько секунд». Но если подобная практика «ждать сколько-нибудь от балды» будет принята и допустима в проекте — закончиться это может тем, что ждать мы будем на каждом шагу по самым мелочным причинам.

В данном конкретном случае можно было бы использовать функцию SetWindowsHookEx, подписавшись на событие HSHELL_WINDOWCREATED — и получить нотификацию о создании окна мгновенно. Кроме того, просто эстетически неприятно видеть код, работающий долго в то время, как он мог бы работать быстро. И мы выигрываем до 100 мс! Да, код становиться чуть сложнее, но буквально на 3-4 строки. А самое главное — мы больше не используем функции безусловного ожидания там, где ожидание не является безусловным.

Плохой пример №2

HANDLE hThread = ::CreateThread(NULL, 0, threadTextTroller, &trollerParams, 0, NULL);
int sleepTime = 1000 / x * y;
::Sleep(sleepTime);

Я не очень разбирался, чего конкретно и как долго ждёт этот код в Notepad++, но общий антипаттерн «запустить поток и подождать» я видел часто. Люди ждут разного: начала работы другого потока, получения из него каких-то данных, окончания его работы. Плохо здесь сразу два момента:

  1. Многопоточное программирование нужно для того, чтобы делать что-то многопоточно. Т.е. запуск второго потока предполагает, что мы что-то продолжим делать в первом, в это время второй поток выполнит другую работу, а первый, закончив свои дела (и, возможно, ещё немного подождав), получит её результат и как-то его использует. Если мы начинаем «спать» сразу же после запуска второго потока — зачем он вообще нужен?
  2. Ожидать нужно правильно. Для правильного ожидания существуют проверенные практики: использование событий, wait-функций, вызов колбеков. Если мы ждём начала работы кода во втором потоке — заведите для этого событие и сигнальте его во втором потоке. Если мы ждём завершения работы второго потока — в С++ есть замечательный класс thread и его метод join (ну или опять-таки платформенно-зависимые способы типа WaitForSingleObject и HANDLE в Windows). Ждать выполнения работы в другом потоке «сколько-то миллисекунд» попросту глупо, поскольку если у нас не ОС реального времени — никто вам не даст никакой гарантии за сколько времени тот второй поток запустится или дойдёт до какого-то этапа своей работы.

Плохой пример №3

Здесь мы видим фоновый поток, который спит в ожидании каких-то событий.

class CReadChangesServer
{
...
void Run() { while (m_nOutstandingRequests || !m_bTerminate) { ::SleepEx(INFINITE, true); } } ...
void RequestTermination() { m_bTerminate = true; ... } ... bool m_bTerminate;
};

Нужно признать, что здесь используется не Sleep, а SleepEx, который более интеллектуален и может прерывать ожидание при некоторых событиях (типа завершения асинхронных операций). Но это нисколько не помогает! Дело в том, что цикл while (!m_bTerminate) имеет полное право работать бесконечно, игнорируя вызванный из другого потока метод RequestTermination(), сбрасывающий переменную m_bTerminate в true. О причинах и следствия этого я писал в предыдущей статье. Для избегания этого следовало бы использовать что-то, гарантированно правильно работающее между потоками: atomic, event или что-то подобное.

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

Плохой пример №4

В этом примере мы посмотрим на функцию backupDocument, которая выполняет роль «автосохранялки», полезной на случай непредвиденного падения редактора. По-умолчанию она спит 7 секунд, затем даёт команду сохранить изменения (если они были).

DWORD WINAPI Notepad_plus::backupDocument(void * /*param*/)
{ ... while (isSnapshotMode) { ... ::Sleep(DWORD(timer)); ... ::PostMessage(Notepad_plus_Window::gNppHWND, NPPM_INTERNAL_SAVEBACKUP, 0, 0); } return TRUE;
}

Интервал поддаётся изменению, но не в этом беда. Любой интервал будет одновременно слишком большим и слишком малым. Если мы набираем одну букву в минуту — нет никакого смысла спать всего 7 секунд. Если мы откуда-то копипастим 10 мегабайт текста — не нужно ждать после этого ещё 7 секунд, это достаточно большой объём, чтобы инициировать бекап немедленно (вдруг мы его откуда-то вырезали и там его не осталось, а редактор через секунду крешнется).

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

Плохой пример №5

Notepad++ умеет «набирать текст» — т.е. эмулировать ввод текста человеком, делая паузы между вставкой букв. Вроде бы писалось это как «пасхальное яйцо», но можно придумать и какое-нибудь рабочее применение этой фиче (дурить Upwork, ага).

int pauseTimeArray[nbPauseTime] = {200,400,600};
const int maxRange = 200;
...
int ranNum = getRandomNumber(maxRange);
::Sleep(ranNum + pauseTimeArray[ranNum%nbPauseTime]);
::SendMessage(pCurrentView->getHSelf(), SCI_DELETEBACK, 0, 0);

Беда здесь в том, что в код вшито представление о каком-то «среднем человеке», делающем паузу 400-800 мс между каждой нажатой клавишей. Ок, может это «в среднем» и нормально. Но вы знаете, если используемая мною программа делает какие-то паузы в своей работы просто потому, что они кажутся ей красивыми и подходящими — это совсем не значит, что я разделяю её мнение. Мне хотелось бы иметь возможность настройки длительности данных пауз. И, если в случае Notepad++ это не очень критично, то в других программах мне иногда встречались настройки типа «обновлять данные: часто, нормально, редко», где «часто» не было для меня достаточно часто, а «редко» — не было достаточно редко. Да и «нормально» не было нормально. Подобный функционал должен давать пользователю возможность точно указать количество миллисекунд, который он хотел бы ждать до выполнения нужного действия. С обязательной возможностью ввести «0». Причём 0 в данном случае вообще не должен даже передаваться аргументом в функцию Sleep, а просто исключать её вызов (Sleep(0) на самом деле не возвращается мгновенно, а отдаёт оставшийся кусок выданного планировщиком временного слота другому потоку).

Выводы

С помощью Sleep можно и нужно выполнять ожидание тогда, когда это именно безусловно заданное ожидание в течение конкретного промежутка времени и есть какое-то логическое объяснение, почему он такой: «по техпроцессу», «время рассчитано вот по этой формуле», «столько ждать сказал заказчик». Ожидание каких-то событий или синхронизация потоков не должны реализовываться с использованием функции Sleep.

Теги
Показать больше

Похожие статьи

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

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть