Add the protocol-comparison module
This commit is contained in:
112
protocol-comparison/docs/04-backpressure.md
Normal file
112
protocol-comparison/docs/04-backpressure.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# 4. Back-pressure: the measurement that decides it
|
||||
|
||||
Previous: [3. Payload](03-payload.md) · Next: [5. Choosing](05-choosing.md)
|
||||
|
||||
---
|
||||
|
||||
Every protocol comparison eventually reduces to one question, and it is not throughput. It is:
|
||||
|
||||
> **When the consumer stops keeping up, what happens?**
|
||||
|
||||
[`BackPressureTest`](../src/test/java/com/ankurm/protocols/BackPressureTest.java) 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`](output/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](02-benchmarks.md) 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`](output/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](https://ankurm.com/spring-grpc-spring-boot-4/) 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`](../src/main/java/com/ankurm/protocols/GrpcQuoteService.java) checks
|
||||
`Context.current().isCancelled()` and returns without calling `onCompleted` for exactly this
|
||||
reason.
|
||||
|
||||
---
|
||||
|
||||
Previous: [3. Payload](03-payload.md) · Next: [5. Choosing](05-choosing.md)
|
||||
Reference in New Issue
Block a user