I thought it was time to share some insights into a key part of a project I’ve been working on. It's not the whole codebase – I wouldn't want to bore you to tears! But I do want to talk about some of the core concepts I've implemented.
One of the critical requirements of this project was building a messaging system capable of handling a potentially massive throughput of messages and ensuring they reach their intended destinations efficiently.
Now, I could have gone with off-the-shelf solutions like ROS (Robot Operating System). However, I'm a bit of a control freak and enjoy crafting things from the ground up.
That's how Polestar was born.
Polestar is a custom library designed to handle messages composed of maps (or dictionaries) containing primitive data types. Think strings, integers, floats, and booleans. These messages are published to Polestar with a specific topic, and any application subscribed to that topic receives a copy.
My initial attempt at building this system was decent enough, achieving a throughput of about 800 messages per second. But I started thinking about how I could push the boundaries, enhance the throughput, and make the system even more robust.
And guess what? I did it! I managed to crank up the throughput to an impressive 16,000 messages per second. That's more than sufficient for any scenario I can currently envision.
To maintain efficiency, if the message queue is full when a new message arrives, the message is dropped to prevent blocking the processes. Considering the queue's substantial capacity of 1,000,000 messages, this scenario should be quite rare.
The Queue Conundrum
But here's where it gets interesting. Recently, I started pondering: what if, instead of dropping the newest message when the queue is full, we dropped the oldest message? How difficult would that be to implement?
Go's channels, in their default state, don't offer this specific behavior. However, as is often the case in programming, there are multiple ways to achieve it.
One approach involves creating a struct that encapsulates a queue (as a slice) and uses a single channel. But this felt like overkill for such a small feature. Plus, I'd lose the inherent speed advantages of Go's channels.
So, I devised what I believe is a more elegant solution. It leverages the fundamental nature of channels and preserves the ability to iterate over them in the standard way.
Go's flexibility allows you to create a new type based on an existing type, even a primitive one. In this case, I created a new type called ch based on a channel of strings:
type ch chan string
This opens the door to using Go's method functionality to add a custom behavior to our new type. I created a Send method with the following logic:
// Send attempts to send a message to the channel.
// If the channel is full,
// it drops the oldest message and tries again.
// Returns a boolean indicating
// whether a message was dropped (true) and an error if the operation failed.
// The error is non-nil only if the channel remains full after attempting to
// drop the oldest message.
func (c ch) Send(msg string) (bool, error) {
select {
case c <- msg:
return false, nil
default:
// Channel is full, drop the oldest and try again
<-c // Discard oldest
select {
case c <- msg:
// Message sent after dropping oldest
return true, nil
default:
//This should rarely, if ever, happen.
//Handle error/log message.
return true, errors.New("Error: Channel still full after dropping oldest.")
}
}
}
This Send method replaces the typical channel send operation:
chVar <- “hello”
with:
chVar.Send(“hello”)
The beauty of this is that if you've created a buffered channel, the oldest item in the queue is dropped when the queue is full. This can be incredibly useful in scenarios like robotics, where outdated messages might lose their relevance, and prioritizing the latest information is crucial.
I haven't integrated this into Polestar just yet. I'm still weighing the pros and cons of dropping the newest versus the oldest message. Ideally, of course, no messages would be dropped at all.
To give you a glimpse of Polestar's speed, here's a short video of one of the test runs:
My original plan involved using a hardware hub for this project. However, I don't believe I could have achieved this level of performance with a microcontroller (MCU), especially considering the queue size. Polestar's heavy use of concurrency would also pose a challenge for microcontrollers.
The trade-off is that all communication now relies on TCP instead of serial. Serial communication might have offered faster data transmission with less overhead, but the routing complexities would have been a significant hurdle.
I hope this deep dive into my process provides some food for thought, especially for fellow developers. And for those who aren't knee-deep in code, I hope it offers a little peek into how my mind works.
I welcome any comments or questions you might have. Please feel free to leave them in the comments section below!
No comments:
Post a Comment