The Rust feature I miss most
Writing high-level C# at my day job, it’s surprisingly not sum types (although I do miss them). Instead, it’s the borrow checker!?
Fearful concurrency
In our backend service, we use a homegrown persistence layer optimized for our specific data access patterns. Imagine a hypothetical domain like virtual video meetings where:
- Meeting state only needs to exist for the duration of the call and can fit on one machine.
- Client requests mostly update self-participant state, making it unlikely that concurrent requests will logically conflict with another.
In this setting, it might make sense to assign all requests for a particular meeting to one machine, store the data in memory, and rely on optimistic conflict detection with retries to handle concurrency. Typical service code looks something like:
[HttpPost("{meetingId}/admitUser/{targetUserId}")]
public IActionResult AdmitUser(Guid meetingId, Guid targetUserId)
{
var meeting = new Meeting(meetingId);
// The data lives in memory, Load just does a deep copy.
meeting.Load();
var updated = meeting.ReloadAndRetryOnConflict(() =>
{
var participant = meeting.GetParticipant(targetUserId);
if (participant?.InLobby != true)
{
return false;
}
participant.SetInLobby(false);
// Under the hood, Save will acquire the mutex for this meeting,
// merge its dirty properties into the master object, and then
// call Load before returning.
// If during the merge it finds the participant was updated via
// another Save since its last Load, it'll throw a conflict
// exception instead.
// ReloadAndRetryOnConflict will then call Load to get clean
// up-to-date state before rerunning the closure.
meeting.Save();
return true;
});
if (updated)
{
RunInBackground(async () =>
{
await SendChangedEvent(meeting, targetUserId);
});
}
return Accepted();
}This approach has some nice properties:
- The persistence overhead is mainly just deep copying objects in memory. Requests get processed largely without synchronization, only entering the critical section to merge completed changes.
- Even when there are conflicts (e.g. multiple clients send concurrent requests to admit the same user), we can logically guarantee:
- Exactly one request will invoke
SendChangedEvent. - Every other request will observe at most one conflict exception before retrying and no-oping.
- Exactly one request will invoke
The not-so-nice part is the myriad of opportunities for innocuous-looking bugs to sneak in. A (simplified) prevalent pattern in the codebase looks like:
public async Task Process(Meeting meeting)
{
RunInBackground(async () =>
{
var result = await DoWork2();
meeting.ReloadAndRetryOnConflict(() =>
{
// ... Update property Y based on result and save.
});
});
var result = await DoWork1();
meeting.ReloadAndRetryOnConflict(() =>
{
// ... Update property X based on result and save.
});
}This code is incorrect even if X and Y are distinct, unrelated properties. Consider the interleaving:
- The main thread updates property X.
- The background thread updates property Y and calls Save on the shared object reference. It receives a conflict due to another concurrent request and so calls Load before retrying.
- The main thread calls Save successfully (but accomplishes nothing as all dirty properties were blown away on Load).
- The background thread retries the closure, updating property Y and calling Save successfully.
The net result is that the write to X is lost forever. Trying to piece together what happened through logs after the fact can be a total nightmare.
The beauty of the Rust equivalent is that it does not compile:
error[E0382]: borrow of moved value: `meeting`
--> src/lib.rs:10:5
|
1 | fn process(mut meeting: Meeting) {
| ----------- move occurs because `meeting` has type `Meeting`, which does not implement the `Copy` trait
2 | std::thread::spawn(move || {
| ------- value moved into closure here
3 | let result = do_work2();
4 | meeting.reload_and_retry_on_conflict(|meeting| {
| ------- variable moved due to use in closure
...
10 | meeting.reload_and_retry_on_conflict(|meeting| {
| ^^^^^^^ value borrowed here after moveThe proper fix is to load an exclusive object reference for the background thread (reordering the main and background operations might also work for simple cases).
RunInBackground(async () =>
{
// NEW:
var newMeeting = new Meeting(meeting.Id);
newMeeting.Load();
var result = await DoWork2();
newMeeting.ReloadAndRetryOnConflict(() =>
{
// ... Update property Y based on result and save.
});
});While Rust automatically enforces this via the compiler, we’re left with options like stylistic guidelines or reworking RunInBackground to load a new object behind the scenes and pass it as a closure argument. The issue with the latter is that not every RunInBackground call needs to pay this deep clone cost. For example, in the first AdmitUser snippet, no one else can access meeting again.
In the Rust version, deep cloning can be skipped for precisely that reason. This compiles:
{
// ...
if updated {
std::thread::spawn(move || {
send_changed_event(&mut meeting, &target_user_id);
});
}
accepted()
}But at the same time, if we ever update the code, the compiler will recheck those assumptions for us.
Blazingly fragile
Here’s another common pattern:
public void LowerHands(Meeting meeting)
{
var participants = meeting.GetAllParticipants();
foreach (var participant in participants)
{
participant.SetIsHandRaised(false);
this.SharedHelper(meeting, participant.Id);
}
meeting.Save();
}Suppose one day, a developer introduces a Save call inside the SharedHelper implementation. Now, for two participants:
- In the first loop iteration, user A is updated and has their changes saved. The successful Save call in
SharedHelpercausesmeeting’s state to be reloaded andparticipantsto now be a stale reference to the old state. - Depending on the exact circumstance and persistence layer implementation, the second Save may result in lost changes, overwritten data, or something in between. Imagine if the first Save’s reload revealed that user B has since been removed by another request.
Once again, Rust surfaces this as an immediate compile error rather than through some impossible-to-debug production incident later.
error[E0499]: cannot borrow `meeting` as mutable more than once at a time
--> src/lib.rs:6:23
|
2 | let participants = meeting.get_all_participants();
| ------- first mutable borrow occurs here
3 | for participant in participants {
| ------------ first borrow later used here
...
6 | shared_helper(&mut meeting, &participant.id);
| ^^^^^^^^^^^^ second mutable borrow occurs hereIn all these examples, the underlying issue is accidental aliased mutation. Even in a high-level garbage-collected language, a borrow checker can be invaluable.
withoutboatsAs I said once, pure functional programming is an ingenious trick to show you can code without mutation, but Rust is an even cleverer trick to show you can just have mutation.