04/11/2023 - 16:50 · 10 Min read

Why I switched from Go to Rust for extremily high-performance Websocket servers

My insights on why I ultimately chose Rust over Golang for implementing a extremily high-performance websocket server, with a focus on memory management and garbage collection

Why I switched from Go to Rust for extremily high-performance Websocket servers

I've consistently been drawn to the challenges of high-performance server-side applications, with a particular focus on websocket servers. This long-standing interest has led me to extensively explore and compare various programming languages, but two have always stood out: Golang and Rust. Over the years, I've used both languages in production environments, pushing them to their limits in scenarios demanding extreme performance and scalability.

My focus will be on a critical aspect that often separates good from great in the realm of high-performance systems: memory management and garbage collection. These factors, while sometimes overlooked in less demanding applications, become crucial when you're handling thousands or event milions of concurrent connections with microsecond-level latency requirements.

It's crucial to note that this preference for Rust only applies in cases of extremely high performance requirements or when one is, like me, somewhat obsessed with squeezing out every last bit of performance. For the vast majority of use cases, Golang's implementation of websocket servers is more than sufficient and often outperforms many other popular choices like Java, C#, Node.js,.... Golang's simplicity, strong standard library, and built-in concurrency features make it an excellent choice for most high-performance scenarios.
Golang has always been my favorite and most recommended language for any backend developer.

The Allure of Golang

Let's start with Golang. It's a language I've used extensively and, for the most part, enjoyed. Its simplicity, strong standard library, and built-in concurrency features make it an attractive choice for many server-side applications. However, as I delved deeper into the realm of high-performance systems, I began to notice some limitations, particularly in its approach to memory management.

Golang's Garbage Collector: A Double-Edged Sword

Golang's garbage collector (GC) is often touted as one of its strengths. It's designed to be low-latency and concurrent, allowing for smooth execution of Go programs without significant pauses. For many applications, this works wonderfully. You write your code, and the GC takes care of memory management for you. It's like having a diligent housekeeper who cleans up after you without you even noticing.

However, this convenience comes at a cost, and it's a cost that becomes increasingly apparent in high-performance scenarios. The GC, no matter how efficient, still has several drawbacks:

  • CPU Overhead: It consumes CPU cycles, which can be significant in a system where every compute resource is precious.
  • Unpredictable Latency: GC can lead to unpredictable latency spikes. In a high-performance websocket server where every microsecond counts, these spikes can be problematic.
  • Memory Overhead: Go's GC requires extra memory to operate efficiently. It needs headroom to allocate new objects while it's running, which means your application might use more RAM than the actual data it's holding.
  • Memory Fragmentation: Over time, the GC can lead to memory fragmentation, which can result in inefficient memory usage.

The RAM Conundrum

Another issue I encountered with Golang in this context was its RAM usage. While Go's memory management is generally efficient, I found that in scenarios with a large number of concurrent connections, the memory footprint could grow larger than I'd like. This is partly due to the way Go allocates memory and how the GC works.For instance, consider this simple Go code for a websocket handler:

package main

import (
    "github.com/lxzan/gws"
    "net/http"
    "time"
)

const (
    PingInterval = 5 * time.Second
    PingWait     = 10 * time.Second
)

func main() {
    upgrader := gws.NewUpgrader(&Handler{}, &gws.ServerOption{
        ParallelEnabled:   true,
        Recovery:          gws.Recovery,
        PermessageDeflate: gws.PermessageDeflate{Enabled: true},
    })
    http.HandleFunc("/connect", func(writer http.ResponseWriter, request *http.Request) {
        socket, err := upgrader.Upgrade(writer, request)
        if err != nil {
            return
        }
        go func() {
            socket.ReadLoop()
        }()
    })
    http.ListenAndServe(":6666", nil)
}

type Handler struct{}

func (c *Handler) OnOpen(socket *gws.Conn) {
    _ = socket.SetDeadline(time.Now().Add(PingInterval + PingWait))
}

func (c *Handler) OnClose(socket *gws.Conn, err error) {}

func (c *Handler) OnPing(socket *gws.Conn, payload []byte) {
    _ = socket.SetDeadline(time.Now().Add(PingInterval + PingWait))
    _ = socket.WritePong(nil)
}

func (c *Handler) OnPong(socket *gws.Conn, payload []byte) {}

func (c *Handler) OnMessage(socket *gws.Conn, message *gws.Message) {
    defer message.Close()
    socket.WriteMessage(message.Opcode, message.Bytes())
}
lxzan/gws is the best ws library in Go (not gobwas/ws) trust me

This seemingly simple piece of code reveals some interesting aspects of Go's memory management and garbage collection:

  1. Deferred Execution: The defer message.Close() statement doesn't immediately free the memory associated with message. Instead, it schedules the Close() method to be called when the function returns.
  2. Garbage Collection Behavior: Contrary to what one might expect, the presence of defer doesn't trigger immediate garbage collection of message. The message object remains in memory throughout the function's execution, even after WriteMessage is called. Only after the function completes and Close() is called does message become eligible for garbage collection.
  3. Memory Overhead: During the function's execution, we're holding onto memory for both the original message and any data created by WriteMessage. This dual retention of data, while usually not a problem, could lead to increased memory usage in high-throughput scenarios.
  4. Unpredictable Cleanup: While defer ensures Close() is called, the actual memory cleanup depends on when the next GC cycle occurs. This non-deterministic cleanup can lead to temporary memory bloat and potentially unpredictable performance in extreme cases.

Enter Rust: A New Perspective on Memory Management

As I grappled with these challenges, I turned my attention to Rust. Now, I won't pretend that Rust is a magic bullet or that it's always the right choice. But for this specific use case - a high-performance websocket server - it offered some compelling advantages.

Ownership and Borrowing: A Different Paradigm

Rust's approach to memory management is fundamentally different from Go's. Instead of relying on a garbage collector, Rust uses a system of ownership and borrowing. This might sound intimidating at first (and trust me, it can be), but it offers a level of control and predictability that's hard to achieve with a GC-based language.

Here's a simplified example of how you might handle a websocket connection in Rust:

use tokio_tungstenite::{WebSocketStream, tungstenite::Message};
use futures::{SinkExt, StreamExt};
use tokio::net::TcpStream;
use std::time::Duration;

async fn handle_websocket(mut ws_stream: WebSocketStream<TcpStream>) {
    let (mut write, mut read) = ws_stream.split();
    
    let mut interval = tokio::time::interval(Duration::from_secs(30));

    loop {
        tokio::select! {
            msg = read.next() => {
                match msg {
                    Some(Ok(msg)) => {
                        if msg.is_close() {
                            break;
                        }
                        // Process message...
                        if let Err(e) = write.send(msg).await {
                            eprintln!("Error sending message: {:?}", e);
                            break;
                        }
                    }
                    Some(Err(e)) => {
                        eprintln!("Error receiving message: {:?}", e);
                        break;
                    }
                    None => break,
                }
            }
            _ = interval.tick() => {
                if let Err(e) = write.send(Message::Ping(vec![])).await {
                    eprintln!("Error sending ping: {:?}", e);
                    break;
                }
            }
        }
    }
}

At first glance, this might not look too different from the Go version. But under the hood, Rust's ownership system ensures that memory is managed efficiently without the need for a GC. The msg variable is automatically deallocated when it goes out of scope at the end of each loop iteration.

Predictable Performance

Rust ensures memory safety at compile-time through its ownership system, without runtime overhead. The key advantage of the Rust implementation in a high-performance scenario is its predictable memory usage and lack of GC pauses. Each message is processed without additional heap allocations, and memory is freed deterministically when it goes out of scope.

One of the biggest advantages I found with Rust in this context was its predictable performance. Without a GC, there are no unexpected pauses or latency spikes due to garbage collection. This predictability is crucial in a high-performance server environment where consistent low latency is key.

Fine-Grained Control

Rust also offers more fine-grained control over memory allocation. For instance, you can use custom allocators or arena allocation strategies to further optimize memory usage for your specific use case. This level of control is harder to achieve in Go, where you're more at the mercy of the GC.

The Trade-offs

Now, I'm not here to bash Golang or claim that Rust is superior in all cases. Both languages have their strengths and weaknesses. Golang's simplicity and ease of use make it an excellent choice for many projects, especially when rapid development is a priority.

Rust, on the other hand, has a steeper learning curve. The ownership system, while powerful, can be challenging to grasp initially. It's not uncommon to find yourself wrestling with the borrow checker, especially when you're just starting out.

Conclusion: Why I Made the Switch

My decision to switch from Go to Rust for high-performance WebSocket servers isn't just a personal preference. It's a move that's been mirrored by some of the biggest players in the tech industry. A prime example is Discord, the popular communication platform that handles millions of concurrent connections and billions of events daily.

In 2020, Discord made headlines in the developer community when they announced that they had rewritten a critical part of their infrastructure - the "Read States" service - from Go to Rust. This service is responsible for keeping track of which messages each user has read across all their conversations, a task that requires processing billions of events every day.

The results of this switch were impressive:

  • CPU usage decreased by 30-50%
  • Latency was significantly reduced
  • Overall operational costs went down

Discord's experience serves as a real-world validation of Rust's benefits in high-performance scenarios. It demonstrates that the advantages of Rust over Go in these extreme use cases aren't just theoretical - they translate into tangible improvements in systems operating at massive scale.While Go remains an excellent choice for many applications, Discord's case study, along with my own experience, illustrates why Rust is increasingly becoming the go-to language for systems that push the boundaries of performance and reliability, especially in the realm of WebSocket servers handling enormous concurrent loads.

This real-world example reinforces my decision to make the switch to Rust for extremely high-performance WebSocket servers. It shows that when every microsecond and every byte counts, Rust's unique features can provide a significant edge over even highly optimized Go implementations.

Is Rust the right choice for every project? Absolutely not. Is it overkill for many web applications? Probably.

As always in software development, the key is to choose the right tool for the job. Sometimes that means stepping out of your comfort zone and embracing a new paradigm. In this case, that paradigm was Rust's ownership system, and while it came with its own challenges, the results in terms of performance and resource usage made it worth the effort.Remember, there's no one-size-fits-all solution in our field. What worked for me might not work for you. The important thing is to understand the trade-offs and make informed decisions based on your specific requirements. And who knows? Maybe the next big project will lead me back to Go, or to some other language entirely. That's the beauty of our ever-evolving field - there's always something new to learn and explore.