Is Rust a good fit for business apps?

While you may hear a lot of harsh words about Rust is this rant, that doesn't have to mean it's a bad language. Rephrasing the classic: there are two types of programming languages: ones that people complain about and ones that nobody uses. I've started my journey with Rust in 2018 and I've been working in it full time since 2021.

I love Rust a lot for many things: good std lib abstractions, ergonomics (to some extend), the best build toolchain in the world (I've tried many things, but cargo is easily number one across programming languages landscape). But mostly I love how it brought sanity into systems programming and gave a viable alternative to this hollow abomination called C++ (and CMAKE).

But what do I mean by term business apps? Nowadays, its all sorts of services targeting various kinds of user/asset management, be it a bank portal, online shop or any other sort of ERP systems. This also covers ETL to huge extend, as they bring your focus outside of main concerns that Rust shines in.

These systems usually have similar shell: a web service providing some API, a database to manage system information and all sorts of other service connectors.

These systems are characteristic because their main complexity comes from the domain: which is not hardware/software related but it's more about modelling complexities of human interactions in code. Quite often the most performance-sensitive parts related to I/O access (databases, HTTP communication) and serialization and solved by tuning access to the other services we use, not the algorithms we write ourselves.

These systems where famously written in many different languages, from Python/Ruby/JavaScript/PHP to Java/C#/Go. Question is: are business apps a good use case for Rust?

Spoilers: in my opinion, No. Now lets explain why.

Standard library

One of the nice things about Rust is that its abstractions defined in standard library feel right in size and scope. On the other side, std lib itself is woefully lacking: no RNG, cryptography or serialization. Even some things that should have been language features since day one - like async traits and yield generators - are supplied as 3rd party macros.

On the other side Rust package ecosystem is enormous. You have everything, from universal abstraction over file system with dozen of services supported, down to cross-platform Bluetooth driver that you can use to (literally) connect to your butt plug.

While languages such as Go enable you to write pretty much entire HTTP service from standard lib alone, this bazaar-style package management comes with the burden: whenever you need to solve any mundane problem, you land in a space where everything has at least 7 different crates available, but half of them are actually a toy projects and most of them were not maintained for the last 5 years. And don't get me started about audits to check if one of 600 dependencies of your hello world app won't be used for supply chain attacks.

It takes time and attention to to sift the wheat from the chaff. Attention that is limited and could be put to better use elsewhere.

And while many of these concerns have sense in systems programming, since they cover very different environments with very slim-tailored constraints - like WASM in the browser or embedded devices where even Rust's minimal standard lib is too much - they don't matter so much in a context of business apps, where solid defaults for common problems are desired: which is one the reasons for Go and .NET popularity in this domain.

Not abstract enough

One of the fantastic parts of Rust is that it manged to - mostly - live up to credo of zero-cost abstractions: situation where the performance of your high abstracted code (i.e. iterator ops or futures) is basically the same as their hand-rolled equivalent.

The problem is that Rust comes with some new concepts like lifetimes and mutability modifiers, that cannot be properly abstracted to the same degree as regular generics.

If you played with Rust you probably already seen those different kinds of iterators for mutable/immutable references, which basically have the same implementation but require twice the boilerplate code. The reason why is that mutability is not a generic property in Rust and cannot be abstracted over.

Some languages like Pony offer an ability to control read/write access to fields and variables, but does it in a way that enables safe "casting" between them. PS: I highly recommend learning Pony for its reference capabilities concept alone, which initially may seem to be more complex than Rusts mutability and borrow-checker but in practice is much more robust and avoids many pitfalls that Rust has, especially in multi-threaded programming.

Dynamic trait references

Since this rant already came to the topic of abstractions, let's talk about dyn Trait. First, let me praise Rust decision about explicitly showing references responsible for doing a virtual table dispatch in code.

However Rust also decided to turn Box<dyn Trait>/Arc<dyn Trait> into fat pointers (similar to Go, and opposite to Java/.NET).

Short explanation: unlike Box<T> which is basically a memory pointer, a memory representation for Box<dyn T> is two pointers - one for type's virtual table, and one for heap address where the corresponding object lives. This comes with few consequences:

  • If you're working with C foreign function interface, there's no right C primitive to support you. You need to rollout something of your own, that most likely won't be compatible with existing solutions. Bizarre design decision given how important native interop is for Rust.
  • If you want to introduce a lock-free mutability via Compare-And-Swap API (like the one that arc-swap offers) and use dynamics at the same time... well, get fucked. You'll need extra layer of indirection, since this API is only available for pointer sized things.
  • Some of the Rust APIs restrict you to work over Sized data - a types which size can be determined at compile time - which unfortunately puts a limitations on your generic params, i.e. if you ever want to use them in Box<T> context (since box pointer will have different size depending on what a T is).

Rust provides a workaround in form of dedicated crates that offer thin dynamic pointers as well, but since they are not part of standard lib, it's unlikely that you'll be able to use them across different libraries in the ecosystem without extra work.

Borrow checker: early adopter syndrome

One of the biggest value proposals of Rust is borrow checker. If you ever thought about reasons to learn Rust: a borrow-checker and ownership model is the one. It changes the way how you think about object graphs.

Rust is probably the first language that adopted borrow-checker as a regular tool in the non-esoteric language. However it comes with some drawbacks: at its current stage the borrow-checker is still not very advanced and extremely conservative, requiring programmer to do a lot of defensive programming and workarounds in order to make it happy. And most likely it will never be improved beyond minor points, as this would require a breaking change.

In short: you can imagine borrow-checker as a recursive read/write lock enforced on all fields and variables at compiler level - at any time you can have multiple read-only references to the same object or one read-write reference, but never a mix of two. Additionally in order to have a reference of given type to a field in an object graph, you need to have the same (immutable/mutable) or stronger (mutable) reference to its parent.

If we think in category of locks, you can imagine a deadlock problem: when A needs to wait for B, and B needs to wait for A to acquire their corresponding locks. In Rust borrow-checker, such situations are compiler errors. The same logic is used by Rust to operate on actual locks, but don't worry: it doesn't mean that Rust is deadlock free language.

What it means however, is that there's no easy way to represent cyclic data structures in Rust (here's description of famous double-linked list problem), since - unlike pretty much any other language - it explicitly disallows you to have two mutable/immutable references to the same variable (even in the same thread).

And speaking of cyclic data structures: you can actually sometimes implement them in straight forward manner with Rc<RefCell<T>>/Arc<Mutex<T>>, but the problem is that:

  1. RefCells can easily blow up since they work the same way like borrow-checker, but during runtime, while Mutex can deadlock at runtime. Neither of them is "zero cost".
  2. You need to keep track of references with strong and weak pointers, which is usually not an issue unless your object graph needs to be a bit more complicated for some reason. If not, you'll get a memory leak. One of the Rust promises was to reduce these, but it only works in comparison to traditional "systems" languages like C/C++. This comparison falls apart against managed languages.

I get why it's there, but forcing it blindly and everywhere as a default behaviour is fucking bullshit: which apparently is acknowledged by the authors themselves, since the common way of getting immutable/mutable reference from an array is to split it into two separate references using method that operates using unsafe pointers under the hood. Shutout to all haters saying that unsafe Rust is not idiomatic: it's not only idiomatic, it's necessary.

Borrow checker and encapsulation

Another thing about borrow checker is that it has very shallow understanding of your code. It also explicitly makes a conservative assumption that if you call method over some reference, this method will try to access ALL fields of that references, forcing any other field accessed outside of it to be invalidated.

Let's check this out on a following example:

struct X {
    commit_offset: usize,
    entries: HashMap<u32, Vec<Entry>>,
    changed: HashMap<u32, Vec<usize>>,
}

impl X {
    fn change_uncommitted<F>(&mut self, client: &u32, f: F)
        where F: Fn(&mut Entry) -> bool 
    {
        let mut i = self.commit_offset;
        if let Some(entries) = self.entries.get_mut(client) {
            // get iterator over uncommitted entries for given client
            for e in entries.as_mut_slice()[self.commit_offset..].iter_mut() {
                if f(e) {
                    let changed = self.changed.entry(*client).or_default();
                    changed.push(i);
                }
                i += 1;
            }   
        }
    }
}

Now let's try encapsulate it a little to make it more readable - nothing much, just encapsulate our cryptic for iterator statement to give it some context:

impl X {
	/// get iterator over uncommitted entries for given client
    fn get_uncommitted(&mut self, client: &u32) -> Option<&mut [Entry]> {
        let e = self.entries.get_mut(client)?;
        Some(&mut e.as_mut_slice()[self.commit_offset..])
    }
    
    fn change_uncommitted<F>(&mut self, client: &u32, f: F)
        where F: Fn(&mut Entry) -> bool 
    {
        let mut i = self.commit_offset;
        if let Some(entries) = self.get_uncommitted(client) {
            for e in entries.iter_mut() {
                if f(e) {
	                /// compilation failure: get_committed already borrowed
	                /// `&mut self` in a scope of if let, so we cannot access
	                /// `self.changed`
                    let changed = self.changed.entry(*client).or_default();
                    changed.push(i);
                }
                i += 1;
            }   
        }
    }
}

The second implementation will fail. Not because it's wrong, not because we broke something (in fact these two implementations are identical), but because it makes borrow checker sad.

This is in fact recurring theme: when working in Rust, you'll often find yourself in situation when you need to split your types or methods in specific way, just because borrow checker says so. It's mandatory, even when it adds no value (or straight up removes it) to your project.

Performance ceiling vs time to performance

One of the common misconceptions about Rust is that apps written in Rust are fast because they are written in Rust. This is true to some extent if we compare them against dynamic languages like Python, Ruby or JavaScript, but it falls short when we start comparison with services written in i.e.. Go, Java or .NET.

This is may be due to oversimplified view on the performance characteristics of real-world apps:

  1. Winning hyper optimization wars in micro-benchmarks rarely translate to visible results in business apps, where our own code is usually ~10% of the overall executed: rest is databases, web stacks, serializers etc.
  2. For those apps most of the optimizations are either done by proper database and network usage, system architecture and right algorithm pick. Language wrestling matters a lot less, at least when we talk about languages in the same performance "weight class".

Moreover, picking Rust may cause a let-down in expectations about performance - I've seen people writing their apps in both Rust and i.e.. C# and noticing that their C# apps were actually faster. This again comes from another issue: when you first try, you probably will write your Rust app just well enough to make it compile, do actual task and avoid glaring performance issues. Most likely you'll stick to its defaults and - in business setting - this will be the last time when you try to optimise that piece of code.

This boils down to the difference between:

  • Performance ceiling which means how possibly fast program written in a given language can be. This is usually low for dynamic languages (since they abstract a lot) but it's very high for Rust. However some platforms, i.e.. .NET or Swift where we can choose to work closer to the metal if we want to, this difference is not that significant.
  • Time to performance which basically means: "how long it takes to solve a problem with acceptable performance". And personally: Rust falls behind many managed languages on that metric, mainly because of things like borrow checker, and multi-threading issues etc. which I cover later.

Your business app will probably be working over things like strings, byte buffers and object graphs to carry over business data between DB and web framework. This will mean that it will move and copy a lot of data around: something that default Rust primitives are not particularly great at ie. String::clone in Rust uses deep copy (where in managed languages it's just pointer copy), while String itself is just wrapper around capacity-bound Vec<u8> which means they may also be bigger than they need to be.

Copying "references" can be much more expensive than in languages with managed memory because of ref-based garbage collector: i.e. for Vec<Arc<T>> means not only memcpy over vector heap space but also following increment of ref counters in every of the nested Arc pointers (including loading each of them from heap into register and coordinating new counter values between CPU caches).

And since we're at Arc/Rc or even Box: once you need to deal with graphs of objects or moving data in between coroutines or threads, you'll see yourself using them quite a lot. The problem is that this technique of allocating is nowhere near as fast as bump pointer allocators that managed languages use. The actual win here is when we need to release memory: which in Rust doesn't introduce GC pauses. However modern runtimes i.e. Go or Java Z collector, can provide a constrained GC pauses that let us keep the latency in check to avoid pathological cases (which is fine enough for most business apps, except maybe HFT space). Moreover they can offset memory release to background threads, which is not the case in Rust and for big object graphs can also affect latency.

And while technically Rust memory footprint would be expected to be lower, in practice that doesn't have to be the case (because of all the deep copying of heap objects and the fact that many of Rust pointers are pinned, causing fragmentation).

Rust is NOT good for multi-threaded apps

Some developers like to claim that - thanks to its strict borrow checker - Rust makes multi-threaded programming safe and reliable. This statement could probably hold in comparison against languages like C/C++, but once again it easily falls apart once you compare it against any of the contenders we described already.

.await pain

First problem is: building multi-threaded apps in Rust is simply painful. 2/3 of this pain comes from the fact that if you ever will have to do it, you'll most probably be put to work with async/await and tokio runtime.

Once you need to work with Rust futures and async code, you'll get exposed to whole new world of dosing micro-complexities into your brain, i.e.:

  • How you cannot just access objects and their fields, but have to work with pinning and Unpin.
  • How to build async iterators: because while async_stream is there, from time to time you'll have to roll something by hand: and it's much harder process than any other language supporting this feature that I know of.
  • Differences between regular threads/locks, and their asynchronous equivalents.
  • Why the hell do you need async_trait and why it's even configurable.
  • How Send and Sync makes each of the issues above exponentially harder than they already are.
  • And how the fact that you have pluggable runtimes - and sometimes need to use more than one in your app, i.e.. tokio+rayon - makes things even more interesting.

I think that this blog post is a good critique of current state of async Rust.

If you're going to pass your objects across threads, Rust forces some constrains over the code you're writing - such as Send + 'static limitations - even if that code is executed in only a single execution scope at the time. The problem is that in tokio - a dominant runtime used in Rust ecosystem - a primary way of parallelizing work is via spawn method, that uses work-stealing scheduler: which moves the suspended executions from busy to idle threads as it seems fit. This usually requires ensuring that most of your async code base is Send + 'static compatible.

What's nice about Send and Sync traits is that they are inferred from bodies of async methods that you implement. What's not nice is that they are not immediately visible, so you may accidentally break API guarantees by changing few lines somewhere down in a method call stack without even noticing, resulting in your methods no longer being forkable by tokio::spawn.

Locks. Locks everywhere.

In practice all of the Send + 'static' constraints mentioned above mean that all kinds of shared data now needs to be wrapped with Arc<Mutex<T>>/ Arc<RwLock<T>>. But which mutexes and locks are we talking about?:

  • Of course since std::sync::RwLock is basically a wrapper around OS primitives, it makes it very heavy. Most notably it doesn't cover async/await API, so it's going to block the threads from tokio thread pool, which is damaging for a server performance.
  • parking_lot::RwLocks are much more lightweight - since they use optimistic locking with atomic counters. They still don't offer async/await API though, potentially blocking thread pool in the process.
  • futures_locks::RwLock which sounds like a good idea if you aim for have runtime-agnostic async locks, until you look into the implementation and realize that it's just bait and the whole thing is using regular locks inside.
  • Tokio has its own RwLock which offers async/await API but it comes with some caveats, like:
    • If you use blocking lock methods inside of a context in which tokio runtime is available, it will straight up panic, crashing your app. And sometimes you just may have to call it in a context where runtime is available but your code cannot be async, calling for another layer of workarounds.
    • It doesn't offer reentrancy or upgradeable locks (promoting read locks into write ones).
  • Finally async_lock::RwLock which offers async/await API, optimal implementation, lock upgrades and doesn't crash your server because the author didn't like the way you're using his library. PS: don't worry I don't like it either, but I'm here to do what I can with what I have in hands, not to write poetry.

So once you finally get your PhD from lock algorithms in Rust, you finally are at the level where you can do the job as efficiently as Go dev after learning the whole language in 2 hours. And god forbid ask yourself a question: why do I need to use locks if this code is never accessed concurrently?

The best part is that - unlike ie. Pony - Rust compiler doesn't guarantee absence of dead locks in your code. Even better: since locks are so wide spread, they are even more likely to occur. It doesn't even have to happen because you're using them wrong, just because you didn't know that the code that you're calling is using them somewhere below (sending changes over tokio::watch channel itself is a great example of that).

Actors

One of the stunning issues I've found in Rust is that, given how well borrow-checker ownership matches the actor model, the actor libraries in Rust are lacking. I'm not talking about all of them, since I didn't have a time nor energy to check out every out of 47 actor libraries listed for a good start, but that number strongly suggests syndrome where after first few every new attempt was trying to solve some issues with existing implementation, creating new ones in the process. If you're using them for your business app, most likely it will be one of the 3 most popular, and most likely it will be actix, because you've been baited by its popularity and pretty mature web framework attached to it.

The problem with Actix is that its core has been defined before the era of async/await Rust. This means that it doesn't natively support async actor methods - and if you need a server app doing any kind of I/O, you WILL have to use async Rust eventually. Eventually some support for async had been added, but now you need to educate yourself which of the 4 different responses that suport futures should be used in which situation. AFAIK none of these support using &mut self actor in async method (and we don't count actix_async_handler since it has list of limitations longer than the actual documentation). It's about as handy as using a knife with 4 different blades but no handle.

In practice, the most popular pattern I've seen was simply using a tokio channel combined with tokio::spawn, which essentially is a retarded cusin of actor: more verbose and missing all of its benefits like structured message handling, lifecycle management, state encapsulation, parent-children hierarchies etc.

Panics

While Rust errors are pretty safe - thanks to being a part of method declaration - they are not alone: panics are still there. And unlike errors, you'll never be 100% sure that you're avoided all of them. Technically you could use some way to notify about their existence i.e.. by using unsafe brackets or something, but in practice it's hard to be sure.

One of the issues are ever-present .unwrap calls. Technically they are meant to be used with caution, but if you're glossing over the code base, the only difference between hash_map.get().unwrap() (which can happen often) and path_buf.to_str().unwrap() (which most likely will never happen in your app) is your experience.

Other issues include:

  • Panics on index accesses.
  • Panics of double borrow/borrow_mut from RefCells - which are perfectly fine in many languages but in Rust will crash your entire app because borrow checker doesn't like second guesses.
  • Panics with stack overflows because the state machines generated by your async methods may be a "bit" bigger than expected: but at least no code has been heap-alloc'ed while solving this problem.

What's important to notice here: we're talking about panics that will crash your server, affecting not only the current request handle but everyone using your services. That's the major difference between Rust failures and exceptions used in managed languages. And sure you could say that these can be fixed with proper programmer discipline, but isn't the Rust promise of compiler taking care of dangerous coding mistakes the reason why we put and effort to learn it and deal with all of the borrow-checker bullshit along the way?

Death by a thousand cuts

Individually the issues above can often be easily solved with some of the experience, and casted off as a "skill issue". But together they build up into developer's mental fatigue: you're here to solve business problems, yet on every step you need to solve "plumbing issues", make decisions about memory model including possible feature changes and refactoring they'll require in the future. Each one of them is considered crucial by borrow checker to a point where it either cause compilation error or runtime panic.

There are places where fine-grained control over program memory and performance tuning is beneficial and can be one of the business goals: these are things from broad area of system engineering. However for your daily ERP app the complexity coming from trying to reflect real-life interactions with all their exceptionality and imprecisions is enough: you're not going to be praised because your asset management app takes 10% less CPU while the task backlog has doubled in the meantime.

So if you're a founder or developer thinking if you should use Rust for your next business project because some crypto start-up is developing their quasi-bank in it and it would look nice in your resume, please think twice and don't make regrettable decision for yourself and your colleagues.