1
0
Files

5.4 KiB

4. Back-pressure: the measurement that decides it

Previous: 3. Payload · Next: 5. Choosing


Every protocol comparison eventually reduces to one question, and it is not throughput. It is:

When the consumer stops keeping up, what happens?

BackPressureTest asks it the simplest way there is. Each server offers an unbounded stream. Each client takes a hundred messages and then does nothing. How many did the server produce?

The sweep over quiet periods is what makes the answer conclusive. A number that stays flat as the wait grows means something bounded the producer. A number that keeps climbing means nothing did.

backpressure.txt:

protocol       quiet 0.5s   quiet 1s   quiet 2s
RSocket               100        100        100
gRPC              103,425    449,318  1,208,219
WebSocket          19,664    143,870    320,255

RSocket produced exactly one hundred, at every quiet period. gRPC produced over a million in two seconds. Raw WebSocket, three hundred thousand. These are re-measured on every run and the absolute counts move by tens of percent; the three orders of magnitude between the rows do not.

(The growth is faster than linear because the loop is still being JIT-compiled during the shortest run. The shape is the finding, not the exponent.)

Why RSocket stops

request(n) is a frame on the wire. When the client's subscriber calls subscription.request(100), a REQUEST_N frame carries that number to the server, and Reactor's Flux.generate is called exactly a hundred times and then not again. Nothing blocks; there is no buffer to size and no thread parked. The producer simply is not invoked.

That is what "Reactive Streams over the network" means, and it is the entire reason RSocket exists. The same property is why RSocket's streaming throughput in chapter 2 is respectable rather than the highest: it is doing real work per batch that the others are not doing at all.

Why the other two do not

Raw WebSocket has no mechanism. The frame protocol has no notion of demand. The server writes until something underneath refuses — a socket buffer, Spring's sendBufferSizeLimit, the heap. Every one of those 320 255 messages was serialised and written for a client that had asked for a hundred.

gRPC has HTTP/2 flow control, and it did not help. This is the result I did not expect. StreamObserver.onNext on a gRPC server does not block, so a plain server-streaming loop keeps producing regardless of what the client is doing.

The documented answer is ServerCallStreamObserver.isReady(), and this module tests it rather than citing it (grpc-isready.txt):

handler                      quiet 0.5s   quiet 2s
plain onNext loop               258,145  1,421,778
isReady() checked before write    667,259  1,044,018

It did not bound the producer. The isReady() row is not lower than the plain one in any useful sense — it produced 667 thousand messages in half a second for a client that wanted a hundred — and across runs it has come out both far above and far below the plain loop, which is its own kind of answer. I have not established whether the surplus accumulates in the server's outbound queue, in the client transport, or is discarded after the deframer, and I am not going to assert a mechanism I have not read the source for.

What the numbers do establish is narrower and still worth knowing: isReady() is not a demand signal, and on a fast link with a stalled consumer it does not behave like one. If you are streaming from gRPC to a consumer that can fall behind, the bound has to come from your own design — a paged API, an explicit "send me more" message on a bidirectional stream, or a count in the request as StreamQuotes uses.

The honest caveat

A loopback connection is the worst case for this test in the fairest possible way: the wire drains instantly, so nothing downstream ever pushes back. On a slow real link, TCP and the HTTP/2 window would eventually stop the gRPC and WebSocket producers — by blocking a thread or by filling a buffer, but they would stop.

That does not rescue them, because the failure mode you actually meet is not "the network is slow", it is "one consumer of forty is slow", and a shared server discovers that as latency for everyone else or as a heap that will not shrink. The difference RSocket makes is that the slow consumer's slowness is expressed, in a number, that the producer can act on before anything fills up.

The other thing gRPC does not do for you

Abandoning a gRPC stream produced this, repeatedly, in the server log:

java.lang.IllegalStateException: Failed to close the call
Caused by: java.lang.IllegalStateException: call already closed

That is the cancellation story from the Spring gRPC article showing up in practice: gRPC does not interrupt your thread when a client leaves, it sets a flag on the Context, and a handler that touches an already-closed stream throws. The loop in GrpcQuoteService checks Context.current().isCancelled() and returns without calling onCompleted for exactly this reason.


Previous: 3. Payload · Next: 5. Choosing