Методы и практики проектирования web-приложений реального времени с использованием технологии Java

Автор: Лакомкин Егор Дмитриевич
Опубликовано: 20.04.2012
Версия текста: 1.1
Предисловие
Введение
Основные источники непредсказуемого поведения Java-приложения
Главные факторы, влияющие на общую производительность
Некоторые популярные библиотеки
Применение ядра реального времени для достижения детерминизма
Заключение

Предисловие

Статья посвящена важным аспектам создания приложений на Java со сверхбыстрым временем отклика. В материале представлены некоторые решения автора, использованные при разработке успешно-действующей системы автоматической торговли на рынке ценных бумаг с жестким требованием по сокращению задержек. Кроме того, в статье систематизированы разрозненные наработки зарубежных проектов и предложены рекомендации по настройке Linux OS, которые значительно влияют на итоговую результативность системы. В том числе приводятся полезные источники, знакомящие читателя с техниками написания быстрых, стабильных приложений на Java.

Введение

Проектирование систем критичных ко времени отклика считается прерогативой языка С и подмножества языка C++, оправданно являющихся стандартом де-факто в разработке программного обеспечения реального времени. Впрочем, существуют технологии, которые позволят создавать аналогичные по характеристикам приложения на Java. Однако из-за не всегда оправданной критики платформы Java в медлительности, громоздкости и неприспособленности к детерминизму и работе без задержек данное решение редко используется для реализации ПО реального времени. В статье наглядно показано, что при максимальных ограничениях во времени отклика до 100 микросекунд использование Java может быть по затратам ресурсов более экономичным решением.

На текущий момент Java является самым распространенным языком в мире[25]. Это дружелюбный и доступный для программистов инструмент. Благодаря несчетному количеству предлагаемых сервисов, библиотек и уровней абстракции, его порог вхождения существенно ниже, чем у признанных мастодонтов C/C++. Значит, и стоимость реализации систем с жесткими требованиями по сверхбыстрой реакции на Java ощутимо меньше. Это обусловлено тем, что при условии одинакового опыта программистов Java и C++, первый, используя предлагаемые ему средой средства, спроектирует такую систему быстрее. При этом выбирая решения, которые автор рекомендует в данном труде, итоговое ПО на Java не только не будет уступать, но и в некоторых вопросах превзойдет свой возможный аналог на С++.

Основные источники непредсказуемого поведения Java-приложения

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

Первым и самым важным источником непредсказуемого поведения в нашем хит-параде является сборщик мусора (garbage collector). С одной стороны, технология выглядит сущим благом для разработчиков, избавляющим их от необходимости управлять памятью вручную, но в то же время она вносит вариационную составляющую в выполнение программы. К сожалению, невозможно предсказать в какой момент включится механизм сбора ненужных объектов, и как долго он будет продолжаться, что может также влечь за собой блокировку важных рабочих потоков.

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

Листинг 1.
Iterator<TestClass> testClassIter = list.iterator();
while (cursor.hasNext()) 
{
  TestClass testClass = testClassIter.next();
  if (o.getID() == RARE_ID) 
  {
    NeverLoadedClass o2 = new NeverLoadedClass(o);
    // здесь класс загрузится впервые и произойдет компиляция
  }
  else 
  {
    ...
  }
}

Избежать озвученной выше ситуации можно приготовив заранее список классов, используемых приложением, и заставив JVM искусственно загрузить их при помощи следующего простого метода:

Iterator<String> classIt = listOfClassNamesToLoad.iterator();
while (classIt.hasNext()) 
{
  String className = classIt.next();
  try 
  {
    Class clazz = Class.forName(className);
    String n=clazz.getName();
  } 
  catch (Exception e) 
  {
    System.err.println("Could not load class: " + className);
    System.err.println(e);
  }
}

Just-in-Time компилятор, задумывавшийся, как и Garbage Collector, для улучшения работы ПО, может в процессе отладки приложения в ряде случаев повлечь дополнительную головную боль. Сутью JIT компиляции является перевод байткода, сгенерированного java, в машинные инструкции процессора. К несчастью такая компиляция “на лету” расходует процессорное время и может вносить задержку во время выполнения от нескольких миллисекунд до секунды. Эта ситуация схожа с динамической загрузкой классов. Так, к примеру, непубличный внутренний метод balanceTree класса TreeMap может вызваться будучи не скомпилированным и тем самым тормозить выполнение приложения.

Хороший источник дополнительных знаний о методах компиляции дается в статье от экспертов IBM : Real-time Java, Part 2: Comparing compilation techniques[28]. Помимо детального описания процесса компиляции в ней приводится техника Ahead-Of-Time compilation, которая как может замещать полностью JIT, так и дополнять ее. Сутью AOT является предкомпиляция байткода перед выполнением программы. Также в случае использования JIT распространенной практикой является введение в работу приложения стадии “прогрева”. Система работает в холостом ходу какой-то период времени(по рекомендациям инженеров Oracle от 10 до 30 минут[29]) в течение которого приложение заходит во все возможные ветки выполнения кода и JIT компилирует и оптимизирует все методы.

Четвертый источник задержек – управление потоками в Java. Так, назначая потоку максимальный приоритет, мы не можем быть уверены, что он не будет прерван в ходе борьбы за ресурсы потоком с более низким приоритетом.

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

Давайте далее перейдем к обзору способов борьбы с вышеуказанными проблемами:

Главные факторы, влияющие на общую производительность

Главным фактором, влияющим на производительность, является Java машина, на которой выполняется приложение. Подходить к выбору нужно ответственно, взвешивая исходные требования к характеристикам системы.

Первое что необходимо отметить - это наличие спецификации RTSJ[1] и JVM, реализующих ее. Вот несколько из них: Sun Java Real-time[2], JamaicaVM[3], Aonix Perc[4], IBM WebSphere Real Time V3.0[5].

Real-time в составе названия спецификации говорит о предсказуемости и надежности времени отклика, а не о том, что приложение должно реагировать на события внешней среды с максимальной скоростью. Таким образом, основным требованием систем реального времени является неизменное соблюдение дедлайнов. RTSJ предоставляет несколько средств, позволяющих достичь этого. Классы RealtimeThread и NoHeapRealTimeThread предоставляют поддержку приоритетов, периодичного поведения, организацию дедлайнов с обработчиками, вызывающимися, когда дедлайн пропущен, а также области памяти, отличные от стандартной heap. NoHeapRealTimeThread в принципе не имеет доступа к heap и, таким образом, не может быть прерван сборщиком мусора, что позволяет использовать его в качестве потока самого высокого приоритета и критичности к дедлайнам. Говоря о памяти, спецификация RealTime обеспечивает разработчика двумя дополнительными областями, неподвластными GC – immortal и scoped. Объекты, выделенные в области immortal, доступны всем потокам и никогда не удаляются, а scoped предоставляет программисту возможность вручную управлять временем жизни объектов. Кроме того, RTSJ гарантирует, что поток с самым высоким приоритетом будет выполняться до тех пор, пока сам не освободит ресурсы процессора или не будет вытеснен потоком с более высоким приоритетом. Более подробно можно почитать об этом здесь[6]. Стоит отдельно отметить JVM от Azul Systems – Zing[7]. Данная технология позволяет иметь неограниченный размер кучи и полностью исключить паузы сборщика мусора. Разработчики Oracle, помимо стандартной Java-машины, создали продукт JRockit[8] - высокопроизводительное решение для Java-приложений, имеющее в своем составе сборщик мусора, поддерживающий детерминистичное поведение с ограничением на максимальное время паузы до 1 миллисекунды.

Как уже отмечалось выше, одним из главных факторов, влияющих на общую производительность, часто становится сборщик мусора, являющийся центром экосистемы Java. Основная задача сборщика мусора – отслеживание ненужных объектов в памяти и их своевременное удаление, с целью недопущения ситуации, когда свободной памяти в heap уже нет. Классическим вариантом алгоритма GC является разделение области heap на 2 зоны: в nursery попадают “молодые” объекты, в tenured продвигаются объекты уже пережившие процесс сборки.

В сети присутствует много материалов по настройке GC под конкретную реализацию Java-машины для достижения детерминизма и сокращения пауз. Существуют примеры, в которых выполнение стадии Full GC осуществляется раз в сутки за счет увеличения областей heap и nursery. К примеру, в системах автоматической торговли, где любые остановки алгоритма купли-продажи на 100мкс могут иметь плохие последствия и событие “stop-the-world” должно быть исключено. В такой ситуации для реализации логирования событий в системе в реальном времени можно использовать подход, напоминающий концепцию реализованную в фреймворке disruptor.

Листинг 2.
public class BackgroundLogger implements Runnable {
 static final int ENTRIES = 64;

 static class LogEntry {
   long time;
   int level;
   double data;
 }

 static class LogEntries {
   final LogEntry[] lines = new LogEntry[ENTRIES];
   int used = 0;
 }
 private final ExecutorService executor = Executors.newSingleThreadExecutor();
 final Exchanger<LogEntries> logEntriesExchanger = new Exchanger<LogEntries>();
 LogEntries entries = new LogEntries();

 BackgroundLogger() {
   executor.submit(this);
 }

 public void log(int level,double data) {
   try {
     if (entries.used == ENTRIES)
       entries = logEntriesExchanger.exchange(entries);
     LogEntry le = entries.lines[entries.used++];
     le.time = System.nanoTime();
     le.level = level;
     le.data = data;
     return;

   } catch (InterruptedException e) {
     throw new RuntimeException(e);
   }
 }

 public void flush() throws InterruptedException {
   if(entries.used > 0)
       entries = logEntriesExchanger.exchange(entries);
 }

 public void stop() {
   try {
     flush();
   } catch (InterruptedException e) {
     e.printStackTrace(); // use standard logging.
   }
   executor.shutdownNow();
 }

 @Override
 public void run() {
   LogEntries entries = new LogEntries();
   try {
     while (!Thread.interrupted()) {
       entries = logEntriesExchanger.exchange(entries);
           for (int i = 0; i < entries.used; i++) {
             bgLog(entries.lines[i]);
            }
       entries.used = 0;
     }
   } catch (InterruptedException ignored) {

   } finally {
     System.out.println("Warn: logger stopping."); // use standard logging.
   }
 }

 private void bgLog(LogEntry line) {
   // log the entry to a file.
 }
}

Следующий важный аспект работы – коллекции, позволяющие удобно, быстро и эффективно хранить и организовывать доступ к данным. На рынке, помимо стандартной Java Collection FrameWork от Oracle, представлены реализации от High Performance Computing Collections[6], Trove[7], Colt[8], PCJ[9]. Основная идея состоит в невозможности использования примитивов в стандартных Java-коллекциях, что влечет за собой генерирование дополнительного мусора в виде классов-оберток, таких как Double и Long для типов double и long соответственно.

Иллюстрации, приведенные ниже, показывают разницу в запусках сборщика мусора на двух примерах: один реализован на примитивах, а другой на wrapper'ах.

Листинг 3.
Map<Integer, Integer> counters = new HashMap<Integer, Integer>();
int runs = 20 * 1000;
for (Integer i = 0; i < runs; i++) 
{
   Integer x = i % 12;
   Integer y = i / 12 % 12;
   Integer times = x * y;
   Integer count = counters.get(times);
   if (count == null)
       counters.put(times, 1);
   else
       counters.put(times, count + 1);
}


Рис. 1. Результат работы приложения, написанный с помощью типов-оберток (Integer). Источник vanillajava.blogspot.com

Тот же пример на примитивах:

Листинг 4.
int[] counters = new int[144];
int runs = 20 * 1000;
for (int i = 0; i < runs; i++) {
   int x = i % 12;
   int y = i / 12 % 12;
   int times = x * y;
   counters[times]++;
}


Рис. 2. Результат работы приложения, написанного на примитивных типах. Источник картинки vanillajava.blogspot.com

Некоторые популярные библиотеки

Библиотека Javolution[18]  состоит из широкого спектра классов, которые предоставляют более детерминистичное поведение по сравнению с аналогами Java Collection FrameWork. Так, например, классы FastMap (аналог HashMap), FastTable (аналог ArrayList) и TextBuilder (аналог StringBuilder) показывают плавный рост, а не выполняют дорогостоящую операцию расширения вместимости коллекции по достижению определенного процента заполненности или процедуру полного перехеширования.

На следующем рисунке в качестве примера приведены всплески во времени вставки записи в HashMap при достижении определенного размера таблицы. FastMap лишен подобного недостатка.


Рис. 3. Сравнение задержки вставки в FastMap и HashMap. Источник javolution.org

Отдельно отмечу библиотеку месседжинга ZeroMQ, разработанную специалистами iMatix, отвечающую самым жестким требованиям к пропускной способности и задержкам. Продукт смотрится выгодно на фоне конкурентов из-за низкого порога вхождения и простоты эксплуатации. ZeroMQ позволяет легко описывать сложные сетевые топологии, варианты обмена данными между тредами (in-process), процессами (inter-process), а также распределенными приложениями. Ниже приведен пример publisher-сервера (Листинг 4), который отдает подписчикам обновления погоды, опубликованные в руководстве по использованию ZeroMQ[13] :

Листинг 5.
import java.util.Random;
import org.zeromq.ZMQ;
//
//  Weather update server in Java
//  Binds PUB socket to tcp://*:5556
//  Publishes random weather updates
//
//  Nicola Peduzzi <thenikso@gmail.com>
//
public class wuserver {
  public static void main(String[] args) {
      //  Prepare our context and publisher
      ZMQ.Context context = ZMQ.context(1);
      ZMQ.Socket publisher = context.socket(ZMQ.PUB);
      publisher.bind("tcp://*:5556");
      publisher.bind("ipc://weather");
      //  Initialize random number generator
      Random srandom = new Random(System.currentTimeMillis());
      while (true) {
          //  Get values that will fool the boss
          int zipcode, temperature, relhumidity;
          zipcode = srandom.nextInt(100000) + 1;
          temperature = srandom.nextInt(215) - 80 + 1;
          relhumidity = srandom.nextInt(50) + 10 + 1;
          //  Send message to all subscribers
          String update = String.format("%05d %d %d\u0000", zipcode, temperature, relhumidity);
          publisher.send(update.getBytes(), 0);
      }
  }
}

Говоря о сетевых библиотеках Java-мира, непременно стоит выделить Netty[23], зарекомендовавшую себя во многих проектах, например Twitter[22]. Netty – оптимизированный фреймворк для создания быстрых и масштабируемых сетевых приложений, поддерживающий большое количество протоколов по умолчанию, а также предоставляющий средства для простой реализации собственных протоколов поверх TCP/UDP. Единственный момент, который препятствует использованию netty в системах реального времени с жесткими требованиями по времени отклика – это необходимость создания отдельного буфера для каждого сетевого сообщения, что повышает нагрузку на сборщик мусора.  Однако на данный момент эта характеристика уже исправляется инженерами Netty, и в 4 версии фреймворка будет доступна возможность использования пула объектов.

В некоторых ситуациях существует необходимость привязать определенный поток к определенному процессу. Данная функция отсуствует в JDK, но существует библиотека Java Thread Affinity[19,24], которая может выполнить подобное требование. В случае, когда необходимо добиться минимальной задержки в обработке сетевых пакетов, предложенная функция чрезвычайно полезна. К примеру, можно заставить определенный процессор обрабатывать прерывания с сетевой карточки (описание процесса можно найти здесь в случае ОС Linux [20]), и к этому же процессору привязать поток, который использует данные пакеты из сети. Таким образом, мы добиваемся минимальной возможной задержки, так как данные не покидают процессор.

Применение ядра реального времени для достижения детерминизма

Рассмотрим критическую важность использования ядра реального времени[17] на результатах, полученных создателями ZeroMQ[14]. Например, SUSE Linux Enterprise Real Time Extensions[15] или Red Hat Enterprise MRG[16]. На рисунках 4 и 5 показаны замеры времени отправки сообщения между двумя серверами в локальной сети. В первом варианте на серверах установлена система SUSE Linux Enterprise, а во втором – Real Time Extension, немного увеличивающая среднее время, но зато исключающая пики и приближающее значение максимума к среднему показателю.

Таким образом, важно осознавать, что, даже используя хорошо написанные библиотеки, нужно внимательно относиться к тому, в каком окружении запускается приложение – каковы операционная система, драйверы, демоны-процессы, аппаратная составляющая. Полезной практикой является выключение ненужных сервисов: irq_balance и  cpu_speed, которые могут увеличить вариационную составляющую выполнения программы. Кроме того, хорошим документом по настройке Linux для достижения минимальных задержек является труд инженеров IBM [21].

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


Рис. 4. Время передачи сообщения на SUSE Linux Enterprise. Источник zeromq.org


Рис.5. Время передачи сообщения на SUSE Linux Enterprise Real Time Extension.Источник картинки [14].

Заключение

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

Список литературы

1. Real-Time Specification for Java  http://www.rtsj.org/

2. http://www.atego.com/products/aonix-perc/

3. http://www.aicas.com/jamaica.html

4. Sun Java Real-Time System, http://java.sun.com/javase/technologies/realtime.jsp

5. IBM WebSphere Real Time V3.0, http://www-01.ibm.com/common/ssi/cgi-bin/ssialias?subtype=ca&infotype=an&appname=iSource&supplier=897&letternum=ENUS211-279

6.http://www.ibm.com/developerworks/java/library/j-rtj1/

7.http://www.azulsystems.com/

8. http://www.oracle.com/technetwork/middleware/jrockit/overview/index.html

9. http://acs.lbl.gov/software/colt/

10. http://pcj.sourceforge.net/

11. http://fastutil.dsi.unimi.it/

12.http://www.zeromq.org/results:rt-tests-v031

13. http://zguide.zeromq.org/java:wuserver

14. http://www.zeromq.org/results:rt-tests-v031

15. http://www.suse.com/products/realtime/

16. http://www.redhat.com/products/mrg/

17. https://rt.wiki.kernel.org/

18. http://javolution.org/

19. http://vanillajava.blogspot.com/2011/12/thread-affinity-library-for-java.html

20. http://lserinol.blogspot.com/2009/02/irq-affinity-in-linux.html

21. http://publib.boulder.ibm.com/infocenter/lnxinfo/v3r0m0/topic/performance/rtbestp/rtbestp_pdf.pdf

22. http://engineering.twitter.com/2011/04/twitter-search-is-now-3x-faster_1656.html

23.http://netty.io/

24. http://vanillajava.blogspot.com/2012/02/how-much-difference-can-thread-affinity.html

25. http://www.tiobe.com/index.php/content/paperinfo/tpci/index.html

26. http://vanillajava.blogspot.com/2011/07/low-gc-in-java-use-primitives-instead.html

27. http://javolution.org/target/site/apidocs/javolution/util/FastMap.html

28.http://www.ibm.com/developerworks/java/library/j-rtj2/

29. http://docs.oracle.com/cd/E13221_01/wlrt/docs30/intro_wlrt/tuning.html


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