IOUring. Часть 1 (updated).

Denis Gabaydulin
3 min readOct 22, 2020

--

Не так давно, в ядре Linux появился новый интерфейс для ввода/вывода io_uring. Если совсем кратко, то это асинхронный интерфейс. У вас есть две очереди. Одна на вставку событий, другая на получение результата. Внутри это кольцевой буфер в shared memory (single consumer/producer) между ядром и user space.

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

Мне стало интересно, а может ли это принести какую-то пользу на практике. Моя основная платформа разработки это — JVM. Так как ничего готового на данный момент нет, я решил реализовать минимальный набор системных вызовов самостоятельно, через JNI.

Мое оборудование — это ноутбук с SSD NVME.

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

За baseline я взял чтение файла размером 256 mb через RandomAccessFile.

private static long readDataFileNio(File file, boolean crc) throws IOException {
byte[] buffer = new byte[BUFFER_SIZE];
long readTotal = 0;
HashFunction hashFunction = Hashing.crc32();
Hasher hasher = hashFunction.newHasher();
try (RandomAccessFile raf = new RandomAccessFile(file, "r")) {
int bytes = 0;
while ((bytes = raf.read(buffer)) != -1) {
readTotal += bytes;
if (crc) {
hasher.putBytes(buffer);
}
}
}
if (crc) {
return hasher.hash().asInt();
} else {
return readTotal;
}
}

Подсчет длины прочитанных данных: 37 миллисекунд (разброс 10–15%)
Подсчет crc32: 49 миллисекунд (разброс 10–15%)

Судя по профайлеру, разница — это как раз стоимость подсчета crc32. А если более точно, то стоимость чтения данных из хип буфера, в который RandomAccessFile.read скопировал данные из файла (обычным read).

Почему разница такая небольшая? Дело в том, что для putBytes существует интрисик, который использует AVX512.

Реализация io_uring состоит из двух частей. Это io loop и реализация file reader.

Абстракция io loop необходима для асинхронного взаимодействия с io_ruing. Код реализации io loop. Алгоритм работы.

  1. Внутри io loop существует единственный тред (io thread), который работает с io_uring. Он работает в “вечном” цикле.
  2. Есть очередь событий, в которую можно добавлять события, например, на чтение.
  3. Io thread выгребает все события из очереди и добавляет их в io_uring (вызывая submit).
  4. Также io thread проверяет есть ли завершенные события и если есть, читает их из кольцевого буфера io_uring и шедулит на обработку, в отдельный thread pool.

Код реализации file reader. Алгоритм работы.

  1. File reader получает размер файла и считает количество чанков на чтение. Фактически, это количество событий, которое требуется, чтобы прочитать файл до конца.
  2. Для каждого чанка создается событие на чтение и добавляется в io loop.
  3. После добавления всех событий, file reader ожидает в busy-loop пока все события будут обработаны и для них будет вызван специальный обработчик, который и делает полезную работу над данными.
  4. Событие на чтение состоит из типа события (флагов io_uring), файлового дескриптора, адреса буфера offheap, отступа и длины.
public class ReadTask {
private final int fd;
private final long offset;
private final int length;
private final OnResult handler;
}

Отдельно рассмотрим обработчик завершенного события.

private static class OnResultImpl implements OnResult {
private final Consumer<byte[]> handler;
private AtomicInteger read;

private OnResultImpl(Consumer<byte[]> handler, AtomicInteger read) {
this.handler = handler;
this.read = read;
}

@Override
public void onRead(ByteBuf buffer) {
byte[] bytes = new byte[buffer.readableBytes()];
int readerIndex = buffer.readerIndex();
buffer.getBytes(readerIndex, bytes);
handler.accept(bytes);
read.decrementAndGet();
}
}

Он вызывается из отдельного thread pool, в который попадают завершенные события io_uring.

Код копирования использует “под капотом” sun.misc.Unsafe.copyMemory. Этот способ используется для crc32 ради интрисика.

Подсчет длины прочитанных данных: 41 миллисекунд
Подсчет длины прочитанных данных (без копирования в хип): 36 миллисекунд
Подсчет crc32: 43 миллисекунды

Видно, что копирование в хип — очень дешевое.

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

В будущих частях, я попробую покрыть больше кейсов использования io_uring и попробую использовать другие события.

--

--