Углубленный анализ принципов Linux IO и реализация нескольких механизмов нулевого копирования.

Java Linux

предисловие

Технология нулевого копирования означает, что когда компьютер выполняет операции, ЦП не нужно сначала копировать данные из одной области памяти в другую область памяти, что может сократить переключение контекста и время копирования ЦП. Его функция заключается в уменьшении количества копий данных, сокращении системных вызовов, реализации нулевого участия ЦП и полном исключении нагрузки на ЦП в процессе передачи дейтаграмм с сетевого устройства в пространство пользовательской программы. Наиболее важными технологиями, используемыми для достижения нулевого копирования, являются технология передачи данных DMA и технология отображения области памяти.

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

текст

1. Физическая память и виртуальная память

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

1.1 Физическая память

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

1.2 Виртуальная память

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

Адреса виртуальной памяти тесно связаны с пользовательскими процессами, вообще говоря, физические адреса, на которые указывает один и тот же виртуальный адрес, в разных процессах разные, поэтому говорить о виртуальной памяти, не выходя из процесса, бессмысленно. Размер виртуального адреса, который может использовать каждый процесс, связан с количеством бит ЦП. В 32-разрядной системе размер виртуального адресного пространства составляет 2^32 = 4G, в 64-разрядной системе размер виртуального адресного пространства составляет 2^64 = 2^34G, а фактическая физическая память может быть намного меньше, чем размер виртуальной памяти. Каждый пользовательский процесс поддерживает отдельную таблицу страниц (Page Table), а виртуальная и физическая память отображаются в адресное пространство через эту таблицу страниц. Ниже приведена схема сопоставления адресов между пространством виртуальной памяти двух процессов A и B и соответствующей физической памятью:

Когда процесс выполняет программу, ему необходимо прочитать инструкцию процесса из первой памяти, а затем выполнить ее.Для получения инструкции используется виртуальный адрес. Этот виртуальный адрес определяется при линковке программы (диапазон адресов динамической библиотеки корректируется при загрузке ядра и инициализации процесса). Чтобы получить фактические данные, ЦП должен преобразовать виртуальный адрес в физический адрес.Когда ЦП преобразует адрес, ему необходимо использовать таблицу страниц (таблицу страниц) процесса и данные в таблице страниц. (Таблица страниц) поддерживается операционной системой.

Среди них таблица страниц (Page Table) может быть просто понята как связанный список с одним отображением памяти (Memory Mapping) (конечно, фактическая структура очень сложна), каждое отображение памяти (Memory Mapping) внутри сопоставляет виртуальный адрес с конкретное адресное пространство (физическая память или дисковое пространство). У каждого процесса есть своя таблица страниц, которая не имеет ничего общего с таблицами страниц других процессов.

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

  1. Пользовательский процесс отправляет запрос памяти в операционную систему.
  2. Система проверит, не израсходовано ли виртуальное адресное пространство процесса, и, если оно осталось, назначит процессу виртуальный адрес
  3. Отображение памяти (Memory Mapping), созданное системой для этого виртуального адреса и помещенное в таблицу страниц (Page Table) процесса
  4. Система возвращает виртуальный адрес пользовательскому процессу, и пользовательский процесс начинает обращаться к виртуальному адресу.
  5. ЦП находит соответствующее отображение памяти (Memory Mapping) в таблице страниц (Page Table) процесса по виртуальному адресу, но отображение памяти (Memory Mapping) не связано с физической памятью, поэтому прерывание по ошибке страницы генерируется
  6. После того, как операционная система получает прерывание сбоя страницы, она выделяет реальную физическую память и связывает ее с соответствующим отображением памяти (Memory Mapping) таблицы страниц. После завершения обработки прерывания ЦП может получить доступ к памяти.
  7. Конечно, прерывание по ошибке страницы происходит не каждый раз, оно используется только тогда, когда система считает необходимым задержать выделение памяти, то есть во многих случаях система будет выделять реальную физическую память и сопоставлять ее с памяти в третьем шаге выше., чтобы связать.

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

  • Адресное пространство: Обеспечьте большее адресное пространство, и адресное пространство является непрерывным, что упрощает написание и компоновку программ.
  • Изоляция процессов: нет никакой связи между виртуальными адресами разных процессов, поэтому работа одного процесса не повлияет на другие процессы.
  • Защита данных: каждый блок виртуальной памяти имеет соответствующие атрибуты чтения и записи, которые могут защитить сегмент кода программы от изменения, блок данных не может быть выполнен и т. д., что повышает безопасность системы.
  • Отображение памяти: с виртуальной памятью файлы (исполняемые файлы или динамические библиотеки) на диске могут быть напрямую сопоставлены с виртуальным адресным пространством. Таким образом, физическая память может быть выделена с задержкой, только когда соответствующий файл нужно прочитать, он может быть действительно загружен с диска в память, а когда памяти мало, эта часть памяти может быть загружена. быть очищенным для повышения эффективности использования физической памяти, все прозрачно для приложения
  • Общая память: например, динамической библиотеке нужно хранить только одну копию в памяти, а затем сопоставлять ее с виртуальным адресным пространством разных процессов, чтобы процесс чувствовал, что он имеет исключительное право на использование этого файла. Разделение памяти между процессами также может быть достигнуто путем сопоставления одной и той же физической памяти с разными виртуальными адресными пространствами процесса.
  • Управление физической памятью: все физическое адресное пространство управляется операционной системой, и процессы не могут быть напрямую выделены и освобождены, чтобы система могла лучше использовать память и сбалансировать требования к памяти между процессами.

2. Пространство ядра и пространство пользователя

Ядром операционной системы является ядро, которое не зависит от обычных приложений и имеет доступ к защищенному пространству памяти и доступ к нижележащим аппаратным устройствам. Чтобы предотвратить непосредственное управление ядром пользовательским процессом и обеспечить безопасность ядра, операционная система делит виртуальную память на две части, одна часть — пространство ядра (Kernel-space), а другая часть — пространство пользователя. (Пользовательское пространство). В системе Linux модуль ядра работает в пространстве ядра, и соответствующий процесс находится в состоянии ядра, тогда как пользовательская программа выполняется в пространстве пользователя, и соответствующий процесс находится в состоянии пользователя.

Соотношение виртуальной памяти, занимаемой процессами ядра и пользовательскими процессами, составляет 1:3, а адресное пространство (пространство виртуальной памяти) системы Linux x86_32 составляет 4Гб (2 в 32-й степени), а старшие байты 1Гб (от виртуального адреса От 0xC0000000 до 0xFFFFFFFF) используется процессом ядра, называемым пространством ядра; в то время как младшие байты 3G (от виртуального адреса 0x00000000 до 0xBFFFFFFFF) используются каждым пользовательским процессом, называемым пространством пользователя. На следующем рисунке показано расположение памяти для пользовательского пространства и пространства ядра процесса:

2.1 Пространство ядра

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

  • Частная виртуальная память процесса: каждый процесс имеет отдельный стек ядра, таблицу страниц, структуру задач и структуру mem_map.
  • Общая виртуальная память процесса: область памяти, совместно используемая всеми процессами, включая физическую память, данные ядра и области кода ядра.

2.2 Пользовательское пространство

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

  • Стек из выполнения: он автоматически выделяется компилятором и сохраняет значения параметров функций, локальные переменные и значения возврата метода. Всякий раз, когда вызывается функция, тип возврата функции и некоторая информация о вызовах хранятся в верхней части стека, и после завершения вызова информация о вызове будет выпущена, а память будет выпущена. Область стека растет от высокоадресных битов на низкие адресные биты. Это непрерывная внутренняя область. Максимальная емкость предопределяется системой. Когда прилагаемое пространство стека превышает этот предел, и пользователь может быть подсказке Получить его из стека. Пространство мало.
  • Запуск: Сегмент памяти, используемый для хранения процесса, запущенного в процессе, расположенном посередине BSS и стека. Подать заявку на распространение (Malloc) и выпустить (бесплатно). Куча состоит из младшего бита адреса в старший бит адреса с использованием цепной структуры хранения. Частое использование Malloc/Free приводит к прерывистому пространству памяти, производя большое количество мусора. При подаче заявки на место в куче библиотечная функция ищет достаточно места в соответствии с определенным алгоритмом. Поэтому эффективность кучи намного ниже стека.
  • Сегмент кода: хранит машинные инструкции, которые может выполнять ЦП.Эта часть памяти доступна только для чтения, но не для записи. Обычно область кода является общей, то есть ее могут вызывать другие выполняющиеся программы. Если на машине есть несколько процессов, выполняющих одну и ту же программу, то они могут использовать один и тот же сегмент кода.
  • Сегмент неинициализированных данных: храните неинициализированные глобальные переменные, данные BSS инициализируются до 0 или NULL до того, как программа начнет выполняться.
  • Сегмент инициализированных данных: хранит инициализированные глобальные переменные, включая статические глобальные переменные, статические локальные переменные и константы.
  • Область сопоставления памяти: например, память виртуального пространства, такого как динамическая библиотека и разделяемая память, сопоставляется с памятью физического пространства, которое обычно представляет собой пространство виртуальной памяти, выделенное функцией mmap.

3. Внутренняя иерархия Linux

Режим ядра может выполнять произвольные команды и вызывать все ресурсы системы, в то время как пользовательский режим может выполнять только простые операции и не может напрямую вызывать системные ресурсы. Пользовательский режим должен передавать системный интерфейс (системный вызов) для выдачи инструкций ядру. Например, когда пользовательский процесс запускает bash, он инициирует системный вызов службы pid ядра через getpid() для получения идентификатора текущего пользовательского процесса; когда пользовательский процесс просматривает конфигурацию хоста с помощью команды cat, он вызовет файловые подчиненные ядра.Система инициирует системный вызов.

  • Пространство ядра может получить доступ ко всем инструкциям ЦП и всему пространству памяти, пространству ввода-вывода и аппаратным устройствам.
  • Пользовательское пространство может получить доступ только к ограниченным ресурсам.Если требуются специальные разрешения, соответствующие ресурсы можно получить с помощью системных вызовов.
  • Пространство пользователя допускает разрывы страниц, а пространство ядра — нет.
  • Пространство ядра и пространство пользователя предназначены для линейного адресного пространства.
  • В процессоре x86 пространство пользователя — это диапазон адресов 0–3G, а пространство ядра — диапазон адресов 3G–4G. Диапазон адресов пользовательского пространства процессора x86_64 — 0x0000000000000000 — 0x00007ffffffffffff, а адресное пространство ядра — 0xffff880000000000 — максимальный адрес.
  • Все процессы ядра (потоки) совместно используют адресное пространство, а пользовательские процессы имеют собственное адресное пространство.

Разделив пространство пользователя и пространство ядра, внутреннюю иерархическую структуру Linux можно разделить на три части, снизу вверх — аппаратное обеспечение, пространство ядра и пространство пользователя, как показано на следующем рисунке:

4. Linux I/O методы чтения и записи

Linux предоставляет три механизма передачи данных между диском и основной памятью, опрос, прерывания ввода-вывода и передачу DMA. Метод опроса основан на бесконечном цикле непрерывного обнаружения порта ввода/вывода. Режим прерывания ввода-вывода означает, что при поступлении данных диск активно инициирует запрос прерывания на ЦП, а сам ЦП отвечает за процесс передачи данных. Передача DMA вводит контроллер диска DMA на основе прерываний ввода-вывода.Контроллер диска DMA отвечает за передачу данных, уменьшая массовое потребление ресурсов ЦП операциями прерывания ввода-вывода.

4.1 Принцип прерывания ввода/вывода

До появления технологии прямого доступа к памяти все операции ввода-вывода между приложениями и дисками выполнялись через прерывания ЦП. Каждый раз, когда пользовательский процесс читает данные с диска, ему требуется прерывание ЦП, а затем инициируется запрос ввода-вывода для ожидания завершения чтения и копирования данных.Каждое прерывание ввода-вывода вызывает переключение контекста ЦП.

  1. Пользовательский процесс инициирует системный вызов чтения ЦП для чтения данных, переключается из пользовательского режима в режим ядра, а затем все время блокируется, ожидая возврата данных.
  2. После получения инструкции ЦП инициирует запрос ввода-вывода к диску и сначала помещает данные диска в буфер контроллера диска.
  3. После завершения подготовки данных диск инициирует прерывание ввода-вывода для ЦП.
  4. После того, как ЦП получает прерывание ввода-вывода, он копирует данные из дискового буфера в буфер ядра, а затем из буфера ядра в пользовательский буфер.
  5. Пользовательский процесс переключается из состояния ядра в пользовательское состояние, разблокирует это состояние, а затем ожидает следующих часов времени выполнения ЦП.

4.2 Принцип передачи прямого доступа к памяти

Полное название DMA — прямой доступ к памяти, который представляет собой механизм, позволяющий периферийным устройствам (аппаратным подсистемам) напрямую обращаться к основной памяти системы. Другими словами, на основе метода доступа DMA передача данных между основной памятью системы и жестким диском или сетевой картой может обойти полное планирование ЦП. В настоящее время большинство аппаратных устройств, включая контроллеры дисков, сетевые карты, видеокарты и звуковые карты, поддерживают технологию DMA.

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

После того, как дисковый контроллер DMA берет на себя запросы на чтение и запись данных, ЦП освобождается от тяжелых операций ввода-вывода.Процесс операции чтения данных выглядит следующим образом:

  1. Пользовательский процесс инициирует системный вызов чтения ЦП для чтения данных, переключается из пользовательского режима в режим ядра, а затем все время блокируется, ожидая возврата данных.
  2. После получения команды ЦП инициирует команду планирования на дисковый контроллер прямого доступа к памяти.
  3. Контроллер диска DMA инициирует запрос ввода-вывода к диску и сначала помещает данные диска в буфер контроллера диска, а ЦП не участвует в этом процессе.
  4. После завершения чтения данных контроллер диска DMA получит уведомление от диска и скопирует данные из буфера контроллера диска в буфер ядра.
  5. Контроллер диска DMA посылает ЦП сигнал о том, что данные прочитаны, и ЦП отвечает за копирование данных из буфера ядра в пользовательский буфер.
  6. Пользовательский процесс переключается из состояния ядра в пользовательское состояние, разблокирует это состояние, а затем ожидает следующих часов времени выполнения ЦП.

5. Традиционный метод ввода/вывода

Чтобы лучше понять проблему, решаемую нулевым копированием, давайте сначала разберемся с проблемами традиционных методов ввода-вывода. В системе Linux традиционный метод доступа реализуется двумя системными вызовами, write() и read(), чтение файла в буферную область с помощью функции read(), а затем использование метода write() для сохранения данных. в кеше.Вывод на сетевой порт, псевдокод такой:

read(file_fd, tmp_buf, len);
write(socket_fd, tmp_buf, len);

Следующие рисунки соответствуют процессу чтения и записи данных традиционных операций ввода-вывода.Весь процесс включает 2 копии ЦП, 2 копии прямого доступа к памяти, всего 4 копии и 4 переключения контекста.Нижеследующее кратко описывает связанные концепции.

  • Переключение контекста: когда программа пользователя инициирует системный вызов ядра, ЦП переключает пользовательский процесс из состояния пользователя в состояние ядра; когда системный вызов возвращается, ЦП переключает пользовательский процесс из состояния ядра в состояние пользователя. .
  • Копирование ЦП: передача данных напрямую обрабатывается ЦП, и ресурсы ЦП всегда будут заняты при копировании данных.
  • Копирование DMA: ЦП выдает команду контроллеру диска DMA, а контроллер DMA обрабатывает передачу данных.После завершения передачи данных информация возвращается обратно в ЦП, тем самым снижая степень занятости ресурсов ЦП.

5.1 Традиционные операции чтения

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

read(file_fd, tmp_buf, len);

Основываясь на традиционном методе чтения ввода-вывода, системный вызов чтения вызовет переключение контекста 2, копирование DMA 1 и копирование ЦП 1. Процесс инициирования чтения данных выглядит следующим образом:

  1. Пользовательский процесс инициирует системный вызов ядра (kernel) через функцию read(), и контекст переключается из пользовательского пространства (user space) в пространство ядра (kernel space).
  2. ЦП использует контроллер DMA для копирования данных из оперативной памяти или жесткого диска в буфер чтения в пространстве ядра.
  3. CPU копирует данные из буфера чтения в пользовательский буфер в пространстве пользователя.
  4. Контекст переключается из пространства ядра в пространство пользователя, вызов чтения выполняется и возвращается.

5.2 Традиционная операция записи

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

write(socket_fd, tmp_buf, len);

Основываясь на традиционном методе записи ввода-вывода, системный вызов write() вызовет 2 переключения контекста, 1 копию ЦП и 1 копию DMA.Программа пользователя отправляет сетевые данные следующим образом:

  1. Пользовательский процесс инициирует системный вызов ядра (kernel) через функцию write(), и контекст переключается из пользовательского пространства (user space) в пространство ядра (kernel space).
  2. ЦП копирует данные из пользовательского буфера в буфер сокета в пространстве ядра.
  3. ЦП использует контроллер DMA для копирования данных из сетевого буфера (буфера сокетов) на сетевую карту для передачи данных.
  4. Контекст переключается из пространства ядра в пространство пользователя, и системный вызов записи возвращается.

6. Метод нулевого копирования

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

  • Прямой ввод-вывод в пользовательском режиме: прикладная программа может напрямую обращаться к аппаратному хранилищу, а ядро ​​операционной системы только способствует передаче данных. Таким образом, по-прежнему происходит переключение контекста между пространством пользователя и пространством ядра, а данные на оборудовании копируются напрямую в пространство пользователя, минуя пространство ядра. Следовательно, копирование данных между буферами пространства ядра и буферами пользовательского пространства для прямого ввода-вывода не выполняется.
  • Уменьшите количество копий данных: в процессе передачи данных избегайте копирования данных ЦП между буфером пространства пользователя и буфером пространства ядра системы, а также копирования данных ЦП в пространство ядра системы, что также является реализацией текущие основные идеи технологии нулевого копирования.
  • Технология копирования при записи. Копирование при записи означает, что несколько процессов совместно используют один и тот же фрагмент данных. Если одному из процессов необходимо изменить данные, он будет скопирован в адресное пространство своего собственного процесса, если только данные читается Операция не требует операции копирования.

6.1 Прямой ввод/вывод пользовательского режима

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

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

6.2. mmap + write

Метод нулевого копирования заключается в использовании mmap + write вместо исходного метода чтения + записи, что сокращает операцию копирования на 1 ЦП. mmap — это метод файла с отображением памяти, предоставляемый Linux, который сопоставляет виртуальный адрес в адресном пространстве процесса с адресом файла на диске.Псевдокод mmap + write выглядит следующим образом:

tmp_buf = mmap(file_fd, len);
write(socket_fd, tmp_buf, len);

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

Основываясь на методе нулевого копирования системных вызовов mmap + write, весь процесс копирования будет иметь 4 переключения контекста, 1 копию ЦП и 2 копии DMA.Программа пользователя читает и записывает данные следующим образом:

  1. Пользовательский процесс инициирует системный вызов ядра (kernel) через функцию mmap(), и контекст переключается из пользовательского пространства (user space) в пространство ядра (kernel space).
  2. Выполните сопоставление адресов памяти между буфером чтения пространства ядра (буфером чтения) пользовательского процесса и областью буфера пространства пользователя (пользовательским буфером).
  3. ЦП использует контроллер DMA для копирования данных из оперативной памяти или жесткого диска в буфер чтения в пространстве ядра.
  4. Контекст переключается из пространства ядра в пространство пользователя, и возвращается системный вызов mmap.
  5. Пользовательский процесс инициирует системный вызов ядра (kernel) через функцию write(), и контекст переключается из пользовательского пространства (user space) в пространство ядра (kernel space).
  6. Сетевой буфер (буфер сокета), в который ЦП копирует данные из буфера чтения (буфера чтения).
  7. ЦП использует контроллер DMA для копирования данных из сетевого буфера (буфера сокетов) в сетевую карту для передачи данных.
  8. Контекст переключается из пространства ядра в пространство пользователя, и системный вызов записи возвращается.

В основном mmap используется для повышения производительности ввода-вывода, особенно для больших файлов. Для небольших файлов файлы с отображением памяти приведут к пустой трате фрагментированного пространства, поскольку отображение памяти всегда выравнивается по границам страниц, а минимальный размер составляет 4 КБ. Файл размером 5 КБ будет отображаться и занимать 8 КБ памяти, что приведет к потере 3 КБ ОЗУ.

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

6.3. sendfile

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

sendfile(socket_fd, file_fd, len);

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

Основываясь на методе нулевого копирования системного вызова sendfile, весь процесс копирования будет иметь 2 переключения контекста, 1 копию ЦП и 2 копии DMA.Программа пользователя читает и записывает данные следующим образом:

  1. Пользовательский процесс инициирует системный вызов ядра (kernel) через функцию sendfile(), и контекст переключается из пользовательского пространства (user space) в пространство ядра (kernel space).
  2. ЦП использует контроллер DMA для копирования данных из оперативной памяти или жесткого диска в буфер чтения в пространстве ядра.
  3. Сетевой буфер (буфер сокета), в который ЦП копирует данные из буфера чтения (буфера чтения).
  4. ЦП использует контроллер DMA для копирования данных из сетевого буфера (буфера сокетов) на сетевую карту для передачи данных.
  5. Контекст переключается из пространства ядра в пространство пользователя, и возвращается системный вызов sendfile.

По сравнению с отображением памяти mmap, sendfile имеет на 2 переключения контекста меньше, но по-прежнему имеет 1 операцию копирования ЦП. Проблема с sendfile заключается в том, что пользовательская программа не может изменять данные, а просто завершает процесс передачи данных.

6.4. sendfile + DMA gather copy

Ядро Linux 2.4 модифицирует системный вызов sendfile, чтобы ввести операцию сбора копий DMA. Он записывает соответствующую информацию описания данных (адрес памяти, смещение адреса) в буфер чтения (буфер чтения) пространства ядра в соответствующий сетевой буфер (буфер сокета), а DMA записывает соответствующую информацию описания данных (адрес памяти, адрес offset) в соответствии с адресом памяти, Смещение адреса копирует данные из буфера чтения на устройство сетевой карты пакетами, что экономит только одну операцию копирования ЦП, оставшуюся в пространстве ядра. Псевдокод sendfile выглядит следующим образом:

sendfile(socket_fd, file_fd, len);

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

Основываясь на методе нулевого копирования системного вызова sendfile + DMA collect copy, весь процесс копирования будет иметь 2 переключения контекста, 0 копий ЦП и 2 копии DMA.Программа пользователя читает и записывает данные следующим образом:

  1. Пользовательский процесс инициирует системный вызов ядра (kernel) через функцию sendfile(), и контекст переключается из пользовательского пространства (user space) в пространство ядра (kernel space).
  2. ЦП использует контроллер DMA для копирования данных из оперативной памяти или жесткого диска в буфер чтения в пространстве ядра.
  3. ЦП копирует файловый дескриптор и длину данных буфера чтения в буфер сокета.
  4. На основе скопированного файлового дескриптора (дескриптора файла) и длины данных ЦП использует операцию сбора/разброса контроллера DMA для прямого копирования данных из буфера чтения ядра (буфера чтения) в сетевую карту для передачи данных в пакетах.
  5. Контекст переключается из пространства ядра в пространство пользователя, и возвращается системный вызов sendfile.

Метод sendfile + DMA collect copy copy также имеет проблему, заключающуюся в том, что пользовательская программа не может изменять данные и сама нуждается в поддержке оборудования.Он подходит только для процесса передачи копирования данных из файла в сокет-сокет.

6.5. splice

sendfile подходит только для копирования данных из файла в сокет-сокет и требует аппаратной поддержки, что также ограничивает область его использования. Linux представил системный вызов splice в версии 2.6.17, который не только не требует аппаратной поддержки, но и реализует нулевое копирование данных между двумя файловыми дескрипторами. Псевдокод для сплайсинга выглядит следующим образом:

splice(fd_in, off_in, fd_out, off_out, len, flags);

Системный вызов splice может установить конвейер между буфером чтения (буфером чтения) и сетевым буфером (буфером сокета) в пространстве ядра, что позволяет избежать операции копирования ЦП между ними.

Основываясь на методе нулевого копирования системного вызова splice, весь процесс копирования будет иметь 2 переключения контекста, 0 копий ЦП и 2 копии DMA.Программа пользователя читает и записывает данные следующим образом:

  1. Пользовательский процесс инициирует системный вызов ядра (ядра) через функцию splice(), и контекст переключается из пространства пользователя (пространства пользователя) в пространство ядра (пространство ядра).
  2. ЦП использует контроллер DMA для копирования данных из оперативной памяти или жесткого диска в буфер чтения в пространстве ядра.
  3. ЦП устанавливает конвейер между буфером чтения (буфером чтения) и сетевым буфером (буфером сокета) в пространстве ядра.
  4. ЦП использует контроллер DMA для копирования данных из сетевого буфера (буфера сокетов) на сетевую карту для передачи данных.
  5. Контекст переключается из пространства ядра в пространство пользователя, а системный вызов splice выполняется и возвращается.

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

6.6. Копирование при записи

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

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

6.7 Совместное использование буфера

Метод совместного использования буфера полностью переписывает традиционную операцию ввода-вывода, поскольку традиционный интерфейс ввода-вывода основан на копировании данных. Чтобы избежать копирования, исходный набор интерфейсов необходимо удалить и переписать, поэтому этот метод является более полным. более зрелым решением является fbuf (Fast Buffer, быстрый буфер), реализованный на Solaris.

Идея fbuf заключается в том, что каждый процесс поддерживает пул буферов, который может быть сопоставлен с пространством пользователя и пространством ядра одновременно Ядро и пользователи совместно используют этот пул буферов, чтобы избежать серии операций копирования.

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

7. Сравнение Linux с нулевым копированием

Будь то традиционный метод копирования ввода-вывода или введение метода нулевого копирования, 2 копии прямого доступа к памяти необходимы, поскольку два прямых доступа к памяти выполняются аппаратно. Ниже приведены различия между описанными выше методами копирования ввода-вывода с точки зрения времени копирования ЦП, времени копирования DMA и системных вызовов.

метод копирования Копия процессора копия прямого доступа к памяти системный вызов переключатель контекста
Традиционный способ (чтение + запись) 2 2 read / write 4
Отображение памяти (mmap + запись) 1 2 mmap / write 4
sendfile 1 2 sendfile 2
sendfile + DMA gather copy 0 2 sendfile 2
splice 0 2 splice 2

8. Реализация Java NIO с нулевым копированием

Канал в Java NIO эквивалентен буферу в пространстве ядра операционной системы, а буфер соответствует пользовательскому буферу в пространстве пользователя операционной системы (user buffer).

  • Канал (Channel) является полнодуплексным (двусторонняя передача), может быть буфером чтения (read buffer), также может быть сетевым буфером (socket buffer).
  • Буфер делится на память кучи (HeapBuffer) и память вне кучи (DirectBuffer), которая является памятью пользовательского режима, выделяемой функцией malloc().

Память вне кучи (DirectBuffer) должна быть освобождена приложением вручную после использования, в то время как данные в памяти кучи (HeapBuffer) могут быть автоматически освобождены во время GC. Поэтому при использовании HeapBuffer для чтения и записи данных, чтобы избежать потери данных буфера из-за GC, NIO сначала скопирует данные внутри HeapBuffer в локальную память (собственная память) во временном DirectBuffer. .misc Принцип реализации вызова .Unsafe.copyMemory() подобен принципу memcpy(). Наконец, передайте адрес памяти данных внутри временно сгенерированного DirectBuffer функции вызова ввода-вывода, чтобы избежать доступа к объекту Java для чтения и записи ввода-вывода.

8.1. MappedByteBuffer

MappedByteBuffer — это реализация, предоставленная NIO, основанная на методе отображения памяти без копирования (mmap), который наследуется от ByteBuffer. FileChannel определяет метод map(), который может отображать область размера файла, начиная с position, в отображаемый в памяти файл. Метод абстрактного метода map() определен в FileChannel следующим образом:

public abstract MappedByteBuffer map(MapMode mode, long position, long size)
        throws IOException;
  • режим: ограничивает режим доступа к отображаемой области памяти (MappedByteBuffer) файлом образа памяти, включая три режима: только чтение (READ_ONLY), чтение-запись (READ_WRITE) и копирование при записи (PRIVATE).
  • position: начальный адрес отображения файла, соответствующий первому адресу отображаемой области памяти (MappedByteBuffer).
  • размер: длина сопоставления файла в байтах, количество байтов от позиции и далее, соответствующее размеру отображаемой области памяти (MappedByteBuffer).

По сравнению с ByteBuffer MappedByteBuffer добавляет три важных метода: fore(), load() и isLoad():

  • fore(): для буферов в режиме READ_WRITE принудительно вносить изменения в содержимое буфера в локальный файл.
  • load(): загружает содержимое буфера в физическую память и возвращает ссылку на этот буфер.
  • isLoaded(): возвращает true, если содержимое буфера находится в физической памяти, иначе false.

Ниже приведен пример использования MappedByteBuffer для чтения и записи файлов:

private final static String CONTENT = "Zero copy implemented by MappedByteBuffer";
private final static String FILE_NAME = "/mmap.txt";
private final static String CHARSET = "UTF-8";

  • Запись данных файла: откройте файловый канал fileChannel и предоставьте разрешение на чтение, разрешение на запись и разрешение на очистку данных, сопоставьте его с доступным для записи буфером памяти mappedByteBuffer через fileChannel, запишите целевые данные в mappedByteBuffer и измените буфер методом force(). содержимое принудительно помещается в локальный файл.
@Test
public void writeToFileByMappedByteBuffer() {
    Path path = Paths.get(getClass().getResource(FILE_NAME).getPath());
    byte[] bytes = CONTENT.getBytes(Charset.forName(CHARSET));
    try (FileChannel fileChannel = FileChannel.open(path, StandardOpenOption.READ,
            StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) {
        MappedByteBuffer mappedByteBuffer = fileChannel.map(READ_WRITE, 0, bytes.length);
        if (mappedByteBuffer != null) {
            mappedByteBuffer.put(bytes);
            mappedByteBuffer.force();
        }
    } catch (IOException e) {
        e.printStackTrace();
    }
}

  • Чтение данных файла: откройте файловый канал fileChannel и предоставьте разрешение только на чтение, сопоставьте его с доступным для чтения буфером памяти mappedByteBuffer через fileChannel и прочитайте массив байтов в mappedByteBuffer, чтобы получить данные файла.
@Test
public void readFromFileByMappedByteBuffer() {
    Path path = Paths.get(getClass().getResource(FILE_NAME).getPath());
    int length = CONTENT.getBytes(Charset.forName(CHARSET)).length;
    try (FileChannel fileChannel = FileChannel.open(path, StandardOpenOption.READ)) {
        MappedByteBuffer mappedByteBuffer = fileChannel.map(READ_ONLY, 0, length);
        if (mappedByteBuffer != null) {
            byte[] bytes = new byte[length];
            mappedByteBuffer.get(bytes);
            String content = new String(bytes, StandardCharsets.UTF_8);
            assertEquals(content, "Zero copy implemented by MappedByteBuffer");
        }
    } catch (IOException e) {
        e.printStackTrace();
    }
}

Основной принцип реализации метода map() описан ниже. Метод map() является абстрактным методом java.nio.channels.FileChannel, который реализован подклассом sun.nio.ch.FileChannelImpl.java Ниже приведен основной код, связанный с отображением памяти:

public MappedByteBuffer map(MapMode mode, long position, long size) throws IOException {
    int pagePosition = (int)(position % allocationGranularity);
    long mapPosition = position - pagePosition;
    long mapSize = size + pagePosition;
    try {
        addr = map0(imode, mapPosition, mapSize);
    } catch (OutOfMemoryError x) {
        System.gc();
        try {
            Thread.sleep(100);
        } catch (InterruptedException y) {
            Thread.currentThread().interrupt();
        }
        try {
            addr = map0(imode, mapPosition, mapSize);
        } catch (OutOfMemoryError y) {
            throw new IOException("Map failed", y);
        }
    }
    
    int isize = (int)size;
    Unmapper um = new Unmapper(addr, mapSize, isize, mfd);
    if ((!writable) || (imode == MAP_RO)) {
    	return Util.newMappedByteBufferR(isize, addr + pagePosition, mfd, um);
    } else {
    	return Util.newMappedByteBuffer(isize, addr + pagePosition, mfd, um);
    }
}

Метод map() выделяет часть виртуальной памяти для файла в качестве его отображаемой в память области с помощью собственного метода map0(), а затем возвращает начальный адрес этой отображаемой в памяти области.

  1. Сопоставление файлов требует создания экземпляра MappedByteBuffer в куче Java. Если первое сопоставление файлов вызывает OOM, вручную запустите сборку мусора, засните на 100 мс, а затем попробуйте сопоставление и сгенерируйте исключение, если это не удастся.
  2. Создайте экземпляр DirectByteBuffer с помощью метода newMappedByteBuffer (доступного для чтения и записи) Util или метода newMappedByteBufferR (только для чтения), где DirectByteBuffer является подклассом MappedByteBuffer.

Метод map() возвращает начальный адрес области отображения памяти, а данные указанной памяти можно получить с помощью (начальный адрес + смещение). Это в определенной степени заменяет методы read() или write(), а нижний слой напрямую использует методы getByte() и putByte() класса sun.misc.Unsafe для чтения и записи данных.

private native long map0(int prot, long position, long mapSize) throws IOException;

Выше приведено определение собственного метода map0, который вызывает реализацию базового C через JNI (собственный интерфейс Java). исходный пакет.c в этом исходном файле.

JNIEXPORT jlong JNICALL
Java_sun_nio_ch_FileChannelImpl_map0(JNIEnv *env, jobject this,
                                     jint prot, jlong off, jlong len)
{
    void *mapAddress = 0;
    jobject fdo = (*env)->GetObjectField(env, this, chan_fd);
    jint fd = fdval(env, fdo);
    int protections = 0;
    int flags = 0;

    if (prot == sun_nio_ch_FileChannelImpl_MAP_RO) {
        protections = PROT_READ;
        flags = MAP_SHARED;
    } else if (prot == sun_nio_ch_FileChannelImpl_MAP_RW) {
        protections = PROT_WRITE | PROT_READ;
        flags = MAP_SHARED;
    } else if (prot == sun_nio_ch_FileChannelImpl_MAP_PV) {
        protections =  PROT_WRITE | PROT_READ;
        flags = MAP_PRIVATE;
    }

    mapAddress = mmap64(
        0,                    /* Let OS decide location */
        len,                  /* Number of bytes to map */
        protections,          /* File permissions */
        flags,                /* Changes are shared */
        fd,                   /* File descriptor of mapped file */
        off);                 /* Offset into file */

    if (mapAddress == MAP_FAILED) {
        if (errno == ENOMEM) {
            JNU_ThrowOutOfMemoryError(env, "Map failed");
            return IOS_THROWN;
        }
        return handle(env, -1, "Map failed");
    }

    return ((jlong) (unsigned long) mapAddress);
}

Можно видеть, что функция map0(), наконец, делает вызов карты памяти базовому ядру Linux через функцию mmap64() Прототип функции mmap64() выглядит следующим образом:

#include <sys/mman.h>

void *mmap64(void *addr, size_t len, int prot, int flags, int fd, off64_t offset);

Значение каждого параметра функции mmap64() и необязательные значения параметров подробно описаны ниже:

  • адрес: начальный адрес файла в области отображения памяти пространства пользовательского процесса.Это рекомендуемый параметр, который обычно может быть установлен на 0 или NULL.В это время ядро ​​​​определяет реальный начальный адрес. Когда flags имеет значение MAP_FIXED, addr является обязательным параметром, то есть необходимо указать существующий адрес.
  • len: длина в байтах файла, который необходимо отобразить в память.
  • prot: управляет правами доступа пользовательского процесса к отображаемой области памяти.
    • PROT_READ: разрешение на чтение
    • PROT_WRITE: разрешение на запись
    • PROT_EXEC: разрешение на выполнение
    • PROT_NONE: Нет разрешения
  • flags: определяет, будут ли изменения в отображенной в памяти области совместно использоваться несколькими процессами.
    • MAP_PRIVATE: изменение данных в отображаемой области памяти не будет отражено в реальном файле.При изменении данных используется механизм копирования при записи.
    • MAP_SHARED: изменения в области отображения памяти будут синхронизированы с реальным файлом, и изменения будут видны процессам, совместно использующим эту область отображения памяти.
    • MAP_FIXED: не рекомендуется. В этом режиме параметр addr, указанный параметром addr, должен предоставлять существующий параметр addr.
  • fd: файловый дескриптор. Каждая операция сопоставления приведет к увеличению счетчика ссылок файла на 1, а каждая операция отмены сопоставления или завершение процесса приведет к уменьшению счетчика ссылок на 1.
  • смещение: смещение файла. Позиция отображаемого файла, величина смещения назад от начального адреса файла

Ниже приведены характеристики и недостатки MappedByteBuffer:

  • MappedByteBuffer использует виртуальную память вне кучи, поэтому размер выделенной (карты) памяти не ограничивается параметром -Xmx JVM, но есть и ограничение по размеру.
  • Если файл превышает ограничение в байтах Integer.MAX_VALUE, содержимое файла можно переназначить с помощью параметра position.
  • Производительность MappedByteBuffer действительно очень высока при работе с большими файлами, но есть также проблемы, такие как использование памяти и неуверенное закрытие файлов.Файлы, открытые им, будут закрыты только при сборке мусора, и этот момент времени неясен.
  • MappedByteBuffer предоставляет метод mmap() для отображаемой на файлы памяти и метод unmap() для освобождения отображаемой памяти. Однако unmap() является закрытым методом в FileChannelImpl и не может вызываться напрямую. Следовательно, программа пользователя должна вручную освободить область памяти, занимаемую отображением, вызвав метод clean() класса sun.misc.Cleaner посредством отражения Java.
public static void clean(final Object buffer) throws Exception {
    AccessController.doPrivileged((PrivilegedAction<Void>) () -> {
        try {
            Method getCleanerMethod = buffer.getClass().getMethod("cleaner", new Class[0]);
            getCleanerMethod.setAccessible(true);
            Cleaner cleaner = (Cleaner) getCleanerMethod.invoke(buffer, new Object[0]);
            cleaner.clean();
        } catch(Exception e) {
            e.printStackTrace();
        }
    });
}

8.2. DirectByteBuffer

Ссылка на объект DirectByteBuffer находится в куче модели памяти Java.JVM может управлять выделением памяти и восстановлением объектов DirectByteBuffer.Как правило, статический метод DirectByteBuffer allocateDirect() используется для создания экземпляров DirectByteBuffer и выделения памяти.

public static ByteBuffer allocateDirect(int capacity) {
    return new DirectByteBuffer(capacity);
}

Байтовый буфер внутри DirectByteBuffer располагается в прямой памяти вне кучи (пользовательский режим) Он выделяет память через нативный метод allocateMemory() Unsafe, а базовым вызовом является функция malloc() операционной системы.

DirectByteBuffer(int cap) {
    super(-1, 0, cap, cap);
    boolean pa = VM.isDirectMemoryPageAligned();
    int ps = Bits.pageSize();
    long size = Math.max(1L, (long)cap + (pa ? ps : 0));
    Bits.reserveMemory(size, cap);

    long base = 0;
    try {
        base = unsafe.allocateMemory(size);
    } catch (OutOfMemoryError x) {
        Bits.unreserveMemory(size, cap);
        throw x;
    }
    unsafe.setMemory(base, size, (byte) 0);
    if (pa && (base % ps != 0)) {
        address = base + ps - (base & (ps - 1));
    } else {
        address = base;
    }
    cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
    att = null;
}

Кроме того, при инициализации DirectByteBuffer создается поток Deallocator, а прямая память высвобождается с помощью метода freeMemory() Cleaner.Нижний слой freeMemory() вызывает функцию free() операционной системы.

private static class Deallocator implements Runnable {
    private static Unsafe unsafe = Unsafe.getUnsafe();

    private long address;
    private long size;
    private int capacity;

    private Deallocator(long address, long size, int capacity) {
        assert (address != 0);
        this.address = address;
        this.size = size;
        this.capacity = capacity;
    }

    public void run() {
        if (address == 0) {
            return;
        }
        unsafe.freeMemory(address);
        address = 0;
        Bits.unreserveMemory(size, capacity);
    }
}

Поскольку DirectByteBuffer используется для выделения системной локальной памяти, которая не находится под управлением JVM, восстановление прямой памяти отличается от восстановления динамической памяти.При неправильном использовании прямой памяти легко вызвать OutOfMemoryError.

Сказав все это, какое отношение DirectByteBuffer имеет к нулевому копированию? Как упоминалось ранее, когда MappedByteBuffer выполняет сопоставление памяти, его метод map() создаст экземпляр буфера через Util.newMappedByteBuffer() Код инициализации выглядит следующим образом:

static MappedByteBuffer newMappedByteBuffer(int size, long addr, FileDescriptor fd,
                                            Runnable unmapper) {
    MappedByteBuffer dbb;
    if (directByteBufferConstructor == null)
        initDBBConstructor();
    try {
        dbb = (MappedByteBuffer)directByteBufferConstructor.newInstance(
            new Object[] { new Integer(size), new Long(addr), fd, unmapper });
    } catch (InstantiationException | IllegalAccessException | InvocationTargetException e) {
        throw new InternalError(e);
    }
    return dbb;
}

private static void initDBBRConstructor() {
    AccessController.doPrivileged(new PrivilegedAction<Void>() {
        public Void run() {
            try {
                Class<?> cl = Class.forName("java.nio.DirectByteBufferR");
                Constructor<?> ctor = cl.getDeclaredConstructor(
                    new Class<?>[] { int.class, long.class, FileDescriptor.class,
                                    Runnable.class });
                ctor.setAccessible(true);
                directByteBufferRConstructor = ctor;
            } catch (ClassNotFoundException | NoSuchMethodException |
                     IllegalArgumentException | ClassCastException x) {
                throw new InternalError(x);
            }
            return null;
        }});
}

DirectByteBuffer — это конкретный класс реализации MappedByteBuffer. На самом деле метод Util.newMappedByteBuffer() получает конструктор DirectByteBuffer через отражение, а затем создает экземпляр DirectByteBuffer, который соответствует отдельному конструктору для отображения памяти:

protected DirectByteBuffer(int cap, long addr, FileDescriptor fd, Runnable unmapper) {
    super(-1, 0, cap, cap, fd);
    address = addr;
    cleaner = Cleaner.create(this, unmapper);
    att = null;
}

Таким образом, в дополнение к прямому выделению памяти операционной системой, сам DirectByteBuffer также имеет функцию отображения файловой памяти, которая не будет здесь объясняться. На что нам нужно обратить внимание, так это на то, что DirectByteBuffer обеспечивает операции произвольного чтения get() и записи write() файлов образов памяти на основе MappedByteBuffer.

  • Операции случайного чтения отображаемых в память файлов
public byte get() {
    return ((unsafe.getByte(ix(nextGetIndex()))));
}

public byte get(int i) {
    return ((unsafe.getByte(ix(checkIndex(i)))));
}

  • Произвольная запись в отображаемые в память файлы
public ByteBuffer put(byte x) {
    unsafe.putByte(ix(nextPutIndex()), ((x)));
    return this;
}

public ByteBuffer put(int i, byte x) {
    unsafe.putByte(ix(checkIndex(i)), ((x)));
    return this;
}

Случайное чтение и запись файла образа памяти определяется методом ix().Метод ix() вычисляет адрес указателя по первому адресу памяти (адресу) пространства карты памяти и заданному смещению i, а затем использует небезопасный класс для вычисления адреса указателя.Методы get() и put() читают или записывают данные, на которые указывает указатель.

private long ix(int i) {
    return address + ((long)i << 0);
}

8.3. FileChannel

FileChannel — это канал для чтения, записи, сопоставления и манипулирования файлами, а также потокобезопасный в параллельной среде.Файловый канал можно создать и открыть на основе метода getChannel() FileInputStream, FileOutputStream или RandomAccessFile. FileChannel определяет два абстрактных метода, transferFrom() и transferTo(), которые реализуют передачу данных, устанавливая соединения между каналами.

  • TransferTo(): ​​записывает исходные данные в файле в целевой канал WritableByteChannel через FileChannel.
public abstract long transferTo(long position, long count, WritableByteChannel target)
        throws IOException;

  • TransferFrom(): чтение данных из исходного канала ReadableByteChannel в файл текущего FileChannel.
public abstract long transferFrom(ReadableByteChannel src, long position, long count)
        throws IOException;

Ниже приведен пример использования FileChannel для передачи данных с помощью методов transferTo() и transferFrom():

private static final String CONTENT = "Zero copy implemented by FileChannel";
private static final String SOURCE_FILE = "/source.txt";
private static final String TARGET_FILE = "/target.txt";
private static final String CHARSET = "UTF-8";

Сначала создайте два файла source.txt и target.txt в корневом пути загрузки класса и запишите данные инициализации в исходный файл source.txt.

@Before
public void setup() {
    Path source = Paths.get(getClassPath(SOURCE_FILE));
    byte[] bytes = CONTENT.getBytes(Charset.forName(CHARSET));
    try (FileChannel fromChannel = FileChannel.open(source, StandardOpenOption.READ,
            StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) {
        fromChannel.write(ByteBuffer.wrap(bytes));
    } catch (IOException e) {
        e.printStackTrace();
    }
}

Для метода transferTo() канал назначения toChannel может быть любым каналом односторонней записи байтов WritableByteChannel, а для метода transferFrom() исходным каналом fromChannel может быть любой канал одностороннего чтения байтов ReadableByteChannel. Среди них такие каналы, как FileChannel, SocketChannel и DatagramChannel, реализуют интерфейсы WritableByteChannel и ReadableByteChannel, которые являются двунаправленными каналами, поддерживающими как чтение, так и запись. Для удобства тестирования ниже приведен пример межканальной передачи данных на основе FileChannel.

  • Скопируйте данные из fromChannel в toChannel через transferTo()
@Test
public void transferTo() throws Exception {
    try (FileChannel fromChannel = new RandomAccessFile(
             getClassPath(SOURCE_FILE), "rw").getChannel();
         FileChannel toChannel = new RandomAccessFile(
             getClassPath(TARGET_FILE), "rw").getChannel()) {
        long position = 0L;
        long offset = fromChannel.size();
        fromChannel.transferTo(position, offset, toChannel);
    }
}

  • Скопируйте данные из fromChannel в toChannel через transferFrom()
@Test
public void transferFrom() throws Exception {
    try (FileChannel fromChannel = new RandomAccessFile(
             getClassPath(SOURCE_FILE), "rw").getChannel();
         FileChannel toChannel = new RandomAccessFile(
             getClassPath(TARGET_FILE), "rw").getChannel()) {
        long position = 0L;
        long offset = fromChannel.size();
        toChannel.transferFrom(fromChannel, position, offset);
    }
}

Ниже описаны основные принципы реализации методов transferTo() и transferFrom(), которые также являются абстрактными методами java.nio.channels.FileChannel, реализованными подклассом sun.nio.ch.FileChannelImpl.java. Нижний уровень TransferTo() и TransferFrom() основан на sendfile для реализации передачи данных.FileChannelImpl.java определяет три константы, чтобы указать, поддерживает ли ядро ​​текущей операционной системы sendfile и связанные с ним функции.

private static volatile boolean transferSupported = true;
private static volatile boolean pipeSupported = true;
private static volatile boolean fileSupported = true;

  • TransferSupported: используется для отметки того, поддерживает ли текущее системное ядро ​​вызовы sendfile(), значение по умолчанию — true.
  • pipeSupported: используется, чтобы указать, поддерживает ли текущее ядро ​​​​системы вызовы sendfile() на основе дескриптора файла (fd) на основе конвейера, по умолчанию — true.
  • fileSupported: используется для обозначения того, поддерживает ли текущее ядро ​​системы вызовы sendfile() на основе файлового дескриптора (fd), файловые (файловые), по умолчанию — true.

Ниже в качестве примера используется реализация исходного кода transferTo(). FileChannelImpl сначала выполняет метод transferToDirectly(), чтобы попытаться скопировать данные способом отправки файла без копирования. Если ядро ​​системы не поддерживает sendfile, выполните далее метод transferToTrustedChannel() для выполнения сопоставления памяти в режиме нулевого копирования mmap.В этом случае канал назначения должен иметь тип FileChannelImpl или SelChImpl. Если предыдущие два шага не увенчались успехом, выполните метод transferToArbitraryChannel() для завершения чтения и записи на основе традиционного метода ввода-вывода. Конкретные шаги заключаются в инициализации временного DirectBuffer, чтении данных FileChannel исходного канала в DirectBuffer, а затем напишите пункт назначения внутри канала WritableByteChannel.

public long transferTo(long position, long count, WritableByteChannel target)
        throws IOException {
    // 计算文件的大小
    long sz = size();
    // 校验起始位置
    if (position > sz)
        return 0;
    int icount = (int)Math.min(count, Integer.MAX_VALUE);
    // 校验偏移量
    if ((sz - position) < icount)
        icount = (int)(sz - position);

    long n;

    if ((n = transferToDirectly(position, icount, target)) >= 0)
        return n;

    if ((n = transferToTrustedChannel(position, icount, target)) >= 0)
        return n;

    return transferToArbitraryChannel(position, icount, target);
}

Далее сосредоточьтесь на анализе реализации метода transferToDirectly(), то есть сути метода transferTo(), реализующего нулевое копирование через sendfile. Видно, что метод transferToDirectlyInternal() сначала получает дескриптор файла targetFD целевого канала WritableByteChannel, получает блокировку синхронизации, а затем выполняет метод transferToDirectlyInternal().

private long transferToDirectly(long position, int icount, WritableByteChannel target)
        throws IOException {
    // 省略从target获取targetFD的过程
    if (nd.transferToDirectlyNeedsPositionLock()) {
        synchronized (positionLock) {
            long pos = position();
            try {
                return transferToDirectlyInternal(position, icount,
                        target, targetFD);
            } finally {
                position(pos);
            }
        }
    } else {
        return transferToDirectlyInternal(position, icount, target, targetFD);
    }
}

Наконец, метод transferToDirectlyInternal() вызывает локальный метод transferTo0(), чтобы попытаться передать данные способом sendfile. Если системное ядро ​​вообще не поддерживает sendfile, как, например, в операционной системе Windows, возвращается UNSUPPORTED, а флаг transferSupported имеет значение false. Если системное ядро ​​не поддерживает некоторые функции sendfile, например, младшая версия ядра Linux не поддерживает операцию копирования DMA, верните UNSUPPORTED_CASE и отметьте pipeSupported или fileSupported как false.

private long transferToDirectlyInternal(long position, int icount,
                                        WritableByteChannel target,
                                        FileDescriptor targetFD) throws IOException {
    assert !nd.transferToDirectlyNeedsPositionLock() ||
            Thread.holdsLock(positionLock);

    long n = -1;
    int ti = -1;
    try {
        begin();
        ti = threads.add();
        if (!isOpen())
            return -1;
        do {
            n = transferTo0(fd, position, icount, targetFD);
        } while ((n == IOStatus.INTERRUPTED) && isOpen());
        if (n == IOStatus.UNSUPPORTED_CASE) {
            if (target instanceof SinkChannelImpl)
                pipeSupported = false;
            if (target instanceof FileChannelImpl)
                fileSupported = false;
            return IOStatus.UNSUPPORTED_CASE;
        }
        if (n == IOStatus.UNSUPPORTED) {
            transferSupported = false;
            return IOStatus.UNSUPPORTED;
        }
        return IOStatus.normalize(n);
    } finally {
        threads.remove(ti);
        end (n > -1);
    }
}

Собственный метод transferTo0() вызывает базовую функцию C через JNI (собственный интерфейс Java).Эта собственная функция (Java_sun_nio_ch_FileChannelImpl_transferTo0) также находится в исходном файле native/sun/nio/ch/FileChannelImpl.c в исходном пакете JDK. Функция JNI Java_sun_nio_ch_FileChannelImpl_transferTo0() предварительно компилирует различные системы на основе условной компиляции.Ниже приведен пакет вызовов, предоставленный JDK на основе ядра системы Linux для transferTo() .

#if defined(__linux__) || defined(__solaris__)
#include <sys/sendfile.h>
#elif defined(_AIX)
#include <sys/socket.h>
#elif defined(_ALLBSD_SOURCE)
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/uio.h>

#define lseek64 lseek
#define mmap64 mmap
#endif

JNIEXPORT jlong JNICALL
Java_sun_nio_ch_FileChannelImpl_transferTo0(JNIEnv *env, jobject this,
                                            jobject srcFDO,
                                            jlong position, jlong count,
                                            jobject dstFDO)
{
    jint srcFD = fdval(env, srcFDO);
    jint dstFD = fdval(env, dstFDO);

#if defined(__linux__)
    off64_t offset = (off64_t)position;
    jlong n = sendfile64(dstFD, srcFD, &offset, (size_t)count);
    return n;
#elif defined(__solaris__)
    result = sendfilev64(dstFD, &sfv, 1, &numBytes);	
    return result;
#elif defined(__APPLE__)
    result = sendfile(srcFD, dstFD, position, &numBytes, NULL, 0);
    return result;
#endif
}

Для систем Linux, Solaris и Apple нижний уровень функции transferTo0() будет выполнять системный вызов sendfile64 для завершения операции нулевого копирования.Прототип функции sendfile64() выглядит следующим образом:

#include <sys/sendfile.h>

ssize_t sendfile64(int out_fd, int in_fd, off_t *offset, size_t count);

Ниже кратко представлено значение каждого параметра функции sendfile64():

  • out_fd: записываемый файловый дескриптор
  • in_fd: файловый дескриптор для чтения
  • offset: укажите позицию чтения файлового потока, соответствующую in_fd, если она пуста, по умолчанию она начнется с начальной позиции.
  • count: указывает количество байтов, переданных между файловыми дескрипторами in_fd и out_fd.

До Linux 2.6.3 out_fd должен быть сокетом, а начиная с Linux 2.6.3, out_fd может быть любым файлом. То есть функция sendfile64() может не только выполнять передачу файлов по сети, но и выполнять операции нулевого копирования над локальными файлами.

9. Другие реализации с нулевым копированием

9.1 Нетти нулевое копирование

Нулевое копирование в Netty — это не то же самое, что нулевое копирование на уровне операционной системы, упомянутое выше. оптимизация операций с данными.Эта концепция воплощается в следующих аспектах:

  • Netty оборачивает метод tranferTo() класса java.nio.channels.FileChannel через класс DefaultFileRegion и может отправлять данные файлового буфера непосредственно в канал назначения (Channel) во время передачи файла.
  • ByteBuf может обернуть массив байтов, ByteBuf, ByteBuffer в объект ByteBuf с помощью операции переноса, тем самым избегая операции копирования.
  • ByteBuf поддерживает операцию среза, поэтому ByteBuf можно разбить на несколько ByteBuf, которые совместно используют одну и ту же область хранения, избегая копирования памяти.
  • Netty предоставляет класс CompositeByteBuf, который может объединять несколько ByteBuf в один логический ByteBuf, избегая копирования между каждым ByteBuf.

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

9.2 Сравнение RocketMQ и Kafka

RocketMQ выбирает метод нулевого копирования mmap + write, который подходит для сохранения данных и передачи небольших файлов, таких как сообщения бизнес-уровня; в то время как Kafka использует метод нулевого копирования sendfile, который подходит для высокой пропускной способности, такой как в виде сообщений системного журнала.Сохранение данных и передача больших фрагментов файлов. Но стоит отметить, что индексный файл Kafka использует метод mmap + write, а файл данных — метод sendfile.

очередь сообщений Метод нулевого копирования преимущество недостаток
RocketMQ mmap + write Он подходит для передачи файлов небольшими блоками и очень эффективен при частом вызове. Невозможно эффективно использовать метод DMA, он будет потреблять больше ЦП, чем файл отправки, контроль безопасности памяти сложен, и необходимо избегать проблемы сбоя JVM.
Kafka sendfile Можно использовать метод прямого доступа к памяти, который потребляет меньше ресурсов ЦП, а эффективность передачи больших файлов высока, и нет проблем с безопасностью памяти. Эффективность файлов небольших блоков ниже, чем в режиме mmap, и их можно передавать только в режиме BIO, а не в режиме NIO.

резюме

Эта статья начинается с подробного описания физической памяти и виртуальной памяти в операционной системе Linux, концепций пространства ядра и пространства пользователя, а также внутренней иерархии Linux. На этой основе дополнительно проанализируйте и сравните разницу между традиционным методом ввода-вывода и методом нулевого копирования, а затем представьте несколько реализаций нулевого копирования, предоставляемых ядром Linux, включая отображение в память mmap, sendfile, sendfile + сбор DMA. механизма копирования и сращивания и сравнил их на уровне системных вызовов и времени копирования. Далее мы анализируем реализацию Java NIO с нулевым копированием из исходного кода, включая MappedByteBuffer на основе сопоставления памяти (mmap) и FileChannel на основе sendfile. Наконец, в конце статьи я кратко объяснил механизм нулевого копирования в Netty, а также разницу между двумя очередями сообщений RocketMQ и Kafka в реализации нулевого копирования.

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