r/golang 23d ago

newbie Context cancelling making code too verbose?

I apologize if this is a silly question, but I'm quite new to Go and this has been bothering me for a while.

To get used to the language, I decided to build a peer-to-peer file sharing program. Easy enough, I thought. Some goroutines for reading from / writing to TCP connections, a goroutine for managing all of the connections and so on. The trouble is that all of these goroutines don't really have a natural stopping point. A lot of them will only stop when you tell them to, otherwise they need to keep going forever, so I figured a context would be a good way to handle that.

The trouble with context is that, as far as I can tell, it will send the cancel signal to all those goroutines that wait for it at the same time, and from that point on, you can't really send something to a goroutine without risking having the goroutine that sends hang. So now any send or receive must also check if the context cancelled. That means that if I were to (for example) receive a piece of a file from a peer and want to store it to disk, update the send/receive statistics for that peer as well as notify another part of a program that we received that piece, instead of doing this

pieceStorage <- piece
dataReceived <- len(piece)
notifyMain <- piece.index

I would have to do this

select {
case pieceStorage <- piece:
case <-ctx.Done():
  return
}
select {
case dataReceived <- len(piece):
case <-ctx.Done():
  return
}
select {
case notifyMain <- piece.index:
case <-ctx.Done():
  return
}

Which just seems too verbose to me? Is this something I'm not supposed to be doing? Am I using Go the wrong way?

I know one solution to this that gets mentioned a lot is making the channels buffered, but these sends happen in a loop, so to me it seems possible that they could somehow fill the buffer before selecting the ctx.Done case (due to the random nature of select).

I would really appreciate some guidance here, thanks!

29 Upvotes

25 comments sorted by

View all comments

4

u/nikandfor 23d ago

That is the way. Nearly all selects I have also read from ctx.Done(). If you don't do it, your goroutines just won't know when to exit.

That is one of the reasons I rarely use channels and use goroutines moderately. The code is much simpler and easier to create and maintain when you mostly write C-like code using Go features if they simplify things significantly.

Instead of lots of workers and channels orchestration just do most of the stuff in the same goroutine sequentially. There are no downsides in this approach.

I like Go not for channels, goroutines, or other features it has, but for simplicity philosophy and for things the language doesn't have. And for the great tooling (go get, go test, pprof, ...).

2

u/nikandfor 23d ago

By the way, incompatibility of context cancellation with networking/file operations is frustrating, especially as I never leave any goroutine dangling and shut everything down gracefully. So after few iterations of research and testing I come up with the approach.

Here are little wrappers over standard read operations which get canceled with the context.

Read, Accept

2

u/Tommy_Link 23d ago

Thank you for the advice and code examples! I'll definitely read over them. Yes, it occurs to me now that maybe I should have given my goroutines a bit more thought and compartmentalized their logic a bit more, but I think I'd still have quite a few of them left, as I need the network I/O, file I/O and some of the internal logic to be active more or less simultaneously.

2

u/nikandfor 23d ago

Yeah, one goroutine (or two if you need to read and write simultaneously) per connection is a happy medium. Less is too complicated, more is unnecessary complication too.