Реактивное программирование Java
Реактивный поток описывает асинхронное поступление от нуля до многих элементов и завершение одним из двух сигналов: complete или error. Главная особенность Reactive Streams — получатель явно сообщает, сколько элементов готов обработать.
Контракт Reactive Streams
В протоколе участвуют четыре роли:
Publisher<T>публикует элементы;Subscriber<T>принимает сигналы;Subscriptionсвязывает их и принимаетrequest(n)илиcancel();Processor<T, R>одновременно subscriber и publisher.
Порядок сигналов для одного subscriber:
onSubscribe(subscription)
onNext(item) × 0..N
onComplete()или:
onSubscribe(subscription)
onNext(item) × 0..N
onError(error)После terminal-сигнала новые элементы запрещены. onError — часть протокола, а не исключение, которое можно поймать вокруг асинхронного subscribe.
Backpressure
Subscriber вызывает request(n), тем самым выдавая publisher разрешение отправить не более n элементов. Это защищает медленного потребителя от неограниченного push-потока, если источник умеет соблюдать спрос.
final class BatchSubscriber<T> implements Flow.Subscriber<T> {
private Flow.Subscription subscription;
private int received;
@Override
public void onSubscribe(Flow.Subscription value) {
subscription = value;
subscription.request(20);
}
@Override
public void onNext(T item) {
handle(item);
if (++received == 20) {
received = 0;
subscription.request(20);
}
}
@Override public void onError(Throwable error) { report(error); }
@Override public void onComplete() { finish(); }
}java.util.concurrent.Flow содержит интерфейсы, совместимые по идее с Reactive Streams, но не даёт богатого набора операторов.
Cold и hot publisher
Cold publisher заново производит последовательность для каждого subscriber. Запрос к файлу или БД часто cold: две подписки могут выполнить работу дважды.
Hot publisher существует независимо от конкретного подписчика. Поздний подписчик может не увидеть ранние события. Для него нужно заранее решить, что делать с медленными и отсутствующими потребителями: буферизовать, отбрасывать, хранить последнее значение или отключать.
Асинхронность не равна параллельности
Reactive API не обещает новый поток для каждого оператора. Пока scheduler не переключён явно, цепочка часто выполняется на потоке источника. Параллельность и порядок задаются отдельно.
Неблокирующий код не удерживает поток в ожидании I/O. Но если внутри цепочки вызвать блокирующий JDBC, файловую операцию или .get(), поток всё равно будет заблокирован.
Когда подход полезен
- много одновременных I/O-операций;
- источники естественно передают события;
- нужно управлять спросом и отменой;
- весь путь запроса поддерживает неблокирующие драйверы.
Для короткой CRUD-операции с JDBC reactive-обёртка не устраняет блокировку. Иногда обычный последовательный код проще и надёжнее.
Частые ошибки
- вызов
subscribe()внутри бизнес-метода вместо возврата publisher; - блокировка event loop через
block(), JDBC или долгий CPU-код; - повторная подписка к cold publisher и повтор побочного эффекта;
- бесконечный буфер вместо реального backpressure;
- потеря контекста и ошибки при переключении scheduler;
- отсутствие timeout и отмены внешнего запроса.
На собеседовании
Чем reactive stream отличается от Java Stream? Java Stream — синхронная pull-обработка конечного или вычисляемого источника; reactive stream — сигнализируемая во времени последовательность с завершением, ошибкой и управлением спросом.
Backpressure гарантирует отсутствие переполнения памяти? Нет. Оператор или внешний источник может буферизовать данные; стратегию очереди и пределы всё равно задаёт приложение.
Связанные заметки: Spring Reactor, Многопоточность в Java, Объект Future.