Raising grpc.max_concurrent_streams above 100: latency spikes under bursty load on a single HTTP/2 connection
0 reputation · 04 May 2020, 12:41 UTC
0 reputation · 04 May 2020, 12:41 UTC
A gRPC service currently runs with the default concurrent stream limit (100 in many implementations) over a single HTTP/2 connection per client. Under bursty concurrency, p99 latency rises sharply even though sequential-request benchmarks show flat, low latency. The plan under consideration is to raise grpc.max_concurrent_streams rather than add more client connections.
Because all streams share one TCP connection, packet loss stalls every stream at once (head-of-line blocking), and the default HTTP/2 flow-control window (65,535 bytes per stream and per connection) can throttle many concurrent streams competing for the connection-level window. Raising the stream cap may reduce queuing at the limit but could worsen contention for the shared flow-control window and the server executor. Trade-offs of the alternative—client-side connection pooling—include higher resource use and uneven stream distribution. Behavior also differs across gRPC implementations and versions, so defaults should be confirmed for the version in use.
grpc.max_concurrent_streams on a single connection actually reduce concurrency-induced latency, or does it just shift the bottleneck to flow control and TCP head-of-line blocking?GRPC_TRACE=http2,flowctl output, packet captures) best distinguish flow-control stalls from server thread-pool queuing?Raising grpc.max_concurrent_streams alone rarely reduces tail latency under bursty load on a single HTTP/2 connection. It removes the application-level queue at the stream limit but shifts contention to the shared connection flow-control window (default 64 KiB) and the server executor pool. TCP head-of-line blocking remains a fundamental limitation: any packet loss stalls every stream simultaneously. Adding a second HTTP/2 connection typically outperforms single-connection tuning once sustained concurrency exceeds roughly 2× the bandwidth-delay product (BDP) in streams.
WINDOW_UPDATE frames, adding an RTT per round-trip. This serializes progress and inflates p99 latency.WINDOW_UPDATE frames indicate flow-control stalls. Correlate zero-window duration with p99 spikes.ThreadPoolExecutor.getQueue().size(), Go runtime/pprof goroutine profile, C++ completion queue depth. Low queue depth with high zero-window time = flow control. High queue depth = executor saturation.ghz or custom client) at 50, 100, 200, 500 concurrent RPCs on a single connection. Capture p50/p99/p999 latency, GRPC_TRACE=http2,flowctl logs, and tcpdump.SETTINGS_INITIAL_WINDOW_SIZE (e.g., to 1 MiB) before raising the stream limit. Verify peer support via SETTINGS frames in the capture.For a typical 10 Gbps LAN with 0.5 ms RTT (BDP ≈ 625 KB), a single 64 KiB window supports ~10 streams. Bursty loads exceeding 20–30 concurrent RPCs benefit from connection pooling. The crossover scales with RTT and window size. Client-side pooling (e.g., Java ManagedChannelBuilder, Go grpc.WithDefaultCallOptions, .NET HttpClientHandler.PooledConnectionLifetime) distributes streams across TCP connections, eliminating single-connection HOL blocking and flow-control contention at the cost of ~2–3× more file descriptors and TLS handshakes.
What is your gRPC runtime and version (e.g., gRPC-Go v1.62, gRPC-Java 1.62, gRPC-C++ 1.62)? Auto-tuning behavior and default window sizes vary, and some proxies clamp SETTINGS_INITIAL_WINDOW_SIZE to 64 KiB end-to-end.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.