Одним из широко освещаемых свойств фреймворка java.NIO является неблокируемость, что означает спо­соб­ность к параллельному выполнению операций ввода-вывода и вычислений. Если приложение, запросившее чтение файла, имеет вычислительную задачу, которую можно обработать до получения данных из файла, то становится возможным одновременное выполнение этих операций. В случае отложенной записи, возможностей для параллелизма еще больше, так как при записи, в отличие от чтения, приложение не ожидает поступления данных.

Примечание. Хотелось бы озаглавить эту статью «NIO: производительность или и совместимость?», но в силу известных ограничений приходится цитировать имена этих древних греческих старух.

Фактором успеха здесь является аппаратная поддержка. Контроллеры mass storage устройств, такие как SATA AHCI (Advanced Host Controller Interface) и NVMe (Non-Volatile Memory Interface for PCI Express) способны обрабатывать достаточно длин­ные последовательности операций ввода-вывода и перемещать данные между оперативной памятью и нако­пи­те­лем в режиме bus-master, без участия программы, выполняемой центральным процессором.

Список команд, формируемый драйвером AHCI в оперативной памяти и аппаратно интерпретируемый контроллером

Рис.1 Список команд, формируемый драйвером AHCI в оперативной памяти и аппаратно интерпретируемый контроллером, может содержать до 32 дескрипторов операций ввода-вывода. Иллюстрация из документа AHCI Specification

Противоречия и решения


Здесь мы подходим к еще одной, менее очевидной, но при этом очень важной характеристике фреймворка NIO, в основе которого два взаимно-противоречивых критерия:

  1. С одной стороны, Java, как язык кросс-платформенного программирования, абстрагирован от аппаратных характеристик вычислительной системы, архитектуры и даже разрядности центрального процессора. Совместимость требует набора абстракций, надежно отделяющих прикладного программиста от низкоуровневых подробностей. Достаточно вспомнить отсутствие указателей в Java.
  2. С другой стороны, производительность требует детальной оптимизации кода, его адаптации под особенности конкретной выполняющей среды и тип применяемого аппаратного обеспечения.


Java Native Interface


Одним из альтернативных решений является сопряжение Java-классов и библиотек, написанных на C или ассемблере. Здесь нельзя не упомянуть нативные классы, реализующие интерфейс JNI (Java Native Interface), основанный на классических конвенциях вызова, стандартизуемых для каждой операционной системы и дополняемых механизмом, обеспечивающим взаимодействие JVM и нативного кода. Кстати, овладев технологией JNI, можно получить доступ к указателям, отсутствие которых в Java, иногда доставляет неудобство.

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

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

NIO


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

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

Для пользовательских приложений, накопитель или файл представлен функциями ОС API дискового ввода-вывода. Не будем рассматривать возможность прямого программирования регистров контроллера дисков пользовательским приложением, канувшую в прошлое со времен MS-DOS, из очевидных соображений совместимости и безопасности.

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

Итак, рассматриваемые объекты аппаратной платформы, это:

  • Канал связи с накопителем или файлом (ОС API).
  • Диапазон оперативной памяти, буфер.

Реализовав Java-классы, прямо соответствующие двум названным компонентам «объективной реальности», можно получить высокопроизводительное решение, за счет минимизации количества вспомогательных операций, что и сделали разработчики фреймворка NIO, в основе которого концепция каналов и буферов.

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

Утилита NIOBench


Утилита NIOBench, разработанная IC Book Labs и предназначенная для измерения производительности mass storage подсистемы, иллюстрирует сказанное, используя каналы и буферы при выполнении файловых операций.

Утилита NIOBench, вывод результатов измерения скорости чтения, записи и копирования файлов на жестком диске

Рис.2 Утилита NIOBench, вывод результатов измерения скорости чтения, записи и копирования файлов на жестком диске ноутбука ASUS N750JK (при обработке данных используется медиана и среднее арифметическое)

Текстовый рапорт утилиты NIOBench с детальным протоколированием результатов

Рис.3 Текстовый рапорт утилиты NIOBench с детальным протоколированием результатов: очевидно влияние кэширования

Методы ввода-вывода, в частности операции чтения, записи и копирования файлов, являющиеся объектом бенчмарок, основаны на следующих архитектурных элементах.

  • Объект FileChannel соответствует каналу, созданному на основе читаемого или записываемого файла. Наряду с простейшими операциями чтения и записи, поддерживаются методы transferTo(), transferFrom(), источником и получателем для которых могут быть объекты файловой системы. Такая особенность очень важна, поскольку позволяет копировать содержимое файлов одним java-оператором, изящно избавляясь от лишних пересылок данных между несколькими буферами. Надо сознаться, что эффективность оптимизации методов копирования, характерная для фреймворка NIO, сыграла злую шутку в процессе разработки и отладки бенчмарок: измеренная скорость копирования иногда оказывалась выше скорости записи.

  • Объект Buffer соответствует диапазону оперативной памяти. При всей очевидности этого понятия, отметим, что для минимизации количества транзитных перемещений данных, рекомендуется создавать прямой буфер, используя метод ByteBuffer.allocateDirect();

Несоблюдение этой рекомендации может привести к снижению производительности, обусловленному необходимостью преобразования Java-абстракций в нативные объекты, передаваемые на обработку функциям ОС API. Интерфейс JNI также используется в проекте NIOBench, для подключения библиотек поддержки аппаратного генератора случайных чисел на основе процессорной инструкции RDRAND (статья об этом «Java-бенчмарки: случайные паттерны и закономерные результаты» в процессе написания).

Резюме


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

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

Ссылки


Поделиться с друзьями
-->

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


  1. gorodnev
    13.09.2016 20:42
    +1

    неблокируемость, что означает спо­соб­ность к параллельному выполнению операций ввода-вывода и вычислений


    Далеко не Java-специалист, но imho неблокируемость не означает параллельность, скорее асинхронность.


    1. icbook
      13.09.2016 20:48

      Конечно, асинхронность. Режим тестирования по умолчанию в утилите NIOBench так и называется Asynchronous (см. скриншот).


    1. icbook
      13.09.2016 21:41

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


  1. vladimir_dolzhenko
    14.09.2016 11:20
    +1

    Не далее как недавно коллега решил с лёгкой руки заменить запись на диск (последовательная запись файла) с Classic IO на NIO — и как результат понижение произодительности

    Ради небольшого теста — пишем 2Гб на диск

    Classic IO (буфферизированная запись BufferedOutputsteam 1M)

    /tmp/test-classicio-3968394580096440112.tmp took 7994.238 ms

    Mapped file of FileChannel
    /tmp/test-channel-3465783895111396195.tmp took 16993.815 ms

    1M Direct ByteBuffer с последующим копированием в FileChannel
    /tmp/test-channel-bb-3779212028599883329.tmp took 21364.169 ms

    1M Heap ByteBuffer с последующим копированием в FileChannel
    /tmp/test-channel-heap-bb-3642623955892374473.tmp took 23544.936 ms


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


    1. icbook
      14.09.2016 12:14

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

      Объекты фреймворка NIO, в частности прямые (нативные) буферы оптимальны для этой цели.

      В то же время, если существующий контекст Вашего приложения ориентирован на потоки, то модификация с IO на NIO «вырванного из контекста» фрагмента может ухудшить производительность.

      Нам важно получить максимальную зависимость от аппаратной производительности диска. Подавляя с этой целью спекулятивные механизмы JVM и ОС, мы неизбежно снижаем производительность.

      С точки зрения использования отложенной записи, прочие условия были равными в Ваших опытах? Ваши примеры для NIO не использовали атрибут синхронизации DSYNC?

      У нас так (для асинхронного режима, без атрибута DSYNC):

      // декларация переменных для каналов
      private static FileChannel in[], out[];


      // создание канала для файла-источника
      in[i] = FileChannel.open( srcPaths[i], CREATE, APPEND );


      // создание канала для файла-получателя
      out[i] = FileChannel.open( dstPaths[i], CREATE, WRITE );


      // пример копирования
      in[i].transferTo( 0, in[i].size(), out[i] );


      1. vladimir_dolzhenko
        14.09.2016 12:46
        +1

        1. icbook
          14.09.2016 14:20

          Попробуйте записывать 10-20 файлов, а затем вывести среднее и медиану от полученных результатов. Здесь важно значение в установившемся режиме, в то время как результат нескольких первых файлов может быть экстремальным из-за отложенной записи.

          У Вас основные потери времени могут быть связаны с программными циклами внутри измеряемых интервалов, между двумя вызовами метода System.nanoTime();

          Например:
          for (int i = 0; i < (size / bs.length); i++)

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

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


          1. vladimir_dolzhenko
            14.09.2016 14:35

            к чему мне медиана и среднее, если файл пишется один раз? и как бы если за 2 гб оно не выдало макс… это что-то уже не то


            1. icbook
              14.09.2016 15:14

              Если пишется один файл размером 2 гигабайта, на платформе с 8-16 гигабайт памяти, то искажение, связанное с отложенной записью может иметь место. Рекомендация попробовать 10-20 таких файлов связана с этим фактом, а не только для того, чтобы получить медиану и среднее. Статистика здесь не самоцель.

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


    1. webkumo
      14.09.2016 14:39

      Хм… а там файловая подсистема ОС не вмешалась, случаем? Через что идёт реализация каналов? (Классическое IO, насколько помню, работает напрямую с файловой подсистемой ОС, которая в свой кеш может сожрать до полугига-гига сохраняемых файлов… и таким образом выдать некорректные результаты бенчмаркинга)


      1. vladimir_dolzhenko
        14.09.2016 14:42

        была такая мысль — пробовали разные размеры — и 50Мб, и 100Мб, и 500Мб, и разные ОС (windows / linux) — результат в целом один и тот же


      1. icbook
        14.09.2016 15:05

        У нас, кстати, в процессе тестирование при прочих равных условиях в среде Windows и под Linux Ubuntu 16.04 LTS.