Пишемо обгортку для 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("с");

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-jnaserce@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.2 MB, 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.00 th=[ 0], 5.00 th=[ 0], 10.00 th=[ 0], 20.00 th=[ 0],
| 30.00 th=[ 0], 40.00 th=[ 0], 50.00 th=[ 0], 60.00 th=[ 1],
| 70.00 th=[ 1], 80.00 th=[ 1], 90.00 th=[ 1], 95.00 th=[ 1],
| 99.00 th=[ 2], 99.50 th=[ 2], 99.90 th=[ 3], 99.95 th=[ 12],
| 99.99 th=[ 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.2 MB, aggrb=26967KB/s, minb=26967KB/s, maxb=26967KB/s, mint=60000msec, maxt=60000msec

Результат jnr-fuseserce@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.5 MB/0KB/0KB /s] [53.4 K/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.00 th=[ 0], 5.00 th=[ 0], 10.00 th=[ 0], 20.00 th=[ 0],
| 30.00 th=[ 0], 40.00 th=[ 0], 50.00 th=[ 0], 60.00 th=[ 0],
| 70.00 th=[ 1], 80.00 th=[ 1], 90.00 th=[ 1], 95.00 th=[ 1],
| 99.00 th=[ 1], 99.50 th=[ 1], 99.90 th=[ 2], 99.95 th=[ 2],
| 99.99 th=[ 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.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.