Day 117 — Bidirectional Streaming¶
Month 5 · Week 1 · ⬅ Day 116 · Day 118 ➡ · Journal index
🎯 Learning Objective¶
Implement a full-duplex (bidirectional) streaming RPC where both sides read and write concurrently, and get the goroutine + termination discipline right.
📚 Topics¶
rpc Chat(stream Msg) returns (stream Msg)and the generated bidi stream interface- Concurrent
Send/Recv, half-close, error propagation viaerrgroup/channels
📖 Reading / Sources¶
- grpc.io — Bidirectional streaming RPC
- grpc.io — Core concepts: RPC life cycle
- grpc-go route_guide example
📝 Notes¶
- Bidirectional = both
streamkeywords:rpc Chat(stream ChatMessage) returns (stream ChatMessage). The two directions are independent — ordering is only guaranteed within a direction, not across them → [[grpc]]. - The natural shape: the server loops
Recv()andSend()s replies; the client runs two goroutines — one pumpingSend, one drainingRecv— coordinated by a channel orsync.WaitGroup/errgroup→ [[goroutines]]. - Termination is a half-close handshake. The client signals "no more requests" with
stream.CloseSend(); the server'sRecvthen returnsio.EOFand it can finish, which the client sees asio.EOFon itsRecv. Each side closes its own write direction. - The one-goroutine-per-direction axiom is mandatory here: concurrent
Sends (or concurrentRecvs) corrupt framing. Sending and receiving simultaneously is fine because they're separate streams → [[channel-axioms]]. - Propagate the first error and cancel the rest: if
Recvfails, cancel the context so theSendgoroutine unblocks.golang.org/x/sync/errgroupwith a derived context is the idiomatic way (or a plaindonechannel). - The shared
contextstill governs everything: cancellation or deadline tears down both directions at once.
💻 Code Examples¶
// Server: echo each message back, in order, until the client half-closes.
func (s *server) Chat(stream chatv1.ChatService_ChatServer) error {
for {
msg, err := stream.Recv()
if errors.Is(err, io.EOF) {
return nil // client called CloseSend -> we're done
}
if err != nil {
return err
}
if err := stream.Send(&chatv1.ChatMessage{Text: "echo: " + msg.GetText()}); err != nil {
return err
}
}
}
// Client: one goroutine sends, the main goroutine receives.
stream, _ := client.Chat(ctx)
go func() {
for _, t := range []string{"hi", "there"} {
if err := stream.Send(&chatv1.ChatMessage{Text: t}); err != nil {
return
}
}
stream.CloseSend() // half-close: no more outgoing messages
}()
for {
in, err := stream.Recv()
if errors.Is(err, io.EOF) {
break // server finished
}
if err != nil {
log.Fatal(err)
}
fmt.Println(in.GetText())
}
The framing that carries messages in both directions is the same 5-byte frame as the half-duplex case:
examples/month-05/framing· Run:go run ./examples/month-05/framing
🏋️ Exercises / Practice¶
| Exercise | Status | Link |
|---|---|---|
| Length-prefixed frame round-trip (both directions use it) | ✅ | exercises/month-05/week-1/frames |
🐛 Mistakes Made¶
- Called
stream.Sendfrom two goroutines to "go faster" → garbled stream. Reverted to a single sender goroutine. - Forgot
CloseSend()on the client, so the server'sRecvnever sawio.EOFand the RPC hung. Half-close is required.
❓ Open Questions¶
- Best pattern for surfacing the send goroutine's error to the caller? (Leaning:
errgroup.Groupwith the stream context so the first failure cancels both directions.)
🧠 Active Recall (answer without looking)¶
- Q: Why does a bidi client typically need two goroutines?
A
The two directions are independent, so you want to Send and Recv concurrently. One goroutine pumps sends (and CloseSends when done); another drains receives until io.EOF. They must be separate goroutines because concurrent sends (or concurrent receives) on one stream are not allowed.
2. Q: What sequence cleanly shuts down a bidi stream from the client?A
The client calls CloseSend() (half-close its write side). The server's Recv returns io.EOF, it finishes its handler, and the client's Recv then returns io.EOF. Each side closes only its own outgoing direction.
🪶 Feynman Reflection¶
Bidirectional streaming is two independent pipes over one connection. Each side reads its pipe and writes the other. The discipline is small but strict: one goroutine per direction, CloseSend to say "I'm done writing", and io.EOF to learn the peer is done. Cancel the context to kill both pipes at once.
🕳️ Knowledge Gaps¶
- Coordinating many concurrent bidi streams (e.g., a chat fan-out hub) without leaking goroutines.
- Flow-control interplay when both directions are hot.
✅ Summary¶
I can implement a bidirectional streaming RPC with a single-sender/single-receiver goroutine model, half-close correctly, and propagate cancellation across both directions.
⏭️ Next Steps / Prep for Tomorrow¶
- Day 118: deadlines, metadata, and the status-code error model — the cross-cutting concerns of every RPC.
| Time spent | Difficulty | Confidence |
|---|---|---|
| 90 min | 🟦🟦🟦⬜⬜ | 🟦🟦🟦⬜⬜ |
Suggested commit: docs(journal): bidirectional streaming (day 117)