Extensible Concurrency with the Sync and Send Traits

One of the interesting aspects of Oxide's concurrency story is that the language defines the concurrency primitives quite minimally. Nearly all concurrency features we've talked about are part of the standard library, not the language itself.

However, two concurrency concepts are embedded in the language: the Send and Sync traits.

Send and the Transferable Across Threads

The Send marker trait indicates that ownership of values of the type implementing Send can be transferred between threads. Almost every Oxide type is Send, but there are some exceptions, such as Rc<T>, which is not Send.

Rc<T> is not Send because of how it maintains reference counts. When you clone an Rc<T>, the reference count is incremented without using atomic operations. If you sent an Rc<T> to another thread and that thread cloned it while the original thread was also cloning it, the reference count could be corrupted.

Most basic types are Send: integers, floats, booleans, strings, and most collections built from Send types. Types that contain raw pointers are generally not Send because it's unsafe to send a raw pointer to another thread.

Examples of Send Types

// All of these are Send
let x: Int = 5
let s: String = "hello".toString()
let v: Vec<Int> = vec![1, 2, 3]

// This is Send because it contains only Send types
struct SendStruct {
    value: Int,
    text: String
}

Examples of Non-Send Types

// Rc is not Send (reference counting is not atomic)
import std.rc.Rc

let rcValue = Rc.new(5)
// thread.spawn move { println!(\(rcValue)) }  // Error: Rc is not Send

Sync and Thread-Safe Shared References

The Sync marker trait indicates that a type is safe to share by reference between threads. In other words, a type T is Sync if &T is Send. A reference is safe to send to another thread if the type implements Sync.

For example, i32 is Sync because references to i32 are safe to share with threads (integers are thread-safe). Cell<T>, on the other hand, is not Sync because it uses interior mutability without synchronization.

Why These Traits Matter

These traits help enforce thread safety at compile time:

import std.sync.Mutex
import std.sync.Arc
import std.thread

fn main() {
    let safeCounter = Arc.new(Mutex.new(0))

    let counterClone = Arc.clone(&safeCounter)
    thread.spawn move {
        var count = counterClone.lock().unwrap()
        *count += 1
    }
    // Code continues...
}

This compiles because:

  • Arc<T> is Send and Sync when T is both
  • Mutex<Int> is both Send and Sync
  • The compiler verifies ownership can be safely transferred

Implementing Send and Sync Manually

Most of the time, you don't need to implement Send and Sync manually. Oxide automatically derives these traits for structs and enums composed entirely of Send and Sync types.

However, in rare cases where you're working with raw pointers or other unsafe code, you might need to manually implement these traits:

// Only do this if you're sure your type is actually safe!
// This is unsafe to implement incorrectly.

struct MyType {
    ptr: *const Int
}

// UNSAFE: only implement if you know what you're doing
unsafe extension MyType: Send {}

unsafe extension MyType: Sync {}

As you can see, implementing Send and Sync requires the unsafe keyword. This is because the compiler can't verify that your type is actually safe to send or share across threads; you're promising it with the unsafe extension.

Common Patterns

Arc<Mutex<T>> is Send and Sync

When T is Send and Sync, Arc<Mutex<T>> is both:

import std.sync.Arc
import std.sync.Mutex
import std.thread

fn main() {
    let counter = Arc.new(Mutex.new(0))

    // Works because Arc<Mutex<Int>> is Send + Sync
    for _ in 0..5 {
        let c = Arc.clone(&counter)
        thread.spawn move {
            var n = c.lock().unwrap()
            *n += 1
        }
    }
}

Rc<T> is Neither Send nor Sync

Rc<T> is not Send because the reference counting isn't atomic. It's also not Sync because &Rc<T> is not Send.

import std.rc.Rc
import std.thread

fn main() {
    let rc = Rc.new(5)

    // This won't compile
    // thread.spawn move {
    //     println!(\(rc))  // Error: Rc is not Send
    // }
}

If you need shared ownership across threads, use Arc<T> instead of Rc<T>.

Cell<T> is Sync but Not Send

Cell<T> provides interior mutability using dynamic borrowing instead of locks. It's Sync because it's safe to share references, but not Send because it's not safe to move across threads:

import std.cell.Cell
import std.thread

fn main() {
    let cell = Cell.new(5)

    // Cell is Sync, so we can share a reference safely
    // (but this requires a reference, not an owned value)

    // Cannot do this: Cell is not Send
    // thread.spawn move {
    //     cell.set(10)  // Error: Cell is not Send
    // }
}

Rust Comparison

The Send and Sync traits work identically between Rust and Oxide:

ConceptRustOxideNotes
Send traitBuilt-inBuilt-inIndicates safe to transfer ownership
Sync traitBuilt-inBuilt-inIndicates safe to share by reference
Manual implunsafe extension T: Send {}unsafe impl Send for T {}Oxide uses extension
Arc is Send+SyncWhen T isWhen T isFor thread-safe shared ownership
Rc is not SendCorrectCorrectReference counting is not atomic

The behavior and semantics are identical. Both languages use these traits to provide compile-time verification of thread safety without runtime overhead.

Summary

  • Send - A marker trait indicating that a type can be safely transferred between threads
  • Sync - A marker trait indicating that a type is safe to share by reference between threads
  • Types composed of Send/Sync types are automatically Send/Sync
  • Arc<T> is Send and Sync when T is both
  • Rc<T> is neither Send nor Sync
  • You can manually implement these traits with unsafe extension, but should only do so when you're certain of thread safety
  • The compiler uses these traits to prevent data races at compile time