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.
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