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



Интересно? Добро пожаловать под кат!

Прежде приступить к реализации обертки нужно понять что же такое FUSE.
FUSE (Filesystem in Userspace) — файловая система в пользовательском пространстве, она позволяет пользователям без привилегий создавать их собственные файловые системы и без необходимости переписывать код ядра. Это достигается за счёт запуска кода файловой системы в пространстве пользователя, в то время как модуль FUSE только предоставляет мост для актуальных интерфейсов ядра. FUSE была официально включена в главное дерево кода Linux в версии 2.6.14.



Т.е. вы по сути реализацией нескольких методов можете легко создать свою собственную файловую систему (пример простейшей ФС). Применений этому миллион, вы можете, например, быстро написать ФС, бэкэндом для которой будет Dropbox или GitHub.
Или же, рассмотрим такой кейс, у вас есть бизнес приложение, где все пользовательские файлы храняться в БД, но клиенту, вдруг, понадобился прямой доступ к директории на сервере, где лежат все файлы. Конечно же дублировать файлы в БД и ФС решение не самое лучшее и тут как раз на помощь приходит виртуальная файловая система. Вы просто пишете свою FUSE обертку, которая при обращении к файлам ходит за ними в БД.

Java и нативный код


Отлично, но реализация FUSE начинается с “подключите заголовочный файл <fuse.h>”, а ваше бизнес-приложение написано на Java. Очевидно, нужно каким-либо образом взаимодействовать с нативным кодом.

JNI


Стандартным средством является JNI, но он вносит очень много сложности в проект, особенно учитывая, что для реализации FUSE нам придется делать колбэки из нативного кода в Java классы. Да и “write once” на самом деле страдает, хотя в случае FUSE нам это менее важно.
Собственно, если попытаться найти проекты, которые реализуют обертку для FUSE на JNI, можно найти несколько проектов, которые, однако, уже давно не поддерживаются и предоставляют кривой API.

JNA


Другой вариант, библиотека JNA. JNA (Java Native Access) позволяет довольно легко получить доступ к нативному коду без использования JNI, ограничившись написанием java-кода. Все довольно просто, объявляем интерфейс, который соответствует нативному коду, получаем его имплементацию через “Native.loadLibrary” и все, используем. Отдельный плюс JNA — это подробнейшая документация. Проект жив и активно развивается.

Более того, для FUSE уже существует отличный проект, реализующий обертку на JNA.
Однако, у JNA есть определенные проблемы с производительностью. JNA базируется на рефлекшене, и переход из нативного кода с конвертацией всех структур в java объекты очень дорог. Это не сильно заметно, если нативные вызовы будут редки, однако это не случай файловой системы. Единственный способ ускорить fuse-jna это пытаться читать файлы большими кусками, однако это сработает далеко не всегда. Например, когда нет доступа к клиентскому коду, или все файлы маленькие – большое количество текстовый файлов.
Очевидно, что должна была появиться библиотека, совмещающая производительность JNI и удобство JNA.

JNR


Вот тут и приходит JNR (Java Native Runtime). JNR, как и JNA базируется на libffi, но вместо рефлекшена используется генерация байткода, за счет чего достигается огромное преимущество в производительности.
Какой-либо информации про JNR довольно мало, самое подробное это выступление Charles Nutter на JVMLS 2013 (презентация). Однако JNR уже представляет из себя довольно крупную экосистему, которая активно используется JRuby. Многие ее части, например, unix-сокеты, posix-api также активно используются сторонними проектами.



Именно JNR является основой для разработки JEP 191 — Foreign Function Interface, который таргетится на java 10.
В отличие от JNA у JNR нет какой либо документации, все ответы на вопросы приходится искать в исходном коде, это и послужило основной причиной написания небольшого гайда.

Особенность написания кода для Java Native Runtime


Биндинг функций


Простейший биндинг к libc выглядит так:
import jnr.ffi.*;
import jnr.ffi.types.pid_t;

/**
 * Gets the process ID of the current process, and that of its parent.
 */
public class Getpid {
    public interface LibC  {
        public @pid_t long getpid();
        public @pid_t long getppid();
    }

    public static void main(String[] args) {
        LibC libc = LibraryLoader.create(LibC.class).load("c");

        System.out.println("pid=" + libc.getpid() + " parent pid=" + libc.getppid());
    }
}

Через LibraryLoader подгружаем по имени библиотеку, которая соответствует переданному интерфейсу.

В случае FUSE нужен интерфейс с методом fuse_main_real, в который передается структура FuseOperations, которая содержит все колбэки.
public interface LibFuse {  
    int fuse_main_real(int argc, String argv[], FuseOperations op, int op_size, Pointer user_data);
}


Реализация struct


Часто необходимо работать со структурами, расположенными по определенному адресу, например структурой fuse_bufvec:
struct fuse_bufvec {
	size_t count;
	size_t idx;
	size_t off;
	struct fuse_buf buf[1];
};

Для ее реализации в JNR необходимо отнаследоваться от jnr.ffi.Struct.
import jnr.ffi.*;

public class FuseBufvec extends Struct {
    public FuseBufvec(jnr.ffi.Runtime runtime) {
        super(runtime);
    }
    public final size_t count = new size_t();
    public final size_t idx = new size_t();
    public final size_t off = new size_t();
    public final FuseBuf buf = inner(new FuseBuf(getRuntime()));
}

Внутри каждой структуры хранится pointer, по которому она размещается в памяти. Большую часть API работы со структурами можно увидев, посмотрев на статические методы Struct.
size_t это inner класс Struct и при его создании для каждого поля запоминается offset с которым это поле размещено в памяти, за счет чего каждое поле знает по какому оффсету оно лежит в памяти. Таких inner классов уже реализовано много (например, Signed64, Unsigned32, time_t и т.д.), всегда можно реализовать свои.

Колбэки


struct fuse_operations {
	int (*getattr) (const char *, struct stat *);
}

Для работы с колбэками в JNR существует аннотация
@Delegate

public interface GetAttrCallback {
    @Delegate
    int getattr(String path, Pointer stbuf);
}

public class FuseOperations extends Struct {
    public FuseOperations(Runtime runtime) {
        super(runtime);
    }

    public final Func<GetAttrCallback> getattr = func(GetAttrCallback.class);
}

После чего можно выставить в поле getattr нужную имплементацию колбэка, например.

fuseOperations.getattr.set((path, stbuf) -> 0);


Enum


Из некоторых неочевидных вещей также стоит отметить обертку над enum, для этого свой enum нужно отнаследовать от jnr.ffi.util.EnumMapper.IntegerEnum и реализовать метод intValue
enum fuse_buf_flags {
	FUSE_BUF_IS_FD	= (1 << 1),
	FUSE_BUF_FD_SEEK	= (1 << 2),
	FUSE_BUF_FD_RETRY	= (1 << 3),
};

public enum FuseBufFlags implements EnumMapper.IntegerEnum {
    FUSE_BUF_IS_FD(1 << 1),
    FUSE_BUF_FD_SEEK(1 << 2),
    FUSE_BUF_FD_RETRY(1 << 3);

    private final int value;

    FuseBufFlags(int value) {
        this.value = value;
    }

    @Override
    public int intValue() {
        return value;
    }
}


Работа с памятью


  • Для прямой работы с памятью существует обертка над сырым указателем jnr.ffi.Pointer
  • Аллоцировать память можно с помощью jnr.ffi.Memory
  • Отправной точкой по API JNR можно считать jnr.ffi.Runtime

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

jnr-fuse


Что собственно я и сделал с FUSE в своем проекте jnr-fuse. Изначально использовалась библиотека fuse-jna, однако именно она была боттлнеком в реализации ФС. При разработке API я постарался максимально сохранить совместимость с fuse-jna, а также с нативной реализацией (<fuse.h>).

Для реализации своей файловой системы в юзерспейсе необходимо отнаследоваться от ru.serce.jnrfuse.FuseStubFS и реализовать нужные методы. Fuse_operations содержит множество методов, однако для того, чтобы получить рабочую ФС достаточно реализовать всего несколько основных.
Это довольно просто, вот несколько примеров рабочих ФС.

На данный момент поддерживается Linux (x86 и x64).

Библиотека лежит в jcenter, в ближайшее время добавлю зеркало в maven central.

Gradle

repositories {
    jcenter()
}

dependencies {
    compile 'com.github.serceman:jnr-fuse:0.1'
}

Maven

    <repositories>
        <repository>
            <id>central</id>
            <name>bintray</name>
            <url>http://jcenter.bintray.com</url>
        </repository>
    </repositories>

    <dependencies>
        <dependency>
            <groupId>com.github.serceman</groupId>
            <artifactId>jnr-fuse</artifactId>
            <version>0.1</version>
        </dependency>
    </dependencies>


Сравниваем производительность fuse-jna и jnr-fuse


В моем случае, FS была read-only и меня интересовал конктретно throughput. Производительность будет сильно зависеть от имплементации вашей ФС, поэтому если вдруг вы уже используте fuse-jna, вы можете легко подключить jnr-fuse, написать тест с учетом вашего профиля нагрузки и увидеть разницу. (Этот тест вам в любом случае пригодится, мы же все любим погоняться за производительностью, правда?)

Чтобы же показать порядок разницы я перенес имплементацию MemoryFS из fuse-jna в fuse-jnr с минимальными изменениями и запустил fio тест на чтение. Для теста я использовал фреймворк fio, про который не так давно была хорошая статья на хабре.

Конфигурация теста
[readtest]
blocksize=4k
directory=/tmp/mnt/
rw=randread
direct=1
buffered=0
ioengine=libaio
time_based=60
size=16M
runtime=60

Результат fuse-jna
serce@SerCe-FastLinux:~/git/jnr-fuse/bench$ fio read.ini
readtest: (g=0): rw=randread, bs=4K-4K/4K-4K/4K-4K, ioengine=libaio, iodepth=1
fio-2.1.3
Starting 1 process
readtest: Laying out IO file(s) (1 file(s) / 16MB)
Jobs: 1 (f=1): [r] [100.0% done] [24492KB/0KB/0KB /s] [6123/0/0 iops] [eta 00m:00s]
readtest: (groupid=0, jobs=1): err= 0: pid=10442: Sun Jun 21 14:49:13 2015
read: io=1580.2MB, bw=26967KB/s, iops=6741, runt= 60000msec
slat (usec): min=46, max=29997, avg=146.55, stdev=327.68
clat (usec): min=0, max=69, avg= 0.47, stdev= 0.66
lat (usec): min=47, max=30002, avg=147.26, stdev=327.88
clat percentiles (usec):
| 1.00th=[ 0], 5.00th=[ 0], 10.00th=[ 0], 20.00th=[ 0],
| 30.00th=[ 0], 40.00th=[ 0], 50.00th=[ 0], 60.00th=[ 1],
| 70.00th=[ 1], 80.00th=[ 1], 90.00th=[ 1], 95.00th=[ 1],
| 99.00th=[ 2], 99.50th=[ 2], 99.90th=[ 3], 99.95th=[ 12],
| 99.99th=[ 14]
bw (KB /s): min=17680, max=32606, per=96.09%, avg=25913.26, stdev=3156.20
lat (usec): 2=97.95%, 4=1.96%, 10=0.02%, 20=0.06%, 50=0.01%
lat (usec): 100=0.01%
cpu: usr=1.98%, sys=5.94%, ctx=405302, majf=0, minf=28
IO depths: 1=100.0%, 2=0.0%, 4=0.0%, 8=0.0%, 16=0.0%, 32=0.0%, >=64=0.0%
submit: 0=0.0%, 4=100.0%, 8=0.0%, 16=0.0%, 32=0.0%, 64=0.0%, >=64=0.0%
complete: 0=0.0%, 4=100.0%, 8=0.0%, 16=0.0%, 32=0.0%, 64=0.0%, >=64=0.0%
issued: total=r=404511/w=0/d=0, short=r=0/w=0/d=0

Run status group 0 (all jobs):
READ: io=1580.2MB, aggrb=26967KB/s, minb=26967KB/s, maxb=26967KB/s, mint=60000msec, maxt=60000msec

Результат jnr-fuse
serce@SerCe-FastLinux:~/git/jnr-fuse/bench$ fio read.ini
readtest: (g=0): rw=randread, bs=4K-4K/4K-4K/4K-4K, ioengine=libaio, iodepth=1
fio-2.1.3
Starting 1 process
readtest: Laying out IO file(s) (1 file(s) / 16MB)
Jobs: 1 (f=1): [r] [100.0% done] [208.5MB/0KB/0KB /s] [53.4K/0/0 iops] [eta 00m:00s]
readtest: (groupid=0, jobs=1): err= 0: pid=10153: Sun Jun 21 14:45:17 2015
read: io=13826MB, bw=235955KB/s, iops=58988, runt= 60002msec
slat (usec): min=6, max=23671, avg=15.80, stdev=19.97
clat (usec): min=0, max=1028, avg= 0.37, stdev= 0.78
lat (usec): min=7, max=23688, avg=16.29, stdev=20.03
clat percentiles (usec):
| 1.00th=[ 0], 5.00th=[ 0], 10.00th=[ 0], 20.00th=[ 0],
| 30.00th=[ 0], 40.00th=[ 0], 50.00th=[ 0], 60.00th=[ 0],
| 70.00th=[ 1], 80.00th=[ 1], 90.00th=[ 1], 95.00th=[ 1],
| 99.00th=[ 1], 99.50th=[ 1], 99.90th=[ 2], 99.95th=[ 2],
| 99.99th=[ 10]
lat (usec): 2=99.88%, 4=0.10%, 10=0.01%, 20=0.01%, 50=0.01%
lat (usec): 100=0.01%, 250=0.01%
lat (msec): 2=0.01%
cpu: usr=9.33%, sys=34.01%, ctx=3543137, majf=0, minf=28
IO depths: 1=100.0%, 2=0.0%, 4=0.0%, 8=0.0%, 16=0.0%, 32=0.0%, >=64=0.0%
submit: 0=0.0%, 4=100.0%, 8=0.0%, 16=0.0%, 32=0.0%, 64=0.0%, >=64=0.0%
complete: 0=0.0%, 4=100.0%, 8=0.0%, 16=0.0%, 32=0.0%, 64=0.0%, >=64=0.0%
issued: total=r=3539449/w=0/d=0, short=r=0/w=0/d=0

Run status group 0 (all jobs):
READ: io=13826MB, aggrb=235955KB/s, minb=235955KB/s, maxb=235955KB/s, mint=60002msec, maxt=60002msec



Тест лишь демонстрирует разницу в скорости чтения файла в fuse-jna и fuse-jnr, однако на его основе можно получить представление о разнице в скорости работы JNA и JNR. Желающие же всегда могут написать более подробные тесты на нативные вызовы с помощью JMH с учетом всех особенностей, мне самому было бы интересно посмотреть на эти тесты.

Разница и в throughput, и в latency в JNR и JNA ожидаемо, как и в презентации от Charles Nutter, составляет ~10 раз.

Ссылки



Проект jnr-fuse размещен на GitHub. Буду раз звездочкам, пул-реквестам, предложениям по улучшению проекта.
А также с радостью отвечу на все возникшие вопросы про JNR и jnr-fuse.

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


  1. gurinderu
    22.06.2015 11:24
    +2

    Полезная статья. Раньше не знал даже о JNR. Теперь есть что почитать вечером перед сном.


  1. Throwable
    22.06.2015 11:27

    Как я понял, в Java придется переписывать все структуры и сигнатуры методов из .h. Нет ли для этого какого-нибудь полуавтоматического конвертора?


    1. SerCe Автор
      22.06.2015 11:37

      Не думаю, что есть конвертер для сигнатур методов из хедеров, из-за директив препроцессора и макросов довольно сложно будет все правильно разобрать, но для констант вполне, в jnr-constants так и сделали, там парсер на ruby.