Не так давно мимо нас пробегала неплохая статья об ужасном состоянии производительности современного ПО (оригинал на английском, перевод на Хабре). Эта статья напомнила мне об одном антипаттерне кода, который встречается весьма часто и в общем кое-как работает, но приводит к небольшим потерям производительности то тут, то там. Ну, знаете, мелочь, пофиксить которую руки никак не дойдут. Беда лишь в том, что десяток таких «мелочей», разбросанных в разных местах кода начинают приводить к проблемам типа «вроде у меня последний 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))
{
    HWND hNotepad_plus = ::FindWindow(Notepad_plus_Window::getClassName(), NULL);
    for (int i = 0 ;!hNotepad_plus && i < 5 ; ++i)
    {
        Sleep(100);
        hNotepad_plus = ::FindWindow(Notepad_plus_Window::getClassName(), NULL);
    }

    if (hNotepad_plus)
    {
    ...
    }
    ...
}


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

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

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

Плохой пример №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.

Комментарии (23)


  1. assad77
    26.10.2018 13:19
    +12

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


    1. ookami_kb
      26.10.2018 14:59
      +3

      Да как бы вообще thread – штука тяжелая, все вроде как давно уже пришли к концепции thread pool. А тут вот так раз – и пусть себе целый thread простаивает.


      По мне, так в 99% случаев sleep вообще использовать не стоит. Даже в тестах.


    1. TimsTims
      26.10.2018 18:34
      +1

      Тоже хотел про это сказать)
      Про пароварку так вообще — нужно же рассчитывать, что свет может моргнуть (значит надо помнить сколько ещё готовить осталось), секунда может быть не секундой, а 1,1с, пользователь может захотеть изменить блюдо, но не сможет, те sleep заблокировал поток(отдал ос) итд итп.


      1. NetBUG
        26.10.2018 20:23
        +1

        А главное, на тех же МК эти задачи отлично решаются с помощью машины состояний и глобального таймера (неважно, реального времени или нет). Если очень хочется, то с помощью ОСРВ.


      1. ser-mk
        26.10.2018 23:59

        И чем же вам моргание света мешает использовать sleep?


        1. TimsTims
          27.10.2018 09:33

          Моргнул свет — система перезагрузилась, все настройки сбиты, текущее время в пароварке/мультиварке неизвестно (обычно в них нет батарейки для часов). Значит, неизвестно сколько блюдо готовилось до перезагрузки, и сколько ещё надо продолжать его готовить после перезагрузки, и в каком режиме. Регулярные просыпания, например каждые 10 секунд и сохранение в память текущего прогресса решают эту проблему. Sleep на 20 минут наоборот — не даёт реализовать такой функционал.


          1. ser-mk
            27.10.2018 18:51

            А что мешает спать по 10 секунд и между ними сохранять состояние?

            ...
            while(time_operation > 0){
            	save_state();
            	sleep(10*1000);
            	time_operation -= 10*1000;
            }
            ...


            1. TimsTims
              28.10.2018 14:12

              Я про это и написал — просыпаться каждые 10 секунд. А в статье предлагается сделать sleep на 20 минут:

              Проработать в нём 20 минут

              Вы непонятно почему спорите, читая невнимательно статью.


    1. QtRoS
      26.10.2018 20:13

      Присоединяюсь, обычно в системе есть событийный таймер, лучше его использовать!


    1. tangro Автор
      28.10.2018 21:14

      Получается, хороший пример придумать вообще трудно :)


  1. Endeavour
    26.10.2018 20:24
    +1

    Плохой пример 1: а автор подумал о гонке между установкой хука на создание окна, и созданием этого самого окна? Кроме того, по коду видно, что засыпает только процесс, задача которого — отдать основному сообщение об открытом файле, и не создающий никакого интерфейса, который мог бы тормозить.


    1. tangro Автор
      28.10.2018 21:11

      Так нужно сначала ставить хук, поток делать FindWindow и, если окно нашлось — снимать хук, а если нет — ждать событие. Мне это казалось настолько очевидным, что я даже не расписывал.

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


  1. johnfound
    26.10.2018 20:39

    У меня очень давно сформулировано:


    Правило №1
    Использование таймера для управления чего нибудь, всегда ошибочно.

    Таймер, это всегда эмулятор события. И этот эмулятор, рано или поздно ошибается. Правда, иногда, просто невозможно найти реальный источник сигналов о событиях ("Корректный пример" №2) и тогда приходится использовать таймеры. Но всегда надо ясно осознавать, что это компромисс и все равно ошибка, а программа написанная таким образом, работает неправильно.


  1. Temtaime
    27.10.2018 00:12
    +1

    А что насчёт Sleep(0)?


    1. vanxant
      27.10.2018 05:26

      Вот да, зашёл почитать про sleep(0) и просто с очень малыми значениями. Особенно если нет SleepEx() или аналога


      1. F0iL
        27.10.2018 13:05

        На всякий случай напомню, если кто увидит коммент про sleep(0), заинтересуется и захочет использовать: в Linux sleep(0) может не сработать, но для такого же есть sched_yield() и std::this_thread::yield().


    1. johnfound
      27.10.2018 13:34

      Sleep(0) это не таймер вообще, а управление планировщика. Если я правильно понимаю о чем вы.


      Но говоря вообще, меньше интервал таймера – более правильная программа. В пределе, интервал 0 дает правильный алгоритм.


  1. catharsis
    27.10.2018 09:15

    Exponential backoff — хорошее или плохое использование таймера?


    1. tangro Автор
      28.10.2018 21:20

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


  1. emmibox
    27.10.2018 11:18
    +1

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

    Нет красоты там нет… Вы забыли, что время там выделяется дискретными кусками в зависимости от задания timeBeginPeriod (что в свою очередь зависит от самой ОС и запущенного софта, который в этот timeBeginPeriod полез и поставил то что ему нравится). Отсюда Sleep(1) может длится как примерно 1.1мс так и примерно 15.6мс.


    1. tangro Автор
      28.10.2018 21:23

      Везде, где тестил последние лет 10, Sleep(1) работал около 1 мс. Паузу в 16 мс при Sleep(1) я реально последний раз видел году в 2005-ом на Windows XP.


      1. emmibox
        28.10.2018 22:45
        +1

        Вообще то во времена Win8 Микрософт возвращала 16мс в каких то из апдейтов на ноутбуках, по той причине, что при кванте 16мс значительно меньше потребление энергии от батарейки — больше времени работы в режиме ожидания. В то же время куча программ откручивают назад на 1 (зачастую не имея для этого оснований).

        Поэтому поведение этой функции далеко не всегда соответствует ожидаемому.


  1. third112
    27.10.2018 12:55

    Спасибо! Понравилось — очень образно!:

    Слегка удивлённый подобной щедростью планировщик выносит функции благодарность от имени процессора

    В общем ИМХО тема очень сложная — была бы простая, все бы приложения сейчас не тормозили даже на средних ПК :)

    Ждать выполнения работы в другом потоке «сколько-то миллисекунд» попросту глупо, поскольку если у нас не ОС реального времени — никто вам не даст никакой гарантии за сколько времени тот второй поток запустится или дойдёт до какого-то этапа своей работы.
    Long time ago работал на уже тогда старой RT11 — и в ней было не просто со «Sleep» :)