anhedonic

dynamic soap bubbles

Feb 22, 2024

why rust won’t let you borrow twice

Berend De Schouwer

What Question Am I Answering?

A question came up in a coding class, about why in Rust there’s a borrow, and there’s a an error when borrowing.

You may have seen it like (copied from the rust book):

error[E0499]: cannot borrow `s` as mutable more than once at a time
 --> src/main.rs:5:14
  |
4 |     let r1 = &mut s;
  |              ------ first mutable borrow occurs here
5 |     let r2 = &mut s;
  |              ^^^^^^ second mutable borrow occurs here
6 |
7 |     println!("{}, {}", r1, r2);
  |                        -- first borrow later used here

For more information about this error, try `rustc --explain E0499`.
error: could not compile `ownership` due to previous error

The Rust book does explain what borrow is, and the theory behind why it’s allowed and not allowed.

I’m showing the risks practical example.

How Am I Answering It?

With as little computer theory as possible.

  • No assembler
  • No diagrams
  • No multiple languages
  • short, simple examples
  • No theory until after the bug is shown

Caveat

The answer is in C. It’s in C because Rust won’t let me break borrow, not even in an unsafe {} block.

It’s all in plain C, though. No assembler, and no C++. As straightforward C as I can make it, specifically to allow for plain examples. As few as possible shortcuts are taken.

I’m not trying to write a C-programmer’s C code. I’m trying to write an example that shows the problem, for non-C programmers.

It should not be difficult to follow coming from Rust.

Inspiration

The inspiration comes from the C max() function, which is actually a macro.

That is great for explaining the differences between functions and macros, and it’s great for explaining pass-by-code instead of value or reference.

It’s also great for explaining surprises.

First Examples

First, we give example code for pass-by-value and pass-by-reference. Pass-by-reference is also called borrow in Rust. The examples are simple, and we do not yet discuss the difference.

For now, it’s simply about C syntax.

#include <stdio.h>

int double_by_value(int x) {
    x = x * 2;
    return x;
}

int double_by_reference(int *x) {
    *x = *x * 2;
    return *x;
}

int main() {
    int x;
    x = 5;
    printf("double_by_value(x) = %d\n", double_by_value(x));
    /* double_by_value(x) = 10 */
    x = 5;
    printf("double_by_reference(x) = %d\n", double_by_reference(&x));
    /* double_by_reference(x) = 10 */
    return 0;
}

The code is almost identical. You can see the syntax difference, but no functional difference yet.

The logic is the same, the input is the same, and the output is the same.

Extra variables

Let’s make it a tiny bit more challenging, and add a second variable.

#include <stdio.h>

int add_by_value(int x, int y) {
    x = x + y;
    return x;
}

int add_by_reference(int *x, int *y) {
    *x = *x + *y;
    return *x;
}

int main() {
    int x, y;

    x = y = 5;
    printf("add_by_value(x, y) = %d\n", add_by_value(x, y));
    /* add_by_value(x, y) = 10 */
    x = y = 5;
    printf("add_by_reference(x, y) = %d\n", add_by_reference(&x, &y));
    /* add_by_reference(x, y) = 10 */
    return 0;
}

The logic is the same, the input is the same, and the output is the same.

Put it together

Add the two and we get…

#include <stdio.h>

/* Previous example code here */

int add_and_double_by_value(int x, int y) {
    x = double_by_value(x);
    y = double_by_value(y);
    x = x + y;
    return x;
}

int add_and_double_by_reference(int *x, int *y) {
    *x = double_by_reference(x);
    *y = double_by_reference(y);
    *x = *x + *y;
    return *x;
}

int main() {
    int x, y;

    x = y = 5;
    printf("add_and_double_by_value(x, y) = %d\n", add_and_double_by_value(x, y));
    /* add_and_double_by_value(x, y) = 20 */
    x = y = 5;
    printf("add_and_double_by_reference(x, y) = %d\n", add_and_double_by_reference(&x, &y));
    /* add_and_double_by_reference(x, y) = 20 */
    return 0;
}

No surprised yet. Both examples print the same, correct answer.

The logic is the same, the input is the same, and the output is the same.

Surprise

Now let’s make it simpler…

#include <stdio.h>

/* Previous example code here */

int main() {
    int x;
    x = 5;
    printf("add_and_double_by_value(x, x) = %d\n", add_and_double_by_value(x, x));
    /* add_and_double_by_value(x, x) = 20 */
    x = 5;
    printf("add_and_double_by_reference(x, x) = %d\n", add_and_double_by_reference(&x, &x));
    /* add_and_double_by_reference(x, x) = 40 */
    return 0;
}

The logic is the same, the input is the same, but the output is different.

Why is it 40?

Double borrow

So what happened? Why did removing a variable, y, result in the “wrong” answer?

Pass by value copies the value, and passes the value, not the variable. The original is never modified.

Pass by reference (or borrow), passes a reference, not a copy. The original is modified.

Look at

int add_and_double_by_reference(int *x, int *y) {
    *x = double_by_reference(x);
    *y = double_by_reference(y);
    *x = *x + *y;
    return *x;
}

When x is doubled, at *x = double_by_reference(x);, and *x is also *y, y is also doubled. X is now 10, as expected, but y is also 10.

Then y is doubled. And since *y is _also *x, x is doubled again. Now both variables are 20.

Tada!

This is (one of the reasons) why Rust won’t let you borrow the same variable twice.

What do you do instead?

Borrow once, or .clone() /* copy */.

Bonus time

So why have pass by reference or borrow at all?

  1. It’s faster, when the data is large.
  2. It’s faster for streamed data.
  3. It’s not possible in assembler pass complicated variables by value. Manual copies must be made, and the language you use might not implement implicit copies.

Extra Points

For extra points, do the same with threads.

Dec 27, 2022

rust async on single vs. multi-core machines

Berend De Schouwer

Who Is This Post For?

Rust programmers tracking down strange behaviours that doesn’t always show up in debuggers or tracers

What Was Rust Used For?

I wanted to connect browsers to terminal programs. Think running a terminal in a browser like hterm or ajaxterm.

One side of the websocket runs a program that may at any time send data. In between there are pauses that stretch from milliseconds to hours.

The other side is the same.

This is a perfect fit for asynchronous programming. It’s also a candidate for memory leaks over time.

Both problems were tackled using Rust.

Problem Experienced

Sometimes the Rust program would stop. The program would still run in the background, running epoll(7), indicating that an async wait was running.

The program would not crash, and would not run away on the CPU.

The last statement executed:

    debug!("This runs");
    Err("This does not run!")
}

Which is strange, to say the least.

This would only happen on single-core machines. On machines with two or more cores, it would run fine.

This would happen on multiple targets architectures, multiple OS-es.

It would go into an infinite look on Err(“…”) on single core machines.

More About the Program

The program runs two parallel asynchronous threads, and waits for either thread to stop.

It does that because the network side or the terminal side could stop and close the connection. So it basically runs:

task::spawn(tty_to_websocket());
task::spawn(websocket_ty_tty());
try_join!(tty_to_websocket, websocket_to_tty);

try_join! should wait for either task to stop with an error.

I’ve setup both tasks to throw an error even on successful completion. This is because join! might wait for both to stop, and it’s possible for either side to stop without the other noticing.

try_join! never completes, because Err() never completes, which is strange.

What Does Err() Do?

Err() ends the function. In Rust that also runs the destructors, like an object-oriented program might. Lets say you have a function like:

fn error() -> Result<> {
    let number: int = 2;
    Err("error");
}

When Err() runs, Rust de-allocates number. The memory is returned.

For an int this is simple, but it can be more complicated.

What Is A TTY?

A TTY, for a program, is a file descriptor on a character device. It’s a bi-directional stream of data, typically keyboard in and text out.

The C API to use one uses a file descriptor. One way to get such a file descriptor is forkpty(3), which has a Rust crate.

Most Rust code want a std::fs::File, not a raw file descriptor, so it needs to be converted:

unsafe { let rust_fd = std::os::from_raw_fd(c_fd); }

The first bell is unsafe {}. The code is indeed unsafe because we’re working with a raw file descriptor.

The second bell is in the documentation for from_raw_fd. The documentation is in bold in the original:

This function consumes ownership of the specified file
descriptor. The returned object will take responsibility
for closing it when the object goes out of scope.

Where Is The Bug?

The bug happens because both tasks need a std::fs::File. One to read the TTY, and one to write to it.

Both tasks consume ownership, and both tasks take responsibility for closing it.

Both destroy the rust_fd and hence close the c_fd, when the tasks run Err().

Expected Bug

The expected bug is that the second task to close won’t be able to close. The second task should get EBADF (bad file descriptor).

However, this is not the bug experienced.

Experienced Bug

The experienced bug is that on single core machines the program just stops, and keeps calling epoll(), which is something Rust does at a low level for async functions.

This makes it harder to debug, since there is no panic!, no crash.

Real Bug

The real bug is that on machines with two or more cores, the program continues fine. It should not continue.

On two or more cores, it should behave the same as on single core machines.

Solution

The solution is to skip running the destructor.

When it’s just a file descriptor, it can be enough to run

mem::forget(rust_fd);

Now we have stopped the crash. We need to take responsibilty and run

try_join!(tty_to_websocket, websocket_to_tty);
close(c_fd);

to prevent leaking file descriptors.

If you wrap the fd in a buffer, don’t forget to de-allocate the buffer by running:

let buffer = reader.buffer();
drop(buffer);

to prevent a memory leak.

posted at 09:00  ·   ·  rust  async