IOUring. Часть 1 (updated).
Не так давно, в ядре 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. Алгоритм работы.
- Внутри io loop существует единственный тред (io thread), который работает с io_uring. Он работает в “вечном” цикле.
- Есть очередь событий, в которую можно добавлять события, например, на чтение.
- Io thread выгребает все события из очереди и добавляет их в io_uring (вызывая submit).
- Также io thread проверяет есть ли завершенные события и если есть, читает их из кольцевого буфера io_uring и шедулит на обработку, в отдельный thread pool.
Код реализации file reader. Алгоритм работы.
- File reader получает размер файла и считает количество чанков на чтение. Фактически, это количество событий, которое требуется, чтобы прочитать файл до конца.
- Для каждого чанка создается событие на чтение и добавляется в io loop.
- После добавления всех событий, file reader ожидает в busy-loop пока все события будут обработаны и для них будет вызван специальный обработчик, который и делает полезную работу над данными.
- Событие на чтение состоит из типа события (флагов 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 и попробую использовать другие события.