Содержание

понедельник, 16 апреля 2012 г.

Измерение времени и засыпание потоков

В данном топике я хотел бы поговорить об инструментарии доступном в java для измерения времени и запуска таймеров, об их точности, производительности и возможных проблемах при работе с ними. Например, на Windows при определенных паттернах работы с Thread.sleep() системные часы могут начать идти заметно быстрее, добавляя по несколько секунд на каждом часе. Так же можно столкнуться с большим джиттером при работе с таким классом как ScheduledThreadPoolExecutor.


Для измерения времени в арсенале java программиста есть два метода: System.currentTimeMillis() и System.nanoTime(). На большинстве систем они используют разные механизмы для получения времени.
System.currentTimeMillis()
Первый использует системные часы и дает довольно грубый результат (не точнее 1 мс), но зато возвращает абсолютное время. Никаких дополнительных обязательств он на себя не берет, поэтому работает быстро на всех платформах. Однако надо понимать, что при изменении системных часов (например, при настроенной синхронизацией времени на боксе) значение, возвращаемое данным методом может сильно скакнуть. Так что в результатах замеров длительности операций могут возникать забавные артефакты, включая отрицательные значения. Так же, учитывая факт, что время в ОС может квантоваться, то последовательные вызовы данного метода могут давать только результаты кратные некоторому числу. Например, на моем рабочем компе под WinXP System.currentTimeMillis() в цикле возвращал значения кратные 16 мс. При запуске такого же цикла на Solaris, я получал значения с точностью 1 мс. Важно, что эта точность не связана с точностью прерываний, при изменении которых (об этом описано ниже) точность System.currentTimeMillis() изменяться не будет.
System.nanoTime()
Второй метод использует специальные счетчики, не связанные с системными часами (хотя на некоторых платформах он и может быть реализован через них, но это скорее исключение нежели правило). Формально System.nanoTime() возвращает наносекунды, но последовательные вызовы этого метода вряд ли дадут точность больше микросекунд. На большинстве систем данный метод будет возвращать неубывающие значения, для чего ему может потребоваться внутренняя синхронизация, если он будет вызываться на разных процессорах. Поэтому производительность этого метода очень сильно зависит от железа, и на некоторых машинах запрос к этому методу может легко занимать больше времени, чем запрос к System.currentTimeMillis() [2].

Учитывая, относительность времени, возвращаемого данным методом, его невозможно использовать, скажем, для измерения времени передачи сообщения от одного бокса к другому. Хотя конечно можно измерить время полного round-trip, вычесть время проведенное на второй машине и поделить на два. Однако если у вас распределенное приложение и вам очень важно мереть время затраченное на определенных операциях, которые распределены по разным боксам, то вы можете написать нативный метод, который будет возвращать абсолютное время с большей точность. Я видел такой подход в одном из проектов, с которыми мне приходилось интегрироваться.
Thread.sleep() / Object#wait()
С помощью данных методов можно попросить текущий поток уснуть на определенное количество миллисекунд. Точность просыпания будет зависеть от размера интервала прерываний на вашей ОС. На Windows это обычно 10 мс (но на некотором железе может быть и 15 мс [4]). Однако длина этого интервала может быть изменена даже стандартными средствами java. Данное переключение происходит автоматически, если вы просите заснуть любой поток на время не кратное, текущему интервалу прерываний. Причем, когда данный поток проснется, ОС вернется обратно к штатному режиму.

Однако с этим надо быть очень аккуратным, так как частое переключение между данными режимами из-за бага в Windows [6] может вызвать изменение в нормальном ходе системных часов. Я однажды столкнулся с жалобами клиентов одного из приложений над которыми я работал, что когда они запускают наш продукт, то их часы начинают спешить на несколько секунд в час. Особо никто не обращал на эти жалобы внимание, так как не особо понимали как это вообще может происходить. Как оказалось, такое действительно может иметь место, если Windows часто переключается между режимами с разной точностью интервалов прерывания. Сделать это довольно просто, достаточно запустить в фоне какой-нибудь поток, который в цикле будет вызывать Thread.sleep(), передавая в качестве параметра небольшое число не кратное 10 мс. Например, 1, 5 или 25. Самое забавное, что данное переключение произойдет даже если вы попросите поток уснуть на 1001 мс, что уже казалось бы бессмысленным, зато это дает очень изящный workaround, описанный ниже. Но стоит заметить, что при вызове sleep() на длительное время переключение режимов происходит не часто и проблема со спешкой системных часов проявляться не будет. Еще на javamex [5] пишут, что даже при дефолтном периоде прерываний в 15 мс, JVM считает, что дефолтное значение 10 и может перейти в режим повышенной точности прерываний при попытки засыпания на 15 мс.

Чтобы обойти баг со спешкой часов в JVM был сделан флаг -XX:+ForceTimeHighResolution, который должен был на старте JVM переводить систему в режим повышенной точности прерываний. Но из-за бага в его имплементации [3], получилось, что он делает совершенно другое, а именно замораживает штатную длину прерывания и никакие команды sleep() уже ее не поменяют. Что впрочем тоже явилось решением проблемы отклонения в ходе системных часов. Забавно, но официальный ответ на баг в реализации данного флага состоит в том, что его править не будут, так как, во-первых, он решает изначальную проблему, во-вторых он был внедрен очень давно и многие уже рассчитывают на то, как он работает сейчас, и, в-третьих, существует изящный workaround, который позволяет сделать именно то, для чего изначально задумался этот флаг.

Трюк заключается в следующем. Если вам обоснованно нужна точность sleep в 1 мс, либо вы не можете изменить код, вызывающий спешку часов, то пользуясь тем, что JVM переходит в режим с повышенной точностью прерываний при засыпании на любой интервал не кратный 10, можно запустить поток-демон и вызывать в нем Thread.sleep(Integer.MAX_VALUE). Так как Integer.MAX_VALUE ни кратно 10 то, Windows переключится в режим с интервалом прерывания в 1 мс и будет оставаться в нем, пока поток не проснется. А так как он будет спать Integer.MAX_VALUE миллисекунд, то можно считать, что он переключился в этот режим навсегда. Хотя тут тоже есть один момент, если ваша система войдет в hibernate, то проснется она опять со стандартным интервалом прерывания [1], и ваш цикл опять начнет терзать операционную систему. Так же надо понимать, что перейдя навсегда в режим с уменьшенным интервалом прерываний, вы можете столкнуться с тем, что ваша ОС, вернее некоторое программы начнут работать не совсем так как в стандартом режиме, ведь не спроста он выбран стандартным. Я к тому, что такой режим может создавать определенный оверхед. Хотя с другой стороны различные игрушки и медиаплееры тоже переводят вашу систему в данный режим и ничего криминального обычно не происходит.

Еще раз замечу, как и писал выше, что все изменения точности прерываний не имеют никакого отношения к точности System.currentTimeMillis(). Т.е. если вы в холостом цикле получаете последовательные значения System.currentTimeMillis() с разницей максимум в 16 мс (что ни имеет никакого отношения к дефолтному интервалу прерываний в 10 или 15 мс), то перейдя в режим повышенной точности прерываний, вы продолжите получать разницу в те же 16 мс.
java.util.concurrent.locks.LockSupport#parkNanos
Для пары методов Thread.sleep()/Object#wait() есть перегруженные версии методов, где кроме миллисекунд можно передать еще наносекунды. Хотя если заглянуть в сорсы JDK, то сразу станет видно, что точность это нисколько не увеличивает, так как второй параметр просто округляется в наиболее близкое количество миллисекунд. Однако с пятой джавы, появился еще один способ усыпить поток: java.util.concurrent.locks.LockSupport#parkNanos, который может принимать на вход наносекунды и честно передавать их операционной системе. Хотя толку от этого особо не будет, разве, что на каких-то уж очень экзотических платформах, где интервал прерываний может быть меньше 1 мс.

Таким образом становится понятна еще одна разница между реализациями старого доброго java.util.Timer и более свежего ScheduledThreadPoolExecutor. Если первый использует Thread.sleep(), то второй - LockSupport#parkNanos. Поэтому природа их поведения на различных платформах может немного разниться. Таким образом, если вы заметили, что ваши таски скедуляться с сильным джиттером на вашей платформы, то можете заменить одну имплементацию на другую, и возможно положение улучшиться [1].
Ссылки
[1] Inside The Hotspot VM Vlocks
[2] What Is Behind Systemnanotime
[3] -XX:+ForceTimeHighResolution
[4] JVM Bug
[5] Thread.sleep() issues tutorial
[6] Бага Windows