Table of Contents

Unsafe and raw pointers

The safe world

In normal Nyx code, you cannot corrupt memory. The garbage collector manages allocations, array bounds are checked, and null pointers do not exist. This safety has a small cost — the GC adds overhead, bounds checks add instructions.

Sometimes you need to escape these guardrails: writing a memory allocator, interfacing with hardware, or squeezing the last drop of performance from a hot loop. That is what unsafe is for.

The unsafe block

Unsafe operations must be wrapped in an unsafe block:

fn main() {
    unsafe {
        let ptr: *int = alloc(8)    // allocate 8 bytes
        *ptr = 42                    // write to raw memory
        print(*ptr)                  // read from raw memory: 42
        free(ptr)                    // deallocate
    }
}

The unsafe keyword is a contract: "I, the programmer, guarantee this code is correct. The compiler should not try to protect me here."

Raw pointers

A raw pointer is a memory address. Unlike references in safe code, raw pointers can be null, dangling, or unaligned. They give you direct access to memory.

fn main() {
    var x: int = 100

    unsafe {
        let ptr: *int = &x       // get address of x
        print(*ptr)               // dereference: 100

        *ptr = 200                // modify through pointer
        print(x)                  // 200 — x changed!
    }
}

Manual memory management

In safe Nyx, the garbage collector handles all allocations. In unsafe code, you manage memory yourself:

fn main() {
    unsafe {
        // Allocate space for 10 integers (10 * 8 bytes)
        let arr: *int = alloc(80)

        // Write values
        var i: int = 0
        while i < 10 {
            *(arr + i) = i * i    // pointer arithmetic
            i += 1
        }

        // Read values
        i = 0
        while i < 10 {
            print(*(arr + i))
            i += 1
        }

        free(arr)
    }
}

Output:

0
1
4
9
16
25
36
49
64
81

alloc(bytes) allocates raw memory (like C's malloc). free(ptr) releases it. Forgetting to call free causes a memory leak. Calling free twice causes undefined behavior.

Pointer arithmetic

You can add to pointers to navigate through memory:

unsafe {
    let base: *int = alloc(32)    // 4 integers * 8 bytes each

    *(base + 0) = 10
    *(base + 1) = 20
    *(base + 2) = 30
    *(base + 3) = 40

    // base + N moves N * sizeof(int) bytes forward
    print(*(base + 2))    // 30

    free(base)
}

Static variables

Static variables live for the entire program lifetime, outside the garbage collector:

static var request_count: int = 0

fn log_request() {
    request_count = request_count + 1
    print("Request #" + int_to_string(request_count))
}

fn main() {
    log_request()    // Request #1
    log_request()    // Request #2
    log_request()    // Request #3
}

Static variables are useful for global state that needs to be fast and GC-free.

Volatile reads and writes

Normally, the compiler can reorder or eliminate memory operations if it thinks they are redundant. Volatile operations prevent this:

unsafe {
    let ptr: *int = alloc(8)

    volatile_write(ptr, 42)     // guaranteed to write
    let v: int = volatile_read(ptr)    // guaranteed to read

    free(ptr)
}

Volatile is essential for:

Atomic operations

When multiple threads access the same memory, you need atomic operations to prevent race conditions without a mutex:

unsafe {
    let counter: *int = alloc(8)
    atomic_store(counter, 0)

    // Thread-safe increment pattern:
    let current: int = atomic_load(counter)
    atomic_store(counter, current + 1)

    print(atomic_load(counter))    // 1

    free(counter)
}

Atomic operations use sequential consistency — the strongest memory ordering guarantee. Every thread sees operations in the same order.

When to use unsafe

Use unsafe only when you must:

Never use unsafe just because it feels faster. Measure first. The GC is highly optimized and adds less overhead than most people think.

Exercises

  1. Write an unsafe function that allocates an array of 100 integers, fills them with values 0-99, sums them, frees the memory, and returns the sum.
  1. Create a static counter variable and increment it from two different functions. Print the final value.
  1. Write a function that swaps two integers using raw pointers: fn swap(a: int, b: int).
  1. Allocate a block of memory, write values using pointer arithmetic, then read them back in reverse order.
  1. Write a program that uses volatile_write and volatile_read to simulate a hardware register that a signal handler might modify.

Summary

Next chapter: Async and event loop →

← Previous: FFI — Calling C code Next: Async and event loop →