Back to Blog
C vs Rust vs Go: Systems Programming Language Comparison

C vs Rust vs Go: Systems Programming Language Comparison

December 18, 2024
7 min read
Tushar Agrawal

Compare C, Rust, and Go for systems programming. Analyze memory safety, performance, concurrency, and use cases to choose the right language for your project.

Introduction

Systems programming requires languages that provide control over hardware resources while maintaining performance. C has dominated this space for 50 years, but Rust and Go offer modern alternatives with different trade-offs. C provides raw power, Rust guarantees memory safety without garbage collection, and Go prioritizes simplicity and fast development.

Language Philosophy

C: Maximum Control

// C - Direct memory manipulation
#include 
#include 
#include 

typedef struct { int id; char name[50]; double balance; } Account;

Account create_account(int id, const char name, double balance) { Account acc = (Account)malloc(sizeof(Account)); if (acc == NULL) return NULL;

acc->id = id; strncpy(acc->name, name, 49); acc->name[49] = '\0'; acc->balance = balance; return acc; }

void free_account(Account* acc) { free(acc); }

// Manual memory management - no safety guarantees int main() { Account* acc = create_account(1, "John Doe", 1000.0); if (acc) { printf("Account: %s, Balance: %.2f\n", acc->name, acc->balance); free_account(acc); } // Danger: use-after-free possible if acc is accessed here return 0; }

Rust: Safety Without Sacrifice

// Rust - Memory safety at compile time
struct Account {
    id: u32,
    name: String,
    balance: f64,
}

impl Account { fn new(id: u32, name: &str, balance: f64) -> Self { Account { id, name: name.to_string(), balance, } } }

fn main() { let acc = Account::new(1, "John Doe", 1000.0); println!("Account: {}, Balance: {:.2}", acc.name, acc.balance); // acc is automatically freed when it goes out of scope // Use-after-free is impossible - compiler prevents it }

// Ownership and borrowing fn process_accounts(accounts: &[Account]) { for acc in accounts { println!("{}: {}", acc.name, acc.balance); } // accounts is borrowed, not moved - original owner keeps it }

Go: Simplicity and Productivity

// Go - Simple, garbage collected
package main

import "fmt"

type Account struct { ID int Name string Balance float64 }

func NewAccount(id int, name string, balance float64) *Account { return &Account{ ID: id, Name: name, Balance: balance, } }

func main() { acc := NewAccount(1, "John Doe", 1000.0) fmt.Printf("Account: %s, Balance: %.2f\n", acc.Name, acc.Balance) // Garbage collector handles memory }

Memory Management

Memory Management Comparison
============================

Aspect C Rust Go ────────────────────────────────────────────────────────────── Model Manual Ownership Garbage Collection Safety None (runtime) Compile-time Runtime (GC) Performance Best Near-C Good (GC pauses) Memory Leaks Possible Prevented Prevented Dangling Ptrs Possible Prevented Prevented Data Races Possible Prevented Possible (goroutines) Learning Curve Moderate Steep Easy

Rust Ownership Example

// Rust ownership prevents memory issues at compile time
fn main() {
    let s1 = String::from("hello");
    let s2 = s1;  // s1 is moved to s2

// println!("{}", s1); // Compile error! s1 is no longer valid

// Borrowing allows multiple readers let s3 = String::from("world"); let len = calculate_length(&s3); // Borrow s3 println!("{} has length {}", s3, len); // s3 still valid

// Mutable borrow let mut s4 = String::from("hello"); change(&mut s4); }

fn calculate_length(s: &String) -> usize { s.len() }

fn change(s: &mut String) { s.push_str(", world"); }

Performance Benchmarks

Benchmark Results (Relative Performance)
========================================

Task C Rust Go ──────────────────────────────────────────── Binary search 1.0x 1.0x 1.5x HTTP server 1.0x 1.05x 1.3x JSON parsing 1.0x 1.1x 2.0x Regex matching 1.0x 0.95x 3.0x Memory allocation 1.0x 1.0x 2.0x Concurrent tasks 1.0x 1.0x 0.9x

Binary Size (Hello World) ───────────────────────── C: 16KB Rust: 300KB (default), 50KB (optimized) Go: 2MB (static), 1MB (stripped)

Compile Time (100K LOC) ─────────────────────── C: 10s Rust: 60s Go: 5s

Concurrency

C with pthreads

#include 
#include 
#include 

#define NUM_THREADS 4

typedef struct { int* data; int start; int end; long result; } ThreadArgs;

void sum_array(void args) { ThreadArgs ta = (ThreadArgs)args; ta->result = 0; for (int i = ta->start; i < ta->end; i++) { ta->result += ta->data[i]; } return NULL; }

int main() { int data[1000]; for (int i = 0; i < 1000; i++) data[i] = i;

pthread_t threads[NUM_THREADS]; ThreadArgs args[NUM_THREADS];

int chunk = 1000 / NUM_THREADS; for (int i = 0; i < NUM_THREADS; i++) { args[i].data = data; args[i].start = i * chunk; args[i].end = (i + 1) * chunk; pthread_create(&threads[i], NULL, sum_array, &args[i]); }

long total = 0; for (int i = 0; i < NUM_THREADS; i++) { pthread_join(threads[i], NULL); total += args[i].result; }

printf("Total: %ld\n", total); return 0; }

Rust with async/await

use tokio;
use std::sync::Arc;

async fn sum_chunk(data: Arc>, start: usize, end: usize) -> i64 { data[start..end].iter().map(|&x| x as i64).sum() }

#[tokio::main] async fn main() { let data: Arc> = Arc::new((0..1000).collect()); let chunk_size = 250;

let handles: Vec<_> = (0..4) .map(|i| { let data = Arc::clone(&data); let start = i * chunk_size; let end = start + chunk_size; tokio::spawn(async move { sum_chunk(data, start, end).await }) }) .collect();

let total: i64 = futures::future::join_all(handles) .await .into_iter() .map(|r| r.unwrap()) .sum();

println!("Total: {}", total); }

// Rust prevents data races at compile time // This won't compile: // fn data_race() { // let mut data = vec![1, 2, 3]; // std::thread::spawn(|| data.push(4)); // Error: data moved // data.push(5); // Error: data already moved // }

Go with goroutines

package main

import ( "fmt" "sync" )

func sumChunk(data []int, result chan<- int64, wg *sync.WaitGroup) { defer wg.Done() var sum int64 for _, v := range data { sum += int64(v) } result <- sum }

func main() { data := make([]int, 1000) for i := range data { data[i] = i }

results := make(chan int64, 4) var wg sync.WaitGroup

chunkSize := 250 for i := 0; i < 4; i++ { wg.Add(1) start := i * chunkSize end := start + chunkSize go sumChunk(data[start:end], results, &wg) }

go func() { wg.Wait() close(results) }()

var total int64 for sum := range results { total += sum }

fmt.Printf("Total: %d\n", total) }

Error Handling

// C - Return codes
int read_file(const char path, char* content) {
    FILE* f = fopen(path, "r");
    if (!f) return -1;

fseek(f, 0, SEEK_END); long size = ftell(f); fseek(f, 0, SEEK_SET);

*content = malloc(size + 1); if (!*content) { fclose(f); return -2; }

fread(*content, 1, size, f); (*content)[size] = '\0'; fclose(f); return 0; }

// Rust - Result type
use std::fs;
use std::io;

fn read_file(path: &str) -> Result { fs::read_to_string(path) }

fn main() -> Result<(), Box> { let content = read_file("file.txt")?; // ? propagates errors println!("{}", content); Ok(()) }

// Go - Multiple returns
func readFile(path string) (string, error) {
    content, err := os.ReadFile(path)
    if err != nil {
        return "", fmt.Errorf("reading %s: %w", path, err)
    }
    return string(content), nil
}

Use Cases

When to Use Each Language
=========================

Use C when: ├── Writing OS kernels or drivers ├── Embedded systems with tight constraints ├── Need absolute performance control ├── Interfacing with legacy C code └── Maximum portability required

Use Rust when: ├── Memory safety is critical ├── Building concurrent systems ├── Web browsers, game engines ├── CLI tools with native performance └── Replacing C in new projects

Use Go when: ├── Building web services and APIs ├── DevOps and infrastructure tools ├── Need fast development cycles ├── Team productivity matters └── Kubernetes/cloud-native apps

Decision Matrix

Language Selection Matrix
=========================

Criteria C Rust Go ────────────────────────────────────────────── Memory safety ✗ ✓✓✓ ✓✓ Raw performance ✓✓✓ ✓✓✓ ✓✓ Concurrency safety ✗ ✓✓✓ ✓✓ Development speed ✓ ✓✓ ✓✓✓ Learning curve ✓✓ ✓ ✓✓✓ Ecosystem maturity ✓✓✓ ✓✓ ✓✓✓ Binary size ✓✓✓ ✓✓ ✓ Compile time ✓✓ ✓ ✓✓✓ Cross-compilation ✓✓ ✓✓✓ ✓✓✓

✓✓✓ = Excellent ✓✓ = Good ✓ = Adequate ✗ = Poor

Conclusion

  • C: Maximum control, best for OS/embedded, requires expertise
  • Rust: Modern C replacement with safety guarantees, steeper learning curve
  • Go: Productive systems programming, excellent for services and tools
Choose based on your project's safety requirements, performance needs, and team expertise. Many organizations use multiple languages, selecting the right tool for each component.

Related Articles

Share this article

Related Articles