Rust Is a Scalable Language
matklad.github.io
matklad.github.io
A younger me, in search of a new language to learn, would have been turned off by something that is only "pretty ok"
But 20 years later, something that is "pretty ok" is exactly what I'm looking for. My experience has been that finding the perfect language (or perfect anything) is futile, and all I want is something that let's me solve my problems, don't get in my way, and don't really surprise me.
For a long while, python was my go-to "pretty ok" language, but eventually it stopped being "pretty ok" for me. Rust has now taken that spot.
That being said, because there's no one perfect language, it's handy to have a few in your arsenal. As much as I like C#, I'd still reach for Python for any data or ML type use cases.
But it has felt like the FOSS library ecosystem for .NET is not quite as good as it is for Rust. C# is far older than Rust, and NuGet apparently has 3.5x as many packages as crates.io has crates. Despite that, my experience has been that I'm more likely to find a good Rust library for any random thing I want to do. Rustdoc documentation isn't amazing but it's consistent, and venturing into other ecosystems usually reminds me that things can be worse.
The downside of C# was microsoft, but maybe thats changed now? I know they made a lot of effort to make the .NET framework more cross platform.
Another issue is that Microsoft 's first party libraries are now increasingly designed to funnel you into Azure services. The language is great, but the ecosystem is rotten.
It actually helps a lot that everything's so open source, because often companies will spit out a SDK for .NET to check a box and not have good docs around it because most of their customers aren't using .NET except for a few enterprise whales.
> Microsoft 's first party libraries are now increasingly designed to funnel you into Azure services.
Outside of the solution for scaling Blazor being "Azure SignalIR service" I'm having trouble with this one too. Their docs often call out Azure, but there's not a ton of bias in support (again, totally might just be that I don't get in the weeds enough).
https://learn.microsoft.com/en-us/aspnet/core/security/authe...
It would be odd if they only gave like Cognito as an example (especially considering AAD is free). And I didn’t know people really used IdentityServer as much in new apps, there’s other ways to do that.
Go is the only language that gets concurrency right.
Rust async is ugly but it’s ugly because async is ugly and as a systems language there are limits to how much of that ugliness it can hide.
If we could fix threads to scale better at the OS or core library level we’d save insane amounts of developer time.
Java and Elixir both have taken the non-async approach. C# is actively looking into it as well now too: https://twitter.com/davidfowl/status/1532880744732758018
Lol. I guess you haven't seen Elixir.
When you couple this with a (mostly) bunch of noob devs who have been told Go is super safe because of it's type safety, you have a recipe for disaster.
The combination of language features and surrounding social effects means, once you pursue concurrency, Go abruptly becomes one of the most dangerous languages to use for any industry where subtle errors of value are a problem ... like anything to do with money.
p.s. No slight intended to Elixir, it avoids all these risks.
- ad hoc callbacks, which had a great Result-ish type signature but really do warrant the “hell” in “callback hell”
- Promise APIs, which are semantically equivalent to async the keyword, unless you care about call stacks, and have a lot of the same hellish problems as the ad hoc callbacks they were meant to address (less nesting! same everything else!)
- Um fibers? Good luck making sense out of whatever that’s doing. It’s a good idea, but it’s also all opaque magic when you try using it.
- Actual threads and child processes… there are valid use cases, and they’re worth pursuing if you have a valid use case, but the facilities for development with them are basically “here’s a bunch of low level concepts that closely mirror their system level counterparts, hope you know/figure out what you’re doing!”
The author of esbuild gave up trying to code esbuild with nodejs/worker threads and switched to Go for a less limited/restricted concurrency environment.
Okay with that clarification I can agree it’s a good model, given its constraints. The event loop with async IO in the abstract is a good way to model a single process/thread workload for many use cases that fit it.
> I'm not sure if this abstraction is better than all of the available alternatives if you compare it to high-level features multi-threaded runtimes offer like thread-safe collections, atomic updates, concurrent hash maps, immutability, structured concurrency, etc... as in Java/Clojure.
Clojure’s solution to concurrency is a breath of fresh air, regardless of your execution environment, because its state transactional semantics are great whether your concurrency is in one process/thread or spread across many. I can’t speak to typical Java solutions, but my general sense is they’re higher level and more powerful than Node’s for actually crossing process/thread boundaries, but subject to most of the shared state problems Java has even in a single process/thread.
> The author of esbuild gave up trying to code esbuild with nodejs/worker threads and switched to Go for a less limited/restricted concurrency environment.
After a lot of exploration of Node worker threads, I’d probably similarly look elsewhere if I had a workload suited to it. You can do a lot with Node worker threads with a lot of special tuning for a use case, and I even have some proof of concept code demonstrating that it can be much better than common usage. But I put it on hold because the complexity of making it perform well is very high compared to optimizing the single thread default.
var a = new A();
A a = new();
A a = new A();I haven't written C# in a while, but I don't remember seeing that one. I skimmed the C# grammar and didn't see it there either, but I only glanced and might have missed an entire section.
I'm honestly surprised they haven't add the ability to drop the `new` keyword like Kotlin:
val a = A();Which new features do you dislike?
Of course you need to have a structure within this codebase or you'll bump into the typical problems of large codebases. Some languages force you to work this way, we do it by enforcing certain rules in the lower level framework code of the project.
Also you need to keep pace, we are on the latest Python version and code is heavily typed. This project was started in the 2.7 days.
If you have a pure ruby project and use it from day one, it probably works. But if you are starting a brand new project, just save yourself the pain and use TS.
> Of course you need to have a structure within this codebase or you'll bump into the typical problems of large codebases.
> Also you need to keep pace
I don't have the wherewithal to really dig into this now but to both those points I respond with a loving and resounding: "Duh!" (not directed at you)
What sort of project is it?
Typing definitely makes it a lot easier to navigate the codebase. But in the end maintaining any large codebase is a question of discipline which starts with simple things like code layout, class and function names, isolation of functionality.
Well, otherwise, how would it have gotten to 2 million lines of code?
Adding lines of code and refactoring are different activities and discussions.
But I admit that there are challenges. For example startup time is ~30 seconds because that's the time it takes to import everything. This automatically means that running even a single test will take at least 30 seconds, to not bang your head against the wall we mostly write new code in a REPL so everything is already loaded.
Also it's hard to beat Django in terms of productivity. You can use type defs too.
`cargo` combines poetry, ruff, pipx, sphinx, pytest, etc. You might expect this to be worse. In some cases, it is (e.g. pytest). The super power is in being able to get a project up and going immediately without wasting time on figuring out which tools to use, scaffolding them together, setting up a host for your documentation, etc.
Then there comes the problem of distributing it where the target system has to have just the right version of Python.
I have been working on a mediumish project (without tests or type hints) where I needed to add functionality including a bit rewiring.
My main problems were:
- I didn't know what data (types) were passed around when looking at parts of the code, so I had to look were the data structures were created, adapted and passed along to actually know more (or just debug it)
- After changing parts of the code, I was 100% certain that I had to run it a dozen times to fix new bugs or things I had forgotten
How it works with Rust:
- I see the types in the function signature
- I know the properties of 'classes' without scanning the whole code of the class
- when it compiles, it works 90+% of the time, because the compiler tells me where I forgot something before, if the IDE hasn't already told me
For me this is the big one. Types isn't just about writing error-free code, it also aids in making the code self-documenting.
When I read a function signature in an unknown codebase in C, C++, C#, Delphi or similar, I can almost always tell what it does and what I can do (or not). Even types I haven't seen before helps, because the name of the types can be informative.
Thanks to this I've helped countless people fix their code using libraries I've never seen before in mere minutes, simply by glossing over some of the code.
When I work in untyped code, like older Python or similar, it's much harder. Invariably I end up sprinkling dir() or similar just to figure out what the heck is going on. Ok, this so function created... something... what can do with it? Who knows?
str vs bytes confusion is a major example. If you use one where you needed the other, the results can be anywhere from inscrutable failures to hilarious. My personal favorite is if you stick bytes into a templating engine. For some reason, iterating bytes in Python yields integers (not bytes-of-length-1 or a separate byte type), and those integers can get stringized and flattened, and your webpage turns into a bunch of digits and you end up with the Matrix.
You will be amazed to find that that's how it works with C, Pascal, Java ...
Types being declared in function headers and checked is 1960's technology.
For example, a c header type might have an out parameter and an error code returned. The interaction between those two isn't specified in the type system.
I personally require discriminated unions (i.e. enums where each variant can hold its own type of data) before I consider a type system adequate.
Then I tried Rust and realized why types were good. (and then Kotlin)
Manually managing memory has turned out to be error prone (for me) and can result in quite nasty to debug bugs (because the type system is lacking), only to mention the most important thing.
I think there is enough out there on C vs Rust, esp. because of the Linux kernel
That said, it was asked about Python (within the context of Rust), and I have limited experience with Pascal and Java, so I will leave it at that.
char *func(char *in, size_t len);
It’s anybody’s guess what the actual input type is, not to mention the ownership or lifetime of the output.It's even better when all those data structures are just dicts, because it's too cumbersome to make serializable classes, so good luck figuring out what fields are in which.
Typed Python 3 is just awkward enough to not make me enjoy it on small scripts and not accurate enough (yet) to be confident in it on large projects.
I don't use numpy & friends so Python doesn't offer me much any more compared to newer typed languages once I go beyond a simple script.
Excited to see where it continues to go and we are writing more scripts in TS at work (without Deno, tho).
Have you done any backend TS? I'm curious what the best practice there is, these days.
I have been on a couple teams that used Node. Everyone was/is still using express and various ORMs / query builders. Knex and Prisma are what I saw.
Lots of neat stuff with Zod and relatedly EdgeDB.
But later I did some backend stuff, I tried to use typed Python and wrote safer code. Then the whole toolchain becomes so cluncky. MyPy, Pylinter, Flake8, etc. They're so slow, and their VSCode extensions are so weird to configure. And I never got the best practice to work with other untyped libraries. It's not that I don't know how to use typed language. Actually I do backend stuff faster in Rust than Python.
It's probably just me or my VSCode acting up.
I think for web stuff though the borrowing and move semantics is a bit of an overkill. Usually you don't need to thread or handle state in these areas (frameworks and databases handle it for you) so a GC works better here. But everything outside of that is superior to something like ruby, python or go when it comes to safety.
I know people that looove Paket, especially in the F# community, but I’ve been happy enough with things now to not feel like I need to invest the effort in adopting it.
For something like python the heap allocation or stack allocation is handled behind the scenes so the entire concept is abstracted away from you.
Arc still requires you to reason about ownership as does this GC.
I think single threaded async w/ easy no copy message passing is more akin to what most people want when thinking about concurrency (eg Go channels) and actually happens to perform exceedingly well when it’s done up and down the stack (eg io_uring). In such a model you don’t even need to worry about ownership so much because you can’t Send anything except for things that need to be so there’s not a lot of value in many of the protections Rust tries to put in place.
Tried TypeScript but that felt.. like a dirty afterthought. Maybe just because I generally dislike JavaScript? I am still using both for this project (since WASM+React) but can't say I love it
Rust was just like seeing God.
The Rust book is awesome. Well written, fun, comprehensive. Reminded me of my experience with the Django Book way back when...
Namespaced module imports? Yes, please.
Then cargo "Just Works"! Especially compared to what I had in Python (particularly if you compare to my first days using the language!) or the messiness of Javascript...
When I learned I could use cargo to open docs for a crate or build my own documentation in section I nearly fell off my chair laughing with joy
Besides THE book (Klabnik/Nichols), the ones from O'Reilly, Pragmatic and Nostarch (for Rustaceans, Gjengset, in particular as a 2nd book) are excellent to superb. I might have missed a few, these were the ones from 3 public library systems.
Just look for 2nd eds on THE book and Oreilly's Blandy et al. Course i saw one from Wrox that was a pass.
Getting the admin UI for free is nice.
All the rules about when you can safely use/share this thing and who needs to deallocate this thing becomes a pain to figure out throughout the code base.
With rust, because ownership is baked into the type system, those classes of problems just don't exist. Change it from a `Foo&` to a `Box<Foo>`, no problem. The compiler will blow up and tell you exactly where (and how) to fix those things.
This applies to more than just memory allocations. Imagine calling a method from 2 threads that manipulates shared memory. Rust won't allow you to do that without protecting that memory with something like a `Mutex`.
C++ can never quite get there despite all the innovations made because of its descent from C style types, which are much more simplistic.
Use of maximal type safety in modern C++ is astonishingly robust and expressive. If it compiles it generally works. The majority of developers are a bit lazy about type safety in my experience, which works against you. It depends on the org.
Rust in theory has a lot of potential here because it's designed to minimize action at a distance.
(Many newer C++ code-bases discourage relying on the preprocessor, but that only goes so far.)
I would say C++ is mostly the same experience, with the exception of anything involving memory safety & lifetimes. It can't guarantee you didn't accidentally introduce a use-after-free/move or anything else of that nature.
In a tortoise and hare race, sprinting off quickly doesn't count if you can't reliably reach the finish line.
That said, there are some general themes:
- The lack of implicit magic in the language. Destructors (specifically the fact that they are called automatically) is implicit. That's it.
- The context-free nature of the language. This exists at several levels:
1. At a function level. There is no cross-function inference and all the information about a function is in its signature, so you don't need to look anywhere else to understand what it does.
2. At a module level. Everything you use from elsewhere is explicitly imported in the module. There's nothing that can change what a piece of code does across a crate (eg. the way you can just redefine `true` to something else in C/C++.).
3. At a file level. The module hierarchy is explicit, it is not inferred based on the layout of files on disk.
Together, these things means you can look at a piece of Rust code and understand it in isolation, without even needing to be familiar with the codebase as a whole. You can move a piece of code from one location to another and (aside from fixing imports) can expect it to continue to function exactly as it did before.
- The amount of information that is available to the compiler. The rich type system and lifetimes means that it's harder to mess up a refactor without the compiler detecting it.- The fact that it can be used as a safe language. This is not unique to Rust, but Rust is unique in being suited to the lowest-level tasks whilst still being safe. Safety limits the damage a bad refactoring can do (at worst, you break the code being refactored, but at least you won't give the whole program UB or cause a segfault).
- The fact that macros operate on the AST rather than text. This eliminates a class of bugs with C-like macros, where implementation details of the macro have a tendency to leak through the macro definition. Refactorings can often trigger these kinds of bugs because you cannot easily think at that level while doing a large refactor.
- The tooling. Whether it's quickly knowing the type of a variable, swapping out a dependency, or applying broad fixes to a codebase, the tooling works extremely well together.
- Consistency of the language. Everything is an expression. "void" is just a normal type, etc. This level of consistency means that large refactorings require fewer actual changes than they might do in other languages. For example, when I used a bit of C# again after using Rust, it was very noticeable the amount of "pointless" transformations I had to do when refactoring because making an expression conditional may require introducing a new variable because `if` statements cannot be used in an expression. (Yeah there's the ternary operator, but it can't be used in every case, and that's just another example of inconsistency).
Well, destructors and unwinding. Many, many bugs in unsafe code have been found through unwinding code paths being not fully accounted for. At least in safe code that doesn't use catch_unwind, the worst that can happen is an unexpected state of an object shared across threads.
What's even more fun is the combination of the two. For instance, the catch_unwind function has a big flaw, where it returns an arbitrary Box<dyn Any> to represent the panic payload, and if you aren't very careful about dropping that object, its destructor can panic and start unwinding again. (There are plans to fix this by putting the payload in a shim type that makes the destructor abort on unwind, without actually changing its type_id. I find it quite an ugly hack, but there's not really any other way around it.)
(There are also other ideas to remove language support for unwinding destructors entirely. I'm against that for a few different reasons, but I'm not the one running the show.)
I am a gopher that wanted to try rust, and I looked into http servers in rust, and it looks like there is like 10 different http servers, and each of them carries with them something that is called “async runtime”, and there are different async runtimes, and you cannot (or can?) use them together, and I realized async rust is not yet finished (or it is?), and I got kind of lost.
What http servers people used? Do the asynch runtimes matter? If I want to “just” build a http server, do I need to study different async runtimes?
For the most part, you really only need to care about Tokio. async-rs is used by some libraries, but it's relatively small in comparison - and many now go to lengths to just support both. There are other runtimes you could tinker with, but really: just use Tokio.
The "easiest" web server/framework to get started with in 2023 is, in my opinion, Axum. It's from the same people who work on Tokio and it does make it somewhat straightforward to dive into as a result.
I have a few actix-web servers in production as well, and it's a fine framework - hard to say where the mindshare falls on it these days.
Rocket is a very nice web framework but effectively has a bus factor of 1 at this point and it's started to show. People coming from e.g Rails may appreciate it though.
Don't use warp (IMO) because http routing is not rocket science and doesn't need the amount of magic it tries to bolt on.
Have fun.
Just like in go, there's also a lot of web frameworks built on top of it (like all the ones listed here for go: https://github.com/mingrammer/go-web-framework-stars)
Just like in go, you can choose not to use any if you want and use hyper directly, or use one that is a quite thin layer on top of hyper like axum.
In terms of async runtimes, just pick tokio and get on with your life. It's the default choice, so picking it is the simplest option.
However, in practice, if you use Tokio you should be fine. And usually this decision will be made for you with the framework you pick.
My #1 tip is to decide on exactly what your use case is, then use cargo and look at the recent number of downloads and go with the most popular framework. Doing this for several tags (http, server, framework) is required for categories that are noisy or not tagged well.
Example: Are you writing a web service API, a web server that has server-side templates, a client-side SPA app, or a server-less function?
Usually you will see they are all aligning on the same stack tokio, serde, etc but this is definitely something to be aware of. It is critical that an application uses the same version of dependencies for cross cutting concerns.
see also: https://www.arewewebyet.org/
I'll be migrating from that to one of:
It's not that big of a deal though
Axum is popular but feels too low level to me, but some nice porcelain APIs might eventually be put on top of it
There are a few parts of the learning curve that come up but the official examples showcase it all beautifully and the community is very responsive if you ask for help.
- sccache helps with some parts of builds but rustc needs to be caching a whole lot more for rapid TDD iterations. It shouldn't take seconds or many minutes to build things. (Invoke "go does it in milliseconds" card.)
- cargo should migrate away from git and sparse protocols. Both are slow because one abuses git and the other fetches each crate metadata as a file using individual requests. It would be faster and simpler to send either a total compressed sqlite database OR a compressed SQL transaction to migrate between versions. N files on disk representing each crate is asking for fs performance problems in the real world on fses people actual use.
To use the sparse protocol with crates.io, set the environment variable CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse, or edit your .cargo/config.toml file to add:
[registries.crates-io]
protocol = "sparse"
https://blog.rust-lang.org/2023/03/09/Rust-1.68.0.html#cargo...I can see and appreciate the unix philosophy at work, but it's also adding complexity. More things to tune, more things that can go wrong or become a bottleneck.
As we've moved our services to Rust, we've found that all those special tools disappear. Sure, we need to make similar decisions around how our system runs, but (as an example) configuring an in-process db pool is much much simpler than pgbouncer.
And that's a big part of a runtime being scalable. We won't be forced to reach for another tool as our requirements expand. We still might, but we don't have to.
(Certainly golang and the JVM fall into this category as well)
Even prior to full AI replacement, a properly trained AI will be able to take English language and effectively translate it to machine code, assembly code, or C code.
It doesn't really make much sense to have the human at the this layer for much longer.
The standard library depends on external crates?
(Edit to remove that last part since it seems irrelevant.)
Yes: https://github.com/rust-lang/rust/blob/478cbb42b730ba4739351...
Of course it is not "some random" crate; all of them is either in the same repository (when `path` is used) or in other repositories under the `rust-lang` organization with well-known contributors. But the latter is conceptually no more special than other repositories, so Cargo works same.
There are dependencies on other repositories in the rust-lang GitHub organization. The optional dependencies for the backtrace feature include libraries outside the rust-lang GitHub org.
There's three dependencies to high-quality libraries in the rust-lang GitHub organization: a macro to make conditional compilation easier (cfg_if), a port of Google's SwissTable hashmap library (hashbrown), and bindings to libc. These all have maintainers in common with the standard library.
The rust standard library hashmap is (zero cost) wrapper around a hashbrown hashmap with a subset of hashbrown's methods.
cfg_if is probably used just for the convenience. I guess they're not ready to commit to stabilizing that macro in the standard library for some reason.
libc was originally autogenerated from the c header files, but if I recall correctly updating it involves a bunch of annoying manual processes necessary to ensure perfect compatibility. It's obvious used in the implementation of standard library features.
Then there's a handful of dependenencies for the optional but enabled-by-default backtrace feature like a symbol demangler and a zlib encoder/decoder. I'm not familiar with their quality, but I don't think it's as excellent as the other dependencies.