Using Threads to Run Code Simultaneously

In most current operating systems, an executed program's code runs in a process, and the operating system manages multiple processes at once. Within a program, you can also have independent parts that run simultaneously. The features that run these independent parts are called threads.

Splitting the computation in your program into multiple threads to run multiple tasks at the same time can improve performance, but it also adds complexity. Because threads can run simultaneously, there's no guarantee about the order in which parts of your code on different threads will run. This can lead to problems such as:

  • Race conditions - Threads accessing data or resources in an inconsistent order
  • Deadlocks - Two threads waiting for each other, preventing both from continuing
  • Bugs that happen only in certain situations - Hard to reproduce and fix reliably

Oxide's ownership system and type checker help prevent these problems at compile time. Let's explore how to work with threads safely.

Creating a New Thread with spawn

To create a new thread, we call the thread.spawn function and pass it a closure containing the code we want to run in the new thread:

import std.thread
import std.time.Duration

fn main() {
    thread.spawn {
        for i in 1..10 {
            println!("hi number \(i) from the spawned thread!")
            thread.sleep(Duration.fromMillis(1))
        }
    }

    for i in 1..5 {
        println!("hi number \(i) from the main thread!")
        thread.sleep(Duration.fromMillis(1))
    }
}

Note that when the main thread of an Oxide program completes, all spawned threads are shut down, whether or not they have finished running. The output from this program might be a little different every time, but you'll see something like this:

hi number 1 from the main thread!
hi number 1 from the spawned thread!
hi number 2 from the main thread!
hi number 2 from the spawned thread!
hi number 3 from the main thread!
hi number 3 from the spawned thread!
hi number 4 from the main thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!

The calls to thread.sleep force a thread to stop its execution for a short duration, allowing a different thread to run. The threads will probably take turns, but that isn't guaranteed: it depends on how your operating system schedules the threads.

In this run, the main thread printed first, even though the print statement from the spawned thread appears first in the code. And even though we told the spawned thread to print until i is 9, it only got to 5 before the main thread shut down.

Waiting for All Threads to Finish Using join Handles

The code above not only stops the spawned thread prematurely most of the time due to the main thread ending, but there's no guarantee that the spawned thread will get to run at all.

We can fix the problem of the spawned thread not running or ending prematurely by saving the return value of thread.spawn in a variable. The return type of thread.spawn is JoinHandle. A JoinHandle is an owned value that, when we call the join method on it, will wait for its thread to finish:

import std.thread
import std.time.Duration

fn main() {
    let handle = thread.spawn {
        for i in 1..10 {
            println!("hi number \(i) from the spawned thread!")
            thread.sleep(Duration.fromMillis(1))
        }
    }

    for i in 1..5 {
        println!("hi number \(i) from the main thread!")
        thread.sleep(Duration.fromMillis(1))
    }

    handle.join().unwrap()
}

Calling join on the handle blocks the thread currently running until the thread represented by the handle terminates. Blocking a thread means that thread is prevented from performing work or exiting. Because we've put the call to join after the main thread's for loop, running this should produce output similar to:

hi number 1 from the main thread!
hi number 1 from the spawned thread!
hi number 2 from the main thread!
hi number 2 from the spawned thread!
hi number 3 from the main thread!
hi number 3 from the spawned thread!
hi number 4 from the main thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
hi number 6 from the spawned thread!
hi number 7 from the spawned thread!
hi number 8 from the spawned thread!
hi number 9 from the spawned thread!

The two threads continue alternating, but the main thread waits because of the call to handle.join() and does not end until the spawned thread is finished.

But let's see what happens when we instead move handle.join() before the for loop in main:

import std.thread
import std.time.Duration

fn main() {
    let handle = thread.spawn {
        for i in 1..10 {
            println!("hi number \(i) from the spawned thread!")
            thread.sleep(Duration.fromMillis(1))
        }
    }

    handle.join().unwrap()

    for i in 1..5 {
        println!("hi number \(i) from the main thread!")
        thread.sleep(Duration.fromMillis(1))
    }
}

The main thread will wait for the spawned thread to finish and then run its for loop, so the output won't be interleaved anymore:

hi number 1 from the spawned thread!
hi number 2 from the spawned thread!
hi number 3 from the spawned thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
hi number 6 from the spawned thread!
hi number 7 from the spawned thread!
hi number 8 from the spawned thread!
hi number 9 from the spawned thread!
hi number 1 from the main thread!
hi number 2 from the main thread!
hi number 3 from the main thread!
hi number 4 from the main thread!

Small details, such as where join is called, can affect whether or not your threads run at the same time.

Using move Closures with Threads

We'll often use the move keyword with closures passed to thread.spawn because the closure will then take ownership of the values it uses from the environment, transferring ownership of those values from one thread to another.

Here's an example of attempting to use a vector created in the main thread inside a spawned thread:

import std.thread

fn main() {
    let v = vec![1, 2, 3]

    let handle = thread.spawn {
        println!("Here's a vector: \(v:?)")
    }

    handle.join().unwrap()
}

The closure uses v, so it will capture v and make it part of the closure's environment. Because thread.spawn runs this closure in a new thread, we should be able to access v inside that new thread. But when we compile this example, we get the following error:

error[E0373]: closure may outlive the current function, but it borrows `v`,
which is owned by the current function
 --> src/main.ox:6:23
  |
6 |     let handle = thread.spawn {
  |                               ^ may outlive borrowed value `v`
7 |         println!("Here's a vector: \(v:?)")
  |                                      - `v` is borrowed here
  |
note: function requires argument type to outlive `'static`
help: to force the closure to take ownership of `v` (and any other referenced
variables), use the `move` keyword
  |
6 |     let handle = thread.spawn move {
  |                               ++++

Oxide infers how to capture v, and because println! only needs a reference to v, the closure tries to borrow v. However, there's a problem: Oxide can't tell how long the spawned thread will run, so it doesn't know if the reference to v will always be valid.

Consider this potentially problematic scenario:

import std.thread

fn main() {
    let v = vec![1, 2, 3]

    let handle = thread.spawn {
        println!("Here's a vector: \(v:?)")
    }

    drop(v)  // Oh no!

    handle.join().unwrap()
}

If Oxide allowed this code to run, there's a possibility the spawned thread would be immediately put in the background without running at all. The spawned thread has a reference to v inside, but the main thread immediately drops v. Then, when the spawned thread starts to execute, v is no longer valid, so a reference to it is invalid. Dangerous!

To fix the compile error, we use the move keyword:

import std.thread

fn main() {
    let v = vec![1, 2, 3]

    let handle = thread.spawn move {
        println!("Here's a vector: \(v:?)")
    }

    handle.join().unwrap()
}

By adding the move keyword before the closure, we force the closure to take ownership of the values it's using rather than borrowing. This modification compiles and runs as we intend.

What would happen if we tried to use v in the main thread after the move closure? Let's try:

import std.thread

fn main() {
    let v = vec![1, 2, 3]

    let handle = thread.spawn move {
        println!("Here's a vector: \(v:?)")
    }

    println!("Main thread: \(v:?)")  // Error!

    handle.join().unwrap()
}

The compiler gives us this error:

error[E0382]: borrow of moved value: `v`
  --> src/main.ox:10:31
   |
4  |     let v = vec![1, 2, 3]
   |         - move occurs because `v` has type `Vec<Int>`, which does not
   |           implement the `Copy` trait
5  |
6  |     let handle = thread.spawn move {
   |                               ---- value moved into closure here
7  |         println!("Here's a vector: \(v:?)")
   |                                      - variable moved due to use in closure
...
10 |     println!("Main thread: \(v:?)")
   |                               ^ value borrowed here after move

The ownership rules have saved us again! We got an error because Oxide is being conservative and only borrowing v for the thread, which meant the main thread could theoretically invalidate the spawned thread's reference. By telling Oxide to move ownership of v to the spawned thread, we guarantee that the main thread won't use v anymore. The compiler enforces this guarantee.

Rust Comparison

The thread API is nearly identical between Oxide and Rust. The main differences are syntactic:

ConceptRustOxide
Importuse std::thread;import std.thread
Spawnthread::spawn(|| { ... })thread.spawn { ... }
Move closuremove || { ... }move { ... }
Path separator::.

The semantics, including how ownership transfers to threads and how join handles work, are exactly the same. Oxide's trailing closure syntax makes thread spawning more readable, while maintaining all the safety guarantees of Rust's type system.

Summary

  • Use thread.spawn with a closure to create new threads
  • Threads execute concurrently and may interleave in unpredictable ways
  • Use JoinHandle.join() to wait for a thread to finish
  • Use move closures to transfer ownership of data into threads
  • The borrow checker prevents data races at compile time