Пишем обертку для FUSE на Java Native Runtime

    В статье я расскажу как реализовать файловую систему в юзерспейсе на 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.
    • +26
    • 12,4k
    • 8
    Поделиться публикацией
    Похожие публикации
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама
    Комментарии 8
    • +2
      Полезная статья. Раньше не знал даже о JNR. Теперь есть что почитать вечером перед сном.
      • 0
        Как я понял, в Java придется переписывать все структуры и сигнатуры методов из .h. Нет ли для этого какого-нибудь полуавтоматического конвертора?
        • 0
          Не думаю, что есть конвертер для сигнатур методов из хедеров, из-за директив препроцессора и макросов довольно сложно будет все правильно разобрать, но для констант вполне, в jnr-constants так и сделали, там парсер на ruby.
        • +1
          Статья интересная. Сам пробовал на JNA писать. Понравился своей простотой.

          А в плане использования не могли бы вы объяснить, как это выглядит? Вот в JNA я описал интерфейс и вызываю метод, который загружает dll (возьмём Windows) и делает инстанс интерфейса для этой dll. И всё. Больше ничего не нужно прописывать, главное, чтобы JNA был в classpath и приложение могло найти dll. А здесь как? Вы говорили, что генерируется byte code. А на каком этапе это происходит? При сборке? Т.е. есть ли какой нибудь gradle задача (для примера), которая при сборке обрабатывает исходники и генерирует byte code? Например, как в AspectJ CTW. Я build.gradle вашего проекта посмотрел, но ничего такого не нашёл (возможно, плохо искал) и pdf презентации тоже посмотрел.

          Вообще если это так же просто как JNA, но так же быстро как JNI, то потрясающе! :)

          P.S. Странно, что reflection такой медленный, его же вроде от версии к версии ускоряют.
          • +1
            Байткод генерируется в рантайме, через ASM, пример: AsmStructByReferenceFromNativeConverter.java

            Зависимость на ASM, кстати, может быть проблемой из-за конфликта версий, поэтому в jnr-fuse я создал отдельную таску, которая делает uber-jar.
            • 0
              О, так вообще всё шикарно получается — всё в runtime работает и не требует предварительной компиляции! Но при таком раскладе возможно увеличение времени старта — на генерация byte code. Не замечали такого? Или оно совсем мало?
              • +1
                Генерация байткода в рантайме сейчас повсеместно, это очень быстро и просходит только 1 раз, так что проблем из-за этого вы не заметите.
          • 0
            Прошу прощения, что не в тему вопрос, но нет ли аналога FUSE для Windows платформы.
            Смотрел на FUSE порт для Win32 и честно говоря как-то очень сложно там все, а нет
            ли попроще реализации от самого Microsoft?

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