Работа с bash pipe из java

Постановка задачи

Не так давно передо мной встала задача работать с нативным приложением из java. Это приложение - rtl_sdr. Суть его работы достаточно простая: оно подключается к USB, читает данные и пишет их в файл или в stdout.

А дальше

Обычно нативные приложения не рекомендуется запускать из JVM. Дело в том, что как только управление передаётся из JVM вовне, то все предоставленные гарантии JVM теряются. Но если хочется, то вызвать приложение можно так:

Process process = new ProcessBuilder().command("rtl_sdr", "-arg1", "value1", ...).start();
int exitCode = process.waitFor();

Здесь все достаточно просто:

  • запускается процесс
  • Java поток ожидает пока приложение завершится

Однако, тут сразу же возникает проблема: rtl_sdr будет работать пока не получит команду от пользователя остановиться. Поэтому этот метод никогда не завершится. Значит для этого необходимо сделать отдельный метод, который бы вызывался с другого потока:

public void complete() {
	process.destroy();
}

Новые требования

Итак, код работает в проде уже несколько месяцев. Но тут выясняется, что raspberry pi не успевает записывать на флэшку. Я провел тесты и убедился, что сжатие входящих данных сильно ускоряет систему и делает её более стабильной.

Самый простой способ сделать сжатие можно следующим способом:

byte[] buf = new byte[BUF_SIZE];
while (!Thread.currentThread().isInterrupted()) {
	int r = process.getInputStream().read(buf);
	if (r == -1) {
		break;
	}
	gzipOutputStream.write(buf, 0, r);
}
gzipOutputStream.flush();

Но такой способ оказался достаточно медленным. Дело в том, что данные копировались в память JVM, а потом уже в gzip. Но если не писать через JVM, то остаётся только способ каким-нибудь образом использовать bash pipe. Немного повозившись с реализацией, у меня получилось следующее:

Process process = new ProcessBuilder().command("rtl_sdr_wrapper.sh").start();
int exitCode = process.waitFor();

И код враппера:

rtl_sdr -arg1 value1 ... | gzip > output.gz

Почти сразу после релиза стало понятно, что сломалась остановка процесса. Дело в том, что этот bash скрипт создаёт 2 подпроцесса. При вызове process.destroy(); завершается сам скрипт и gzip. rtl_sdr при этом остаётся работать. Пришлось повозиться с bash-магией и получилось следующее:

_term() { 
  kill -TERM "$rtl" 2>/dev/null
}

rtl_sdr -arg1 value1 ... | gzip > output.gz &

rtl=$(jobs -p)
child=$! 
wait "$child"

Что же здесь происходит?

  • устанавливается обработчик сигнала TERM. Этот сигнал посылает JVM при вызове метода destroy
  • запускается pipe и переходит в фоновый режим
  • получается pid первого процесса - rtl_sdr
  • скрипт начинает ожидать, когда завершиться процесс rtl_sdr

Последним штрихом необходимо правильно передать код возврата. Есть две ситуации завершения работы:

  • Нормальная работа. При нормальной работе rtl_sdr всегда должна завершаться через kill. И тогда статус должен быть 143. Однако, если завершать pipe с помощью kill -TERM, то pipe вернёт код возврата последней команды - gzip. И тогда этот код передастся команде wait, и она пробросит его выше. Этот код будет 0.
  • Завершение с ошибкой. Код возврата в таком случае 0. Он должен опять же проброситься в команду wait и дальше в Java.

Решается эта проблема следующей командой:

set -o pipefail

Она заставляет pipe вернуть код первой команды, которая завершилась не статусом 0.

Выводы

Если необходимо оптимизировать обработку данных на уровне операционной системы, то можно использовать bash pipe. И да, его можно вызывать из Java. Надо только делать это очень осторожно.