Feeling down about Rust for serious projects

I am writing an OSI networking stack (based on the ITU-T X.200-series recommendations) in Rust here (Sorry to plug a personal project, if that is considered rude, but this is relevant.). Even though it deals with networking I/O, each layer of the protocol stack is modeled in the specifications as a finite state machine, so I figured I could make it synchronous, then define a generic I/O interface (currently called NSProvider) which would provide non-blocking socket functions using the abstract functions defined for the OSI networking layer. This means that it would not wait for a response to system calls, essentially. It just fires and forgets, and the code that "owns" the state machine would have to synchronously apply events to the stack (e.g. It would not wait for a successful TCP connection. The owning code would do that, then fire the .dispatch_N_CONNECT_confirm() method on the NSProvider to synchronously input an event into the protocol stack. To add to this, the relations between objects at the network and transport layers is many-to-many. So imagine modeling a many-to-many relation that shares identifier namespaces and I/O resources, and you can't use async code. Not the easiest problem I have ever solved for sure.

(Sorry, I'm getting to my point.)

I tried to do this with sync code and without using any Tokio-specific primitives, such as Tokio's async Mutex, because I want this library to be as re-usable as possible. But increasingly, I think I need to make it async. I have already started this change, and it has surfaced a bunch of other changes that I would need to make to my code for this to work.

I know going from sync to async is a big deal, but in several cases throughout my Rust experience over the past year, I have had a more broad categorization of this problem crop up over and over again: I write a lot of code, then find out it will not work thousands of lines in. Even when I go to solve whatever problem crops up, I do not know in advance that the solution itself is not yet another problem to be solved.

I don't even think my point is really about async alone. I'm not even sure I know what my question is either. Maybe one or more of the following:

  1. Do you feel like you are productive in Rust? I know the guys that wrote Bun (the NodeJS alternative) tried it in Rust, then switched to Zig because they were "productive almost immediately," which scares me a little.
  2. Does this problem get better as you level up in Rust? I have been programming in Rust for over a year now, and I would consider myself to be a very talented software engineer overall, but maybe I still have a ways to go with Rust.
  3. How do you know how to construct a complicated library or application upfront so you don't get 10,000+ lines of code in before you get the dreadful red squiggly that requires you to redo the whole thing?
  4. Was there something obviously wrong about my approach to the above project where I would have known not to do it that way upfront? Or is this approach still salvageable?

Thanks in advance

6 Likes

In reply to 4. if you are taking on large projects solo you might not be getting benefits of shared competencies.

3 Likes

You can't, or rather: being able to do that goes beyond the level of “competent programmer” to “genius programmer” — or sometimes “has solved this problem many times before, knows the exact requirements, and can thereby predict everything it will need”.

Instead, be prepared to rewrite. Develop the skill. Learn what to refactor first (that is, make changes which keep the program in a working state but reorganize the implementation) to make the eventual rewrite smaller. Practice not being intimidated by the slog through the whole problem. Find tools to automate parts.


Regarding your specific problem — I'd also suggest you think about adopting, perhaps in part, the “sans-IO” strategy. Keep the “not wait for a response” part, because that's highly valuable for flexibility and testability — and add the async parts you need on top of your state machines. Asyncify anything that actually needs NSProvider, but try to keep the amount of code that actually needs it small and at the upper layers of your code.

(This will also keep compilation faster, because from what I've heard though not benchmarked myself, async fns are slower to compile than regular fns since they need more analysis and generated state machine types.)

17 Likes

Yes, it is in fact the language I am by far the most productive in. I don't have to worry about making stupid, trivial mistakes, and I can describe my problem clearly and immediately, in terms of domain concepts.

I don't see a "problem", but maybe if you are not feeling productive, it's your background? I came from C++, and most of the beauty of Rust to me is what common mistakes I can't make that I could (and did) make in C++. And the memory model is a breeze to understand coming from a native language. Maybe not so much if you only ever needed to work with "objects".

You don't. You should write your code in a way that not the whole 10000 lines are interdependent on each other. So if you need to change the design of one part, you don't need to change absolutely everything.

18 Likes

There are certain kinds of problems that are easy to solve in Rust, and other that are harder, due to the trade-offs in its design. This is true for any language, but many mainstream languages have very similar characteristics. Rust breaks away from some of them to get other gains, which is what I believe makes it feel more alien and "less productive". It's largely a matter of familiarity, and productivity is hard to define. That's not meant to minimize people's experiences, there are areas with more roadblocks than other, but a lot of the friction disappears with experience. At least in my experience. I remember trying to use it like Java at first and tripping over my own feet a lot. That's my answer for 1 and 2. Change project for a bit if you need something fresh, but there's no need to give up.

For 3, my simple answer is again "experience", or rather, I make the mistakes and learn from them. Unless I happen to have read about them first. It has taken me years to realize some early mistakes, but then I know how to do it better next time. It just means that I have learned something new. There's only so much you can anticipate from experience before the rubber has to hit the asphalt. Some things are larger than other, of course. Going from sync to async is a major architecture change, so it will have a massive impact. What's always useful is to keep your code easy to change, if anything. Rust has some patterns that differ from other languages, so the road has bumps, and it becomes more important to understand the underlying problem being solved.

Enough rambling from me, just don't let this discourage you. :slightly_smiling_face:

4 Likes

I had the same feeling when I was starting with Rust. This goes away with experience, as you learn what shapes of data, and what kinds of program architectures work with Rust, and which won't. So now I can predict in advance whether a struct will be self-referential, data will need to have shared ownership, or what abstraction won't be compatible with dyn traits (object safe).

  1. Nobody is immediately productive in Rust. Rust requires internalizing rules of ownership and borrowing, which don't have close equivalents in other languages.

  2. Yes.

  3. There are some technicalities you need to know, around limitations of exclusive access, self-referential structs, patterns for interior mutability. Apart from that, observe Gall's Law, and don't immediately write 10K lines of code before it even compiles. Start from something smaller that works. If your goal is to have some very optimized zero-copy multi-threaded thing, you'll need to carefully evaluate first whether it can work given borrow checking rules (don't just write whatever and add 'a anytime compiler complains).

When you get stuck with something that just won't compile, it's important to dig into it to understand why. It can be because what you're trying to do is impossible (e.g. you're returning some on-stack data from a function), or because the language just can't express it (e.g. a setter method borrows all of self instead of one field, or you're writing a mutable iterator). When you understand why you hit a dead end, you'll know how to avoid it (rearrange code, clone something, or use unsafe).

14 Likes

I started a project with Zig and then moved to Rust, so maybe my experience will be relevant to you. You already said it -- productivity is slower to start with in Rust, and improves greatly over time. Some specifics for me were:

I created a low level concurrent data structure (a b+tree for a db storage layer) and I needed some custom unsafe data structures for optimal performance. In Zig I was able to do this relatively quickly, but as the code size grew I became more worried about memory safety and undefined behavior. That's the main reason I switched to Rust. With Rust it did take some time to create the safe abstractions (wrapping the unsafe code) but once done and tested I had a lot more confidence in it, and writing the safe code around it was very productive. Since most of the code is in the safe domain, I didn't see Rust as hurting productively but rather helping it.

The other problem I had with Zig was the lack of traits/interfaces for structuring and sharing code internally in my project. You can roll your own interfaces in Zig but doing this actually slowed me down, compared to creating traits in Rust. Again it does take time to learn to use traits effectively in Rust and I still have a lot to learn about that. But there is plenty of flexibility and the big advantage is the up-front type checking, especially via LSP in the editor -- more on this below.

  • How do you know how to construct a complicated library or application upfront so you don't get 10,000+ lines of code in before you get the dreadful red squiggly that requires you to redo the whole thing?

Rust does require a lot of up-front design, but this is also true of using other systems languages (Zig, C/C++). Quick-and-dirty prototyping is not very quick (or dirty) in Rust -- is that what you're referring to?

For almost every project I end up having to refactor it at some point and this project was no exception. The way Rust generics and traits work made this easier for me than in most other languages, since the compiler is telling me more about what I need to change. I don't understand your point about the red squiggly lines, since those are what made it easier for me. With the Zig LSP you don't get that degree of up-front type checking and in fact that's the other big reason I moved away from it. I had very little confidence when attempting to refactor in Zig.

Building things incrementally is the best way I know to avoid this problem, although since you're experienced you must already know this. Was there something specific about Rust that made this more difficult?

  • Was there something obviously wrong about my approach to the above project where I would have known not to do it that way upfront? Or is this approach still salvageable?

Sorry, I don't have much to say on that, as I have not done a networking state machine or used async.

8 Likes

Note that here the domain space make it very hard. @JonathanWilbur tries to implement something that never existed in any other language in complete form specifically because that pile of specifications not just contradicts the Gall's Law, it defies to atrocious degree.

If anyone would ever implement full ISO networking stack in any language and it would actually work… in any language… I would be ready to afford that someone “a misguided genius” status.

Simply because that pile of documents describes something so complicated and convoluted that no one ever done that before… and I have no idea why anyone would want to do that.

The actual system that works, the one called Internet today… that one was built in full accordance to Gall's Law, as series of incremental improvements.

But as a result there are no nicely looking books which preach how everything should work in a ideal world and as a sort of “revenge of bureaucracy” ISO Networking model was adopted by colleges who try to teach students using these documents.

It make as much sense as trying to teach airplane pilots with the use blueprints from experimental interstellar spaceship… that's why, I guess @JonathanWilbur started this project. Kinda like there are still people who try to build analytical engine, almost two hundred years after it was designed.

This may be an interesting project, but like analytical engine still is not finished so would that stack, I'm afraid: it's just beyond abilities of most mortals to implement that single-handedly and there are not enough interest in building something so impractical for the corporations to pour millions into it.

3 Likes

Yes, absolutely. I came to Rust from working with Scala profesionally, and immediately felt at home with its type system. Almost every obscure bug that me and my colleagues would find in our day-to-day work could have been caught by Rust's stronger type system, so one of the things that I identified early on was that Rust is the language that I would use for the type of large projects that I usually work on.

I don't think it's a matter of being talented or not. As @kpreid said, it boils down to experience. The more experience you accumulate on using a tool for solving problems, the easier it will become to use it to solve the next problem.

You don't. Again, it's a matter of experience. If you are a developer that has being writing, let's say parsers, for a given amount of time, it's just natural to expect that the next parser that such developer would write would be founded on all the experience obtainwed so far. Only in such situations you can expect such level of planning ahead.

If I were not at that point in my journey, then I wouldn't expect that everything goes perfect from the start. That's where skills such as refactoring come into play which, again, can only be obtained with experience.

I think you should change your perspective. Instead of considering this as a failure, think of it as gained knowledge: Now you have a better feeling for when a 100% sync approach wouldn't work, and async would be a more sensible approach.

People who stop learning and consider that they know everything are the very living example of what stagnation means.

12 Likes

Absolutely!

Since starting Rust, I've found that I tend to design things in a much more maintainable and readable way, and the end product tends to be quite reliable. The type system lets me express the problem domain so much more effectively than other languages.

I used to write a lot of C# for work and I never really liked the way the language guides your designs. I rewrote the company's CAD/CAM package in C# from Delphi and the web of shared mutability with implicit nulls (this was before nullable reference types was a thing) made complex user interactions really hard to reason about.

The only other language that I feel similarly productive in is TypeScript, but that comes with the disadvantage of building on the dumpster fire that is JavaScript's compiling and packaging story.

Yep. After building several projects, you get a better feel for what architectures work and don't work in Rust.

It's often the case that habits and patterns that used to work fine in other languages won't be a good fit for something with explicit mutability and affine types like Rust, so previous experience can actually hinder you there.

You don't.

Embrace the fact that you probably will need to rewrite most of the code in your project at some point. Probably multiple times.

Given that you'll need to rewrite stuff, the best way to limit the scope of refactoring is separate code into components that are separated by a relatively thin API. This doesn't always mean adding traits for everything, by the way, it could just be about splitting things into modules and keeping most of the code private except for a couple high-level "workhorse" types.

This reminds me of a great Go proverb:

The bigger the interface, the weaker the abstraction.

Writing unit tests as you go will naturally push you towards this sort of architecture. If your code becomes too coupled, tests will become really hard to write or you might find yourself rewriting the test every time an implementation detail changes. That's your code telling you that the API being exposed to others is too coupled to other things or you aren't exposing things at the right level of abstraction.

It's a bit hard to tell from prose - I'd need to read the code and really play with it to provide good feedback - bit in general it's a really good idea to structure your underlying network logic to not require an async runtime or do anything IO-related. That makes your underlying code considerably easier to maintain, test, and reason about, then you can build thin IO-specific layers on top of the core logic.

I'd avoid putting async in the core logic because it adds an entirely new dimension of complexity (time) onto an already complicated problem domain.

Others have mentioned it before, but the concept is called Sans-IO. That linked article gives a good explanation of the idea and how to implement it.

10 Likes

FWIW I think you are right that Rust often slows you down, and I do think it's a problem overall. On the other hand, it forces you to think deeply about certain types of relationships in what you are trying to do, and there is a reward for doing that, in the satisfaction you get from getting your program to compile and work. I think the people who don't admit this is a problem at all are either happier compromising with the language on their plans, or not trying to do very future proof or dynamic things, which are inherently difficult with the language, at least with the rough edges that currently remain. That said, I personally enjoy doing perverse things in Rust exactly because of the understanding you get from doing so. As far as the title of this thread is concerned, I wouldn't not use it based on the 'seriousness' of the project though, but maybe not for projects that need to move fast.

Dynamic is the very opposite of future-proof. Of all the mainstream languages actually being used outside of academia, Rust is probably on the more future-proof end of the spectrum.

Well, it's probably time to gain some more hands-on experience with the standard library and the wider ecosystem as well.

Programmers don't spend their time power-typing. I honestly don't get what "slowdown" it is you are describing here; development speed is basically never limited by how fast you can churn out code, but by the complexity of the architecture (that Rust helps the programmer a lot more than less "strict" languages do). Competitive programming is impressive but it's not what most languages should be optimized for.

6 Likes

112 closed and 56 open segmentation fault errors. I wouldn't call it to be productive.

That is 3% from all open issues are about segfaults.

6 Likes

Well, there are different interpretations of future-proof I suppose. Yes, the language is not likely to change out from underneath you, or at least there are ways to prevent it from doing so violently. But if you want to leave space in your program for abstractions you haven't thought through yet while using the same efficient language constructs and concrete types you prefer to, you may be in for a surprise when it comes time to add those future things.

I'm not surprised to see a response of the form "well you simply don't have enough experience with _" or telling me what "real programmers do". But I'm not interested in getting into a pissing contest with you. "Slowdown" is a relative term, meaning it depends what you are comparing to. I respect your opinion that Rust is faster than the C++ programming you were doing. But to deny there's much more to think about in Rust than many other languages is just wearing blinders, as I see it.

3 Likes

There's much more to think now and much less to think later. And in reality, I guess, for the people who find themselves using Rust, the latter one outweighs the former - not just subjectively, but in the actual time spent, too.

13 Likes

What's the difference?

As someone who did that many times in different [statically typed] languages I may assert that it's not hard as you think. Types guide you while you are doing rewrite or even full refactoring. Often on projects 10-20 years old started before my time.

But I'm yet to recall a single project written in a dynamic language which wasn't rewritten every 3-5 years from scratch. Simply because adding new feature in any language takes k₁ + L * k₂ where L is amount of lines already in the project and k₁ with k₂ are, roughly, constants.

Dynamic typing reduces k₁ and increases k₂ which leads to “increased velocity” in the beginning, but large k₂ constant kills you, eventually.

Static languages may feel slow and sluggish in the beginning when k₁ dominates, but since k₂ is low they may grow to humongous sizes without collapsing under it's own weight.

I'm yet to see any project with component larger written in a dynamic language that even one million of lines of code, while systems written in static languages are reaching tens of millions lines of code easily.

Dynamic typing provide flexibility, static typing provides future-proofing. Both are valuable qualities, but I have no idea how may you mix up them.

And since, out of mainstream languages, Rust have lowest k₂ and pays with largest k₁ it's the most future-proof one.

This depends very much on a timescale. Dynamic languages are perfect for startups: if you manage to sell your startup or do an IPO before your house of cards collapses then you win. Static languages are more suited for large companies which may afford that slow-down in the beginning.

Both approaches are viable, even if in different situations, but how may you call something that is destined to collapse and would be, eventually, rewritten, perhaps may times “more future-proof”? That's just some starge use of the term which I'm not sure I can understand.

9 Likes

Hmm...

If you program in C or C++ and you want robust reliable product there is an awful lot to think about. Much of it is enumerated in various coding standards documents. For example the MISRA coding standards: https://www.perforce.com/resources/qac/coding-standards or Google's coding standards: https://students.cs.byu.edu/~cs235ta/references/Cpp%20Style/Google%20Cpp%20Style%20Guide.pdf These documents run to thousands of pages and contain 100's of dos and don'ts. All these rules have to be born in mind when writing C or C++ or reviewing the code. I venture to suggest it is impossible for anyone to hold all that in their mind or spot all the violations they see in code. Which is why companies make money out of providing tools to help.

Or to see how much one has to think about when writing C++ just spend a week watching all the presentations from C++ conference one can find on YouTube. I swear over half of them are discussing the many ways one can cause UB or otherwise shoot oneself in the foot with C++ and all the things you have to bear in mind too avoid doing so.

If you write in a language like Javascript or Python you have to hold in your mind what all the types are you are dealing with. Not to mention all the "WAT"'s of Javascript that everyone likes to rag on: https://www.youtube.com/watch?v=oK2vXWfCnt4

So my question is: What are these other languages you are thinking of when you say there is more to think about in Rust than them?

Perhaps I am "wearing blinders" by virtue of my ignorance of the likes of Haskel or functional programming in general. But certainly I would have a lot to think about if I were to try and get into that area. But I have used quite many languages over the decades, from assembler up, and am not convinced that "there is more to think about in Rust".

Conversely when I have thousands of lines of Rust compiled I know that there are thousands of the time bombs that one can create in other languages that simply do not exist in my code. Arguably a lot less to think about.

7 Likes

I'd add that even statically typed languages are not all born equal in these regards. Just a couple of days ago I've spend most of the day tracking the bug in our Spring application, just to find out that some internal component of Spring has changed behavior during the minor update in such a way that it broke, essentially, half of our functionality - without any sign beforehand, besides some deprecation warnings in logs (which no one paid attention to, since, well, logs are for when something's wrong). This is very contrasted with "if after refactoring it still compiles, then probably it works" approach I'm used to in Rust.

3 Likes

There are also perspectives in which there less to think about because of the extra annotations and checking that Rust does.

Certainly compared to C++ you have to think about less in Rust, because the compiler will just tell you it's wrong, rather than being UB.

But even compared to C#, which is commonly considered less complicated, there's a bunch of places where there's less to think about in Rust. For example, since in C# you can't pass around an immutable array or class, you either have to make two copies of every class, or you have to remember to clone things all over, or you say "naw it'll be fine" and then it's not fine when you hire someone new who doesn't realize that touching the thing in the wrapper function breaks the world.

Rust has a bunch of way to say various restrictions in code -- like no, you're not allowed to share that thing between threads -- and you can look at that as "you have to do all this extra work" or as "I'm glad I don't have to thing about that manually when I'm editing something".

Whether convention or syntax is something you think is better is probably largely a personal values question.

11 Likes

Do not allow any one to impress you by saying "when we switched to language X, we were productive (almost) immediately". It's the equivalent to "I tried this diet and I lost X pound in Y days". A more useful metric would be how long it took them to produce something of actual value to a user.

When you are a beginner, you will run into dead ends, because there was a problem where you thought you knew how to solve it, but then it turned out that Rust did not accept your solution. Most of the time, Rust has a good reason. You simply find a better solution, and next time, you apply the better solution right away.

If some one who only did a few small things in Rust is suddenly tasked with a large, complex project, I expect them to struggle a bit in the beginning. On the other hand, if some one successfully built a complex piece of software in Rust, I have full confidence that they will be very productive at almost anything.

3 Likes