Hacker News new | past | comments | ask | show | jobs | submit login
When Rust hurts (mmapped.blog)
296 points by nequo on Feb 15, 2023 | hide | past | favorite | 198 comments



> Remember that paths are raw pointers, even in Rust. Most file operations are inherently unsafe and can lead to data races (in a broad sense) if you do not correctly synchronize file access.

This is a reality that our operating systems have chosen for us. It's bad. The way Unix does filesystem stuff is both too high level for great performance, and too low level for developer convenience and to make correct programs easy.

What would it look like to go higher level? For example:

- Operating systems could support START TRANSACTION on filesystem operations, allowing multiple operations to be performed atomically and with isolation. No more having to reason carefully about which posix file operations are atomic, no more having to worry about temp file TOCTOU etc.

- fopen(..., 'r') could, by default, operate on a COW snapshot of a file rather than on the raw file on the filesystems. No more having to worry about races with other processes.

- Temp files, like database temp tables, could by default only be visible to the current process (and optionally its children) or even the current thread. No more having to worry about temp file races and related issues.

That sort of thing. Maybe implemented like DBOS: https://dbos-project.github.io/. Or, you know, just get rid of the whole idea of processes for server-side applications. Was that really a good idea to begin with?

> However, if the locks you use are not re-entrant (and Rust's locks are not), having a single lock is enough to cause a deadlock.

I'm a fan of Java's synchronized, and sometimes wish that Rust had something higher-level object-level synchronization primitive that was safer than messing with raw mutexes (which never seems to end well).


> Operating systems could support START TRANSACTION on filesystem operations, allowing multiple operations to be performed atomically and with isolation

Windows supports that since Windows Vista [1], and at least back then it was used by Windows Update and System Restore [2]. But programming languages usually only expose the lowest common denominator of file system operations in easy APIs, so approximately nobody uses it. Also, it's deprecated by now.

But maybe someone here has deeper insights in what went right and wrong with that implementation, because on the surface it looks like it would erradicate entire classes of security bugs if used correctly.

1: https://learn.microsoft.com/en-us/windows/win32/fileio/about...

2: https://web.archive.org/web/20080830093028/http://msdn.micro...


I don't know the answer, but I'd suspect the following: nobody in storage business cares about what Microsoft is doing beside Microsoft themselves. From storage perspective, supporting Microsoft is always a huge pain. Most of those who do provide support try to limit it to exposing SMB server. I had a misfortune to try to expose iSCSI portal to Windows Server. Luckily, the company was in its relatively early stages where they could decide what things they want to support, and after couple months of struggle they just decided to forget Windows existed.

So, I think, this feature, just like WinFS, and probably ReFS after it, will just end up being cancelled / forgotten. The kind of users who use Windows aren't sophisticated enough to want advanced features from their storage, but support and development of this stuff is costly and demanding in other ways. If they cannot sell this as a separate product that runs independently of Windows, there's no real future for it. The only real future for Windows seems to be Hyper-V, but hypervisors, generally, have very different requirements to their storage than desktops. Simpler in some ways, but more demanding in other ways.

So, bottom line: it probably didn't see any use because the audience wasn't sophisticated enough to want that, and they couldn't sell it to the audience that was sophisticated enough, but was also smart enough not to buy from Microsoft.


> Also, it’s deprecated by now.

This is why programming languages don’t rush to support hyped new platform features without some combination of wide support and/or a very compelling use case in their core design or standard libraries.


> Also, it's deprecated by now.

Aaaand there it is.

Such paradigms need years, at least a decade, to percolate into the general programming culture. Microsoft (and I daresay companies in general) just doesn't have the stability to shepherd such changes.


It's kind of sad how programming improvements are essentially gated by the median skill level of developer.


It's not gated by median skill.

Rather, it's the level of effort required to standardize, carry over, or reproduce a particular feature in the disparate environments we use.

Say Windows maintained that filesystem transactional feature. It'd be exposed in their C++ (?) API. What if you wanted to write your program in Python? What if you wanted to port it to Mac? Linux?

It just takes time for such platform features to percolate to the various environments that could use them.


I do not see how it follows. A smart developer will choose a database, not a file system, if data integrity is important. So it can be simply the case that the transactional API with a lot of its limitations were simply not flexible enough for people to bother.


Nonsense. It's "gated" by common APIs available in programming languages.

If this feature is Windows-only then barely anyone would take the requisite time and effort to use it properly.

Have a POSIX-like API that is supported at least on Win / Mac / Linux and you'll see people adopting it. Before that, no chance. It's not justified.


And you just described why Rust is such a great choice for a open source project over C/C++.


Like many other things, the file system is a layer that, after some passing of time, is proving a bit raw in the abstraction it provides. And thus layers are created above it.

The same thing is happening pretty much everywhere in computer engineering. Our network protocols provide higher level abstractions (encryption, RPC calls, CRUD, …). Higher level graphics rendering libraries proliferate. Programming languages provide additional layers and safety guarantees.

> What would it look like to go higher level?

File systems aren't "bad" and don't need to be changed or replaced — we just need to use the higher abstractions that already exist much more. Use a proper database when it's appropriate. Or some structured object storage system, maybe integrated into your programming language.

Ultimately, accessing the file system should more and more become akin to opening a raw block device, a TCP socket without an SSL layer, or drawing individual pixels. Which is to say: there are absolutely good reasons to do so, but it shouldn't be your default. And it should raise a flag in reviews to check if it was the appropriate layer to pick to solve the problem at hand.

(added to clarify:)

This all is just to say: it's more helpful to proliferate existing layers above the file system, than to try to change or extend the semantics of the existing FS layer. Leave it alone and put it in the "low-level tools" drawer, and put other tools on your bench for quick reach.


> File systems aren't "bad" and don't need to be changed or replaced

The complaint is specifically about UNIX filesystem. And yeah, it's bad. It needs to be replaced, not wrapped in another layer of duct tape.

The examples of layers you described come not from any kind of sound design that evolved to be better. It was bad, design without foresight and much design at all. Historically, it won because it was first to be ready, and the audience was impatient. And it stayed due to network effect.

The consistency guarantees, the ownership model, the structure of the UNIX filesystem objects are conceptually bad. Whatever you build on top of that will be a bad solution because your foundation will be bad. The reason these things aren't replaced is tradition and backwards compatibility. People in filesystem business knew for decades that the conceptual model of what they are doing isn't good, but the fundamental change required to do better is just too big and too incompatible with the application layer to ever make the change happen.


> This all is just to say: it's more helpful to proliferate existing layers above the file system, than to try to change or extend the semantics of the existing FS layer. Leave it alone and put it in the "low-level tools" drawer, and put other tools on your bench for quick reach.

Yes! But it's easier said than done when one of these things is in the stdlib and the other isn't.


> one of these things is in the stdlib and the other isn't

Oh it's much worse than that. One of these things is what the user sitting in front of their computer has a nice integrated UI to view and search… if a photo editing applications starts storing my photos in a database that I can't easily and simply copy some photo out of, I'll be rather annoyed. And each application having their own UI to do this isn't the solution either, really.

[EDIT: there was some stuff here about shell extensions & co. It was completely besides the point. The problem is that the file system has become and unquestionably is the common level of interchange for a lot of things.] …didn't Plan 9 have a very interesting persistence concept that did away with the entire notion of "saving" something — very similar to editing a document in a web app nowadays, except locally?

Either way I don't know jack shit about where this is going or should go. I'm a networking person, all I can tell you for sure is to use a good RPC or data distribution library instead of opening a TCP socket ;).


> didn't Plan 9 have a very interesting persistence concept that did away with the entire notion of "saving" something — very similar to editing a document in a web app nowadays, except locally?

Though I find that to be an absolute mismatch when I'm opening a document for reference purposes only, which means that any edits I might make are either accidental or otherwise only meant to be temporary (like if I'm opening a DWG drawing of a plan and am drawing some additional auxiliary lines to take some measurements or something like that). Automatically saving a safety copy to guard against program crashes or something like that is fine, but automatically overwriting the master file with my changes definitively isn't the right thing in that case…


> …didn't Plan 9 have a very interesting persistence concept ...

I think that was Oberon which influenced the Plan 9 Acme editor.


It was, you can see it on an Oberon emulator.

https://schierlm.github.io/OberonEmulator/


Disagreed, the UNIX FS model is as bad as you can get.

It "works" only because 99.99% of all programs don't try to poke into other files and directories where their fingers don't belong. That's the only reason things are not in a complete chaos.

We need DB-like features in FS-es not just yesterday, but 10 years ago.

Some of the most successful projects I've seen and participated in were making a heavy usage of SQLite which does solve the FS deficiencies quite well. Though it does require a buy-in that's rarely there for most teams.

We don't need more abstractions on top of stuff. We need new ways of interacting with the old existing stuff while hiding the existing stuff forever.


The filesystem is a database.

Databases like Postgres implement such a transaction not through a lock, but by keeping multiple versions of the data/file until the transactions using them closed.

Yet other databases operate on the stream of changes and the current state of data is merely the application of all changes until a certain time, allowing multiple transactions to use different snapshots without blocking each other (you can parse a file while somebody else edits it and you won’t be interrupted).

I read about various filesystems offering some of these features, but not in IO APIs.


Then you user wonders why they see 40GB of files but 70GB of used space

> Yet other databases operate on the stream of changes and the current state of data is merely the application of all changes until a certain time, allowing multiple transactions to use different snapshots without blocking each other (you can parse a file while somebody else edits it and you won’t be interrupted).

Databases have features in-place to merge those parallel strings of changes and at worst, abort transaction. Apps using that handle that.

Now try to explain to user why their data evaporated coz they had it open in 2 apps...


> The filesystem is a database.

Nah. The file system is a slightly improved block storage management tool that isolates your database implementation from dealing directly with mass storage devices.

Thinking that the filesystem is viable as a database when it isn't — that's exactly the problem here :D


> The filesystem is a database

A totally shit database designed before we knew anything about databases. Well past time to retire them.


They are ok databases, optimized for very different use cases than normal databases. If you treat files as blobs that can only be read or written atomically, then SQLite will outperform your datasystem. But lots of applications treat files as more than that: multiple processes appending on the same log file, while another program runs the equivalent of `tail -f` on the file to get the newest changes; software changing small parts in the middle of files that don't fit in memory, even mmapping them to treat them like random access memory; using files as a poor man's named pipe; Single files that span multiple terabytes; etc.


None of those other uses are outside the scope of a real database:

> multiple processes appending on the same log file, while another program runs the equivalent of `tail -f` on the file to get the newest changes

Not a problem with SQLite. In fact it ensures the atomicity needed to avoid torn reads or writes.

> software changing small parts in the middle of files that don't fit in memory

This is exactly an example of something you don't need to worry about if you're using a database, it handles that transparently for any application, instead of every application having to replicate that logic when it's needed. Just do your reads and writes to your structured data and the database will keep only the live set in memory based on current resource constraints.

> using files as a poor man's named pipe

Even better, use a shared SQLite database instead, and that even lets you shared structured data.

> Single files that span multiple terabytes; etc.

SQLite as it stands supports databases up to 140 TB.

> even mmapping them to treat them like random access memory

This is pretty much the only use I can think of that isn't supported by SQLite out of the box. No reason it can't be extended for this case if needed.


I remember a discussion a while ago to use SQlite as a filesystem engine. I imagine that not needing a server / daemon would make it more reliable for one of the first things needed on boot.

However, I don't know what's the recommended way to handle concurrent writes with SQlite. In the end we have a single process handling all the persistence logic, which becomes essentially a server just like Postgres?


> I'm a fan of Java's synchronized, and sometimes wish that Rust had something higher-level object-level synchronization primitive that was safer than messing with raw mutexes (which never seems to end well).

I'm not a Java expert. But from looking it up it seems like Java's synchronized is worse in every way to Rusts Mutex(And you most of the time shouldn't even be using mutexes in Rust). You forget a synchronized annotation? Too bad. Whereas a mutex in Rust protects the access to a variable. You literally can't modify it (well modulo unsafe) unless you lock the mutex.


The "file" abstraction sucks. However, it's so deeply ingrained in everything we do that it's nearly impossible to do anything about it. At the language level, if your files don't behave the way Unix files do, then you will have questions from your users, and things like ported databases will not work.

Mutexes and locks are also a really tricky API, but making them re-entrant has performance and functionality costs - you basically need to store the TID/PID of the thread that holds the lock inside the lock.

I'm sure there's a crate for a mutex-wrapped datatype, trading speed for ease of use, but if not, it's likely a very easy crate to put together.


I agree that it sucks but I do think that it’s possible to solve most of the problems.

On Linux, there are some new APIs you see crop up every once in a while. For apps running in the data center, you can use network storage with whatever API you want, instead of just using files. And on the Mac, there have been a couple shifts in filesystem semantics over the years, with some of the functionality provided in higher-level userspace libraries.

Solving the problems for databases seems really hard, just based on all the complicated stuff you see inside SQLite or PostgreSQL, and the scary comments you occasionally come across in those codebases. But a lot of what we use files for is just “here is the new data for this file, please replace the file with the new data”, and that problem is a lot more tractable. Another use case is “please append this data to the log file”. Solving the use cases one-by-one with different approaches seems like a good way forward here.


Moving to a fully transactional model for filesystems sounds like it will create more pain than help, especially given the historical tendency of filesystem implementations to cheat on semantics every opportunity they get.

> - fopen(..., 'r') could, by default, operate on a COW snapshot of a file rather than on the raw file on the filesystems. No more having to worry about races with other processes.

You can go a few steps further. If you have some sort of O_ATOMIC mode on creating a file descriptor, that operates on a separate snapshot in both read and write mode (and in the case of open for write, will atomically replace the existing file with the newly written copy on close), that would have more correct semantics than existing POSIX I/O for most applications. You can't make it default at the syscall level, but most language I/O abstractions could probably make it default. Notably, this can be implemented in the OS kernel without actually touching filesystem implementations themselves, just the way the kernel arbitrates between userspace and the filesystem.

Another interesting file mode that fixes many use cases is having some sort of "atomic append" for files that handles the use case of simultaneous readers and writers of log files. Set up the API so that you can guarantee that multiple writers each get to add their own atomic log message (without other synchronization), and that readers can never see just part of a log message.

The difficult part of the filesystem to reason about is the file metadata and directory listings. I've seen suggestions in the past to use file descriptors instead of strings as the basis for handling directory queries in language implementations. If you don't do at least that much, it's basically impossible to solve TOCTOU issues, but I'm not sure there's a lot you can do with COW snapshotting of directories that still manage to be acceptable in performance.

> Temp files, like database temp tables, could by default only be visible to the current process (and optionally its children) or even the current thread. No more having to worry about temp file races and related issues.

Creating unnamed temporary files is a solved problem. Named temporary files is more difficult, but create-only-if-doesn't-exist exists on all platforms, and most language APIs have some way of getting to that (it's "x" flag in C11's fopen).


>Another interesting file mode that fixes many use cases is having some sort of "atomic append" for files that handles the use case of simultaneous readers and writers of log files

My understanding is that O_APPEND gives exactly this guarantee. Readers can read partial records, but with proper record marking it shouldn't be an issue.


That assumes your data can be written in a single write(2) call.

I don't know what size record is guaranteed to be writable that way, if any, but I wouldn't assume it's infinity. Short writes aren't illegal. Perhaps you were pointing at this, but then you need to work around the limitations of the OS, and...

Why can't we just have transactions?


> Short writes aren't illegal.

They aren't, but in practice I don't think they can happen in the scenarios I looked at (= BSD and Linux writes to "normal" file systems backed by mass storage) - unless you run out of disk space or your disk has some low-level error.

But, again, the problem is that the API does not provide this guarantee. I think it's generally true in practice, but… you can't rely on it.

(My context was a thread-capable lock-free logging backend; we just call writev() with the entire log message at once. Of course there's still locking to do this in the kernel, but that's always there — rather deal with one lock than two.)


Honestly that's the worst of all worlds: Behaviour that's reliable, but not guaranteed.

People will definitely start to rely on it, and then you can't change it later when it makes sense, which means the relevant APIs can't be reworked to better treat disks as the asynchronous network devices they nowadays are. Even though the APIs are flexible enough (per documentation) to manage that.

On the flip side, when you're the developer, you can't rely on the behaviour without feeling dirty, and/or needing separate codepaths per OS because this isn't specified, and it's a bad idea to rely on if you aren't absolutely sure you're on Linux.

And then there's the fact that, while write(2) on O_APPEND is certainly atomic in most cases... I simply can't trust that's true for arbitrarily large writes, because it certainly isn't specified; it might do short writes above 64k, or 64M, or something. So I'll need an error-handling path of some sort anyway.


I don't think that write on a actual file can randomly return a partial write, unless an actual error has occurred (out of disk space, segfault on the buffer, i/o error, or an interrupt). These are all things that are either under the application control or would need to handled anyway.


I thought it could happen if you received a signal but apparently on linux signals won't interrupt file i/o. which brings back memories of processes getting stuck doing NFS I/O.


Personally I think keeping it low level but predictable would be far saner approach. App knows what it want to do with data, filesystem can't. Just trimming all of the inconsitencies (especially the "fun" around sync and cached access..) and it's already much better. Of course probably very hard to do without fucking up existing apps ;/

> Operating systems could support START TRANSACTION on filesystem operations, allowing multiple operations to be performed atomically and with isolation. No more having to reason carefully about which posix file operations are atomic, no more having to worry about temp file TOCTOU etc.

And your performance goes absolutely to trash once you start trying to make filesystem act as transactional database. Especially if you then try to use the filesystem for a database.

> fopen(..., 'r') could, by default, operate on a COW snapshot of a file rather than on the raw file on the filesystems. No more having to worry about races with other processes.

Another "performance goes to trash" option. Sure, now more palatable with NVMes getting everywhere, but still a massive footgun

> Temp files, like database temp tables, could by default only be visible to the current process (and optionally its children) or even the current thread. No more having to worry about temp file races and related issues.

Already an option O_TMPFILE

> O_TMPFILE (since Linux 3.11) > Create an unnamed temporary regular file. The pathname argument specifies a directory; an unnamed inode will be created >in that directory's filesystem. Anything written to the resulting > file will be lost when the last file descriptor is closed, unless the file is given a name. ... > Specifying O_EXCL in conjunction with O_TMPFILE prevents a temporary file from being linked into the filesystem in the >above manner. (Note that the meaning of O_EXCL in this case is differ- > ent from the meaning of O_EXCL otherwise.)


You are thinking about it wrong.

I'm not the author of the above proposal, but I'd imagine the implementation to be more in the same spirit as uring_io or EBPF: batch many operations together and execute them all at once, without interruption, without crossing multiple times between user-space and kernel-space, without resorting to implement the same guarantees on the side of the application which, inevitably, are going to be less efficient than those provided by the platform.

You fall into the same trap as many others who benchmark individual tiny features w/o seeing the larger picture where these features combined to accomplish the end goals. Applications want sane correctness guarantees, and if they try to accomplish those goals using existing tools they will have to use multiple features. Perhaps, individually, those features perform well, but in a useful (from the application standpoint) combination, they don't. And that combination is the one you need to benchmark.


Yeah my expectation is that this sort of thing would dramatically improve performance. Doing atomic operations on filesystems right now involve an awful lot of fdatasync() calls and some creative use of checksumming to avoid torn writes. A better designed API wouldn’t need all that guff, saving me (the application developer) from a lot of headaches and bugs. The kernel could almost certainly do a better job of all of that work internally, with less overhead than I can from user space.

My much more modest feature request from the kernel is a write barrier. If I’m using double-buffered pages or a write-ahead log, right now my code says write(); fsync(); write(); in order to ensure the log is written before updating the data itself. I don’t need the operating system to flush everything immediately - I just need to ensure the first write happens entirely before the second write begins. Like, I need to ensure order. A write barrier command is all I need here.

The posix filesystem apis are a sloppy mess. I have no doubt that the right apis would make my life better and improve the performance of my software.


> > fopen(..., 'r') could, by default, operate on a COW snapshot of a file rather than on the raw file on the filesystems. No more having to worry about races with other processes.

> Another "performance goes to trash" option. Sure, now more palatable with NVMes getting everywhere, but still a massive footgun

Only because the filesystems aren't built that way. ZFS could handle this just fine.

The fact that adding SQLite into your application is often faster performance than doing raw disk accesses yourself says that our abstractions are wrong.


> Already an option O_TMPFILE

This is not portable.


Well obviously, this is a discussion of the limitations of posix.

(Of course, as we see time and again, the real portability solution is for users to standardise on a single platform)


> Temp files, like database temp tables, could by default only be visible to the current process (and optionally its children) or even the current thread. No more having to worry about temp file races and related issues.

This is one area where the Unix design is much closer to good compared to other historic approaches. The separation of file identity from file paths (not sure if it is a a Unix invention) pretty much allows temp files to be implemented, in fact I believe O_TMPFILE in Linux allows that.

> START_TRANSACTION

can be implemented on top. Transactions are quite involved, so I wouldn't blame a random file system for not implementing them. Use a database (yes, most of them skip the buffering and synchronisation layer of the filesystem they are stored on, and use e.g. O_DIRECT).

> fopen(..., 'r') could, by default, operate on a COW snapshot

Something like that would require the definition of safe states in the first place (getting an immutable view is pointless if the current contents aren't valid), so we're almost back at transactions.

I think Unix file systems are fine and usable as a common denominator to store blobs, like object stores do, but also easily allow single writers to modify and update them. As ugly and flawed and bugged as most file systems are, most of the value comes from the uniform interface. You need better guarantees than that, you probably have to lose that interface and move to a database.


Per application isolated storage is becoming more and more popular. Docker containers, flatpaks, android apps, etc are all providing isolated storage by default.

Recursive locks are a nightmare. You cannot reason about lock acquisition order and have to invent new ways to make it safe.


> The way Unix does filesystem stuff is both too high level for great performance, and too low level for developer convenience and to make correct programs easy.

Indeed. Imagine if on program start, each process gets its own sqlite database rather than a file system. So many issues would be solved. A user shell is then just another sqlite database that indexes process databases so you can inspect them using a standard interface.


That sounds like the Android nonsense where your data ends up "living inside" a particular application, though.



File systems should never be transactional. That is plain incorrect. You or your tech should solve that if needed. The easiest way to see that this is incorrect is that you need to handle the error anyway. Meaning, adding something that can cause another error for an error is not viable. GO got this totally right.


The problem with the application solving the problem is that it can't control other applications attempting to write to the same files.


Data stores are poor integration points.


Sure, but the filesystem is already an integration point. I much prefer standard file formats that I can use with any piece of software than trying to get pieces of software to talk in some other way.


> I'm a fan of Java's synchronized, and sometimes wish that Rust had something higher-level object-level synchronization primitive

There are two big problems with Java's synchronized that I can think of. It adds a monitor and therefore memory overhead to every single object. And it publically exposes the monitor allowing potentially bugs due to unintended use by clients.


rust is supposed to be a systems programming language, so... yeah expect to have systems programming problems to deal with. you want the safety of high level languages, use one...


same issue with networking (specifically per packet heuristics) and even Java hasn't got that right either.


I really resonate with the async complaints in the article. Rust concurrency is not fearless. I fear writing async code every time I have to do it. I have to think so much harder which ultimately means opportunity for errors.

Even though Rust does slightly skirt the colored function issue, my code still bifurcates because truly correct async code needs to use async variants of the standard lib for file access. Knowing when something you call blocks, or grabs a lock, etc. is something which the compiler doesn’t help you with but should. And then how the hell are you supposed to use any code sanely when the entire community is using different async runtimes? Why can’t the runtime be part of the core language with hooks into parts that might need to be customized in hyper special cases?

It really feels like we lost the steam behind async Rust and we’ve got this partially complete thing that’s neither high level enough to be convenient nor low level enough to be fearlessly correct. Async traits still aren't a thing and it’s been years.


> And then how the hell are you supposed to use any code sanely when the entire community is using different async runtimes

It's not that big of a deal in practice. You pick a runtime. Most of the library ecosystem doesn't care about the async runtime used, and that which does uses tokio.

> Why can’t the runtime be part of the core language with hooks into parts that might need to be customized in hyper special cases?

Because Rust ships a minimal runtime.

> Async traits still aren't a thing and it’s been years.

They were blocked on generic associated types which landed recently, and will land in stable in the near future. For everything else there's `#[async_trait]`.

Also, "fearless concurrency" is a lower level kind of "fearless" than "how do I use an async API." It's about memory safety and program soundness in the presence of concurrent execution. The fact that the only fears we have are about the ecosystem and APIs is a testament to how Rust eliminated the deep fear of concurrent execution.

And if we're going to take a shot at async, let's take a shot at how fucking hard it is to implement a correct async executor and how bad the docs are. std Rust does next to nothing to abstract or make your life easier.


I use Rust in practice. We use async_std. Someone recently added reqwest. We now have two async runtimes. It’s a problem.

“minimal runtime” is semantics. Rust shipped async. IMO that would expand the definition of what is minimal for Rust to include in the runtime. I’m 100% certain no async anything would need to make it into programs that don't use async so the surface area of the runtime need not change for anybody unduly if Rust supported async.

I’m not saying Rust hasn't made async things better. It has. But it’s still really messy. It doesn't give me the same peace of mind that Rust does around memory safety.

And that’s my gripe. I admit it’s a rather privileged one. But that’s Rust’s fault. Rust has shown me how nice it is to fight with the complier till I want to scream if it means my program isn't going to have dangling pointers everywhere. I am a lazy dev and want the same for my concurrent code. Rust currently doesn't deliver on that promise, IMO. And it surely could with some continued work and slight philosophy adjustment.

It’s literally incorrect to use parts of Rust’s stdlib from async code. You can’t tell me that’s a good thing. C.f. Swift where the async support is thoroughly cooked into the language, runtime, and stdlib. Rust can do better here.


> We now have two async runtimes. It’s a problem.

I agree that this is a problem, which is why "providing an abstraction layer for runtimes" is something that comes up often as a desired project, but if you zoom out I believe the choice to not have a baked in runtime was the right call. It allowed schedulers to experiment with their APIs and internals in a way that anything in the stdlib can't. It allowed for the existence of schedulers for embedded devices. You can write a keyboard firmware using async/await to deal with IO events! How cool is that?

Granted, if you're working in a single "vertical", like web services, this sounds like "useless gold plating", but precluding use cases like that one would go counter to Rust's goals. If Rust didn't care about the language scaling from embedded to HPC, then you could indeed have "Easy Rust" that erases a bunch of sharp edges of the language.

> Rust can do better here.

The team agrees. We're working on it. It was a major area of focus on 2021 and wg-async still exists and is making progress. Some things take time be finished because there's a lot to do, not enough people and they are just plain hard.


If Rust never wants to ship an async runtime, then fine. But in place of that there needs to be a clear standard async API that everything uses so that as a user I can just bring my own runtime as I please and not worry about dependencies shipping their own runtimes. The problem right now is that the api is provided by the runtime so everything ends up depending on some flavor of async standard functionality. As part of this async API, things like async variants of functions that block (in the commonly accepted definition of blocking, i.e. "sleep or do no work while waiting for an OS interrupt to wake them, so locks and I/O") need to be clearly defined as such so that the compiler can reassure me that if the code compiles then I'm not calling any blocking functions inappropriately. Part of that I'd think would also be an attribute or syntax that would be attached to the existing blocking stdlib functions so that calling code that eventually calls them from an async context could be flagged as an issue at compile time. These two things would solve a lot of the pain I'm telegraphing.

Anyway, I don't keep up day-to-day on Rust happenings. I'm just a user of the language who sometimes needs to dive into the issue tracker when I run into bumps. If all this stuff is being worked on then awesome, simply take my critique as validation that you're working on the right stuff. At the end of the day, though, my experience and feedback is driven by what's currently available to me, not what's in the works but hasn't shipped yet. And part of my critique is that it takes quite awhile for Rust to move on issues that really only exist because something was shipped in a partially completed state. Perhaps the community could be more aggressive with the notion of a version 2.0 and that would unlock changes that are really difficult and annoying and time consuming to make in a compatible way but would be much more palatable if allowed to break a few things here and there. It's great that we have nightly Rust, but perhaps it's about time for a rust-next where people can start staging breaking changes.


FWIW, these are items that they are working to improve with things like keyword generics and defining a clearer async api so that it will be possible to swap out executors in the future.


One thing that drew me to Rust was actually Elixir/Erlang calling out to it for certain specialized needs. Within Elixir/Erlang you get best of breed concurrency but exiting the BEAM to run other code is unsafe. Calling out to Rust, however, comes with great safety guarantees.

Managing concurrency outside of Rust and then calling Rust for the more focused and specialized work is a good combination IMO.

https://github.com/rusterlium/rustler


New to Rust, but found this project via Elixir forum that looks intriguing: https://lunatic.solutions


This does look intriguing! That being said:

> Lunatic's design is all about super lightweight processes. Processes are fast to create, have a small memory footprint and a low scheduling overhead. They are designed for massive concurrency.

> Lunatic processes are completely isolated, with a per-process stack and heap. If a process crashes, it won't affect others in the runtime.

I don't understand. Is each Lunatic process an OS process or not? If it is, it sounds anything but lightweight. If it is not, it is not "completely isolated", at least not in the usual sense of the word.


> If it is not, it is not "completely isolated", at least not in the usual sense of the word.

Well that's where lunatic comes in. Lunatic provides the isolation and abstraction so you can run various wasm binaries side by side. If one of those binaries segfaults, lunatic presumably catches the interrupt and kills the corresponding wasm instance that caused it rather than just giving up and crashing.


I was rather referring to security boundaries – if lunatic processes run inside the same OS-level process, the sandboxing cannot, almost by definition, be as good.


Why not? But you’re probably right. Anyway I doubt this os a full blown hypervisor yet. Probably more an application deployment model where you trust everything you deploy, similar to K8s.


> Why not?

Because if there is a vulnerability in Lunatic, one Lunatic process could (in theory) gain access to another Lunatic process's data. Kernel-space processes protect against that through various mechanisms and their implementation has been battle-tested over decades.


The kernel has vulns all the time. It’s super complicated and complex and C—that’s the real problem. Lunatic is new and simple. I wouldn’t be surprised at all if Lunatic was more secure than the kernel.


Not an OS process, no. It's a green thread / fiber.


Completely isolated is probably wrong but isolated hits the nail on the head in the sense that there are no surprise global variables like in say Java.


If it’s using the terminology to reflect processes in the BEAM then it’s very lightweight.


We actively ban Rust's async /await syntax from our codebase for a few reasons, though primarily it's been an issue with it's lack of maturity. If feels incredibly painful to use in all but the most trivial cases. However; even if were to be much more user friendly, it still would be the wrong model for most of our code since we care significantly more about parallelism then about concurrency.

Having use async/await in production a bit, I wouldn't really recommend it to anybody: the async runtimes are large dependencies typically that are difficult to roll your own, understand or debug. Code built on top of them has numerous performance issues that I uncovered when running it through it's paces. Much as I'm a fan of Rust, a fan of async await, I am not.


Are there any web server frameworks built on the now standardized mspc concurrency model?


There were some popular comments on HN recently saying that manual threading has got much easier in Rust lately and it's now easier to just not use async.

I have no idea about this myself, it's just a bit of public opinion that felt worth noting in the back of my mind!


Imho this has always been true. Multithreading is very mature in rust, and generally a pleasure to work with (especially if you lean towards channels over mutexes). Async is still a bit tacked on and has a number of footguns and UX problems.

The main reason to use async in rust is that seemingly every network library uses async. And of course servers that would have to start a huge number of threads without async, though from a performance standpoint many servers could honestly live without it.


The main reason to use async in rust is that seemingly every network library uses async.

Yes. I was calling that "async contamination" a few years back when that trend started. There's a strong lobby for doing web backend stuff in Rust, and they want it to work like Javascript.

On the thread side, deadlocks can be a problem. You don't get any help from the language in keeping your lock hierarchy consistent. There are systems that can detect at compile time that one path locks A before B, and another path locks B before A. Rust needs such analysis.

It turns out that the built in mutexes are not "fair". Actually, they're worse than not fair. If you have two thread loops that do "get item, lock, handle item, unlock", one of them can be starved out completely. This happens even though there's a moment when the mutex is unlocked and something is waiting on it. It's a consequence of an optimization in std::sync::Mutex for the fast case.

The "parking_lot" crate has fair mutexes, but it doesn't have panic poisoning and is not unwind safe, so catching failed threads to get a clean shutdown is hard. This is an issue for GUI programs, because failing to catch panics means the user experience of a failure is that the window closes with no message.


"On the thread side, deadlocks can be a problem. You don't get any help from the language in keeping your lock hierarchy consistent."

The 98% solution to this problem is to dodge it entirely. Communicate with things like channels, mailboxes, and actors, or other higher-level primitives. This is how Go programs hold together in general despite not even having as much support as Rust does for this sort of analysis.

The rule I've given to my teams is, never take more than one lock. As soon as you think you need to, move to an actor-ownen-resource or something. The "real" rule (as I'm sure you know, but for others) with multiple locks is "always take them in the same deterministic order" (and even that's a summary, it really also ought to discuss how only mutexes that can ever be taken at the same time have to be considered in order, and heck for all I know it gets complicated beyond that too; at this point I bug out entirely) but this is, in my opinion, the true source of "thread hell" as written about in the 90s. I don't think it was threading that was intrinsically the problem; yes, threads are more complicated than a single thread, but they're managable with some reasonable guard rails. What was insane was trying to do everything with locks.

In theory, this rule breaks down at some point because of some transactionality need or another; in practice, I've gotten very far with it between a combination of being able to move transactionality into a single actor in the program or offload it to a transactionally-safe external DB. For which, much thanks to the wizards who program those, test those, and make them just a primitive I can dip into when I need it. Life savers, those folk.


Yes, I might have been better off with an actor model. I'm writing a high-performance metaverse client, which has both the problems of a MMO game and of a web browser. Must maintain the frame rate, while content you don't control floods in.


> The main reason to use async in rust is that seemingly every network library uses async

Isn't it easy though to use an async function as a blocking function, by polling in a loop? (though maybe that still requires an async runtime?)


I'm coming from go, what did you mean by "if you lean towards channels"? Did you mean std::sync::mpsc? Seems like a rather crippled channel if you can only have a single consumer.


Crossbeam (https://docs.rs/crossbeam/latest/crossbeam/channel/index.htm...) provides a high performance and ergonomic MPMC channel. As of the latest release of Rust it's now the basis for the MPSC channel in the stdlib.



> seemingly every network library uses async

still some options that don't require async:

attohttpc

oxhttp

ureq


I recently wrote a program that used rayon to do a multithreaded but heavily I/O-bound computation. It was super simple to write that and get it working. It executed hundreds of millions of tasks, but you just tell rayon how big the thread pool should be and execute everything on a parallel iterator. It looks just like single-threaded code. As simple as this sort of thing can be.

But in theory, because of the I/O, it should be much more efficient to run this async. So I tried doing it async on a single thread to start with. That was a bit more difficult to get working, just because of all the messiness around async. And it was much slower because of the single thread.

I then tried to get that working with multiple threads. That proved problematic. I tried libraries like par_streams but ran into issues. I ended up using Tokio’s buffered_unordered to get a simpler setup with a pool of Tokio tasks.

This ended up giving quite unpredictable performance and being very heavy on memory usage. It seemed like it was going to need quite a bit of tweaking to get it to work as smoothly as the multithreaded version. I ended up abandoning it, since I didn’t have time to debug the black box of Tokio’s internal scheduling.

The lesson I took from this is that unless you have a really strong need to use async, multithreaded is going to be a lot easier and more reliable to implement, and the code will be much more transparent, not littered with awaits and complex Future type signatures.


Unfortunately async is the only straightforward way you can do cancellation so there's still a lot of scenarios where you can't just use threading easily.

I'm not sure why people are disagreeing with this: how would you cancel UdpSocket::recv without an async runtime? If you're using just the sync UdpSocket, you will need to go set a read timeout and repeatedly poll and insert your own cancellation points, or otherwise jury-rig your own mechanism up with syscalls to select/epoll/whatever, and be careful the whole time you're not leaking anything. However, if you're using an async runtime, you only have to drop the future and the recv is cancelled and the FD is dropped from the scheduler's waitset and everyone is happy.


It depends on what the OS gives you. As far as I know the old BSD sockets-like Linux I/O syscalls are not really cancellable (the process would have to send a signal to itself to get interrupted, or close the fd, or pthread_kill ... https://stackoverflow.com/questions/17822025/how-to-cleanly-... )

And that's usually where the zero-cost [compared to hand rolling it] abstractions come in, but it turns out that's not enough, we would also need composability (decomposability to be precise), because async Rust is doing a lot, especially if you need only the cancellation feature.

Stepping back, it all depends on the development budget and goals. If time is short but there's plenty of RAM and performance budget, just go with Tokio. If that's too bloated, go with something low-level, eg. mio, and if you need every last bit of performance, then you are basically bargaining with the kernel anyway, so io_uring, and XDP (eBFP) ... and along the way there are potential stops at Seastar (a C++ framework) and dark alleys like DPDK (https://talawah.io/blog/linux-kernel-vs-dpdk-http-performanc...) :)


What's changed to make it easier?


Probably the addition of `thread::scope`.


They are probably referring to the introduction of scoped threads last year.


I really feel that's this is just inherent to async/await model, you can only put so much lipstick on a pig.

Light threads + messaging (whether proper like Erlang or bastardized like Go) feels like far superior model for concurrency in near-all cases and also easier to write and reason about (series of "functions" sending messages looks very similar to serial code) but I'd imagine it would be very hard to make convenient in GC-less language


> I really feel that's this is just inherent to async/await model, you can only put so much lipstick on a pig.

I've been coming to the same conclusion after working with C# async/await significantly in the last few years. I suspect that async/await in javascript was better than callbacks, so it entered the industry zeitgeist as absolutely good as opposed to relatively good.


> And then how the hell are you supposed to use any code sanely when the entire community is using different async runtimes?

Honestly, and this might be a hot take, but I think that's easy to solve. Just pretend that tokio is in the standard library and use it. And refuse to support anything else. Tokio is stable, fast, well maintained and supported. There's really no reason to use anything else in 98% of cases.


The problem is that this.. preference.. isn't made clear or justified anywhere. I use `async_std` because its value prop is "the stdlib but for async". I don't want some weird thing called `tokio` in my codebase with extra bells and whistles I don't need. async-std is a minimal simple fast async runtime for Rust. I am aligned with their vision and love using it. async-std works flawlessly and only really lacks more comprehensive stream support which I augment with the futures crate as is pretty standard.

If yall want everyone to use Tokio then tell me why it's better than async_std. Tell me why it's the de-facto community standard. Because "popular web framework" uses it? Why are all the cool kids using it? Because the others are?

No. Either I want some group with authority to bless a core async runtime implementation that aligns with Rust's philosophy and value prop and bring it under their wing so that it can be made to serve the goals of the project which might include being configurable enough to deploy in all of the myriad scenarios and environments where people use Rust today.

Or in lieu of that, provide the necessary abstraction layer so everyone can bring their own executor like it's supposed be.

The entire premise of Rust not providing a blessed implementation is so that people could pick and choose based on their preferences and use cases. That's the argument. The reality is that the goal is not realized because Rust has not done the work to enable everyone to simply bring their own runtime and call it day. So Rust has not delivered on the core reason for not shipping a runtime in the first place.

The solution of "just use a de-facto blessed runtime" really seems like a concession if Rust's argument for not having one is to be respected.


> If yall want everyone to use Tokio then tell me why … it's the de-facto community standard. > I want some group with authority to bless a core async runtime implementation…

Hmm, if Tokio is the de-facto community standard, isn’t that saying that the authority of “people who write and run async code for actual production systems” is blessing it as a preferred option?

> The reality is that the goal is not realized because Rust has not done the work to enable everyone to simply bring their own runtime

Or, another possibility is that it hasn’t been realized because building a robust, production-ready, well-maintained runtime is itself a lot of work, and Tokio is the only project that has actually done it.


> Hmm, if Tokio is the de-facto community standard, isn’t that saying that the authority of...

Being the most popular async runtime (a de-facto status) is one thing. Being a community standard voted on by the rust community and blessed as such is another.

> Or, another possibility is that it hasn’t been realized because building a robust, production-ready, well-maintained runtime is itself a lot of work...

The Rust people only need to provide a standard async abstraction layer, not build the runtime.

Then everyone can bring whichever runtime they want, which is the argument for why Rust doesn't ship/bless one.

Basically Rust wants to have its cake and eat it too. "We don't want to add an async runtime to rust because rust has a small runtime that only supports some of the features the language offers and we want to enable people to interchangeably use their own based on their own use cases and needs." vs the reality that "In order to use their own runtimes interchangeably would require an async abstraction layer which we also don't provide."

So we're left with the worst of both worlds. We have neither (a) a blessed community standard so other runtimes can die, nor (b) an abstract interface atop which authors can write async code and beneath which runtime implementers can hook in interchangeably. We have (c) a bunch of disparate little runtime ecosystems with their own sets of compatible code which can't be used together.


It took me about of month of writing async rust (coming from a solid sync rust background) to feel comfortable. In that first month there were definitely a lot of inscrutable issues (fortunately the tokio discord was very helpful).

But now it feels productive, and if you're totally in the async world, it all works pretty well.

The lack of async traits is my main pain point today, but you can mostly work around that with the async_trait crate. Generally it's been slow progress on usability but I think it's in a much better place than it was a year ago, and will clearly be better in another year.


It's coming!

https://blog.rust-lang.org/inside-rust/2022/11/17/async-fn-i...

Although I'm still not sure how they return the unsized Future without the whole pin box method


My understanding is that `async fn` in a trait is desugared to `fn() -> impl Future` just like it would be anywhere else, so there's no unsized types involved.


It's actually desugared to a generic associated type:

    trait Foo {
        type bar_Returns<'s>: Future<Output = Result<u64, ()>>
        where Self: 's;

        fn bar<'s>(&'s self) -> Self::bar_Returns<'s>;
    }


> Even though Rust does slightly skirt the colored function issue [...] knowing when something you call blocks, or grabs a lock, etc. is something which the compiler doesn’t help you with but should.

Are not colored functions precisely the compiler helping you identify when the code has the potential to block? If I write a block of JS in a non-async function context, I can be 100% guaranteed that no other task will interfere with my function's execution. If I write a block of JS in an async context, I can be 100% guaranteed that the only times when any other task may interfere with my functions execution are explicitly marked with `await`. I love this! I can't imagine writing serious code without it (barring some go-like messaging model), and I have no idea why people hate it so.


The point is that having to think about color of the function is disadvantage and needless complexity

> If I write a block of JS in an async context, I can be 100% guaranteed that the only times when any other task may interfere with my functions execution are explicitly marked with `await`. I love this!

You only need to think about it because runtime isn't parallel. If you want ordering in execution you should be using synchronization primitives anyway.

In Go doing "async" via channels could possibly start running the code the instant you instantiate it, on however cores there are available. Go have problem of that requiring a bunch of boilerplate (as there is no macros), but with generics now it is manageable and I can just say "here is function, run it on all elements of the slice in parallel" very easily and it all be pretty reasonabe, like

    out = ParallelMapSlice(mapFunc,8,in)
All done async without thinking about async.


If mapFunc reads state it also writes, you should be thinking about async. If it doesn't, the problem isn't all that interesting. Though I did admit in the parent comment that Go's approach is reasonable.

It should be noted that similar code is possible in JS with WebWorkers. Async/Await is more for same-thread concurrency.


Some downsides.

The term blocking isn't well defined. How slow does an operation have to be to be considered blocking? There are no rules. Fetching google.com/robots.txt is by universal agreement a blocking operation. Reading a file from the filesystem is considered blocking in JS but may not be in other languages, especially given that very fast disks like Optane or modern NVMe Flash can be only an order of magnitude or two slower than RAM. Taking a lock may or may not be considered blocking. Computing pi to a trillion digits would usually not be considered blocking because it doesn't make any async OS API calls, even though it might cause long delays before it returns, but if the author chose to implement it as an async iterator then it could be considered blocking.

Worse, async vs not async is about latency requirements of the caller, but the function author can't know that. If you're in a UI thread doing 60fps animation maybe even reading a file would be too slow, but if you're in a script then it doesn't matter at all and you'd rather have the simpler code. You're calling the same function, the only thing that changed is the performance requirements of the user.

So "potential to block" is to some extent a makework problem that occurs in JS because V8 isn't thread safe, and V8 isn't thread safe because JS isn't, because it was designed for the web which is a kind of UI toolkit. Such toolkits are almost never thread safe because it's too much hassle to program a thread safe renderer and would make it too slow, so that browser-specific implementation constraint propagates all over the JS ecosystem.

Final major problem: some languages use async hype as an excuse to not properly support threads at all, but that's often the only way to fully exploit the capabilities of multicore hardware. If your language only does async you'll always be restricted to a single core and you have to hope your use case parallelizes embarrassingly well at the top level.

If you have threads then this whole problem just goes away. That's the core argument that Java makes with Project Loom. Instead of introducing colored functions everywhere with poorly defined and inconsistent rules over which color to use, just make threads work better. Suddenly the binary async/not async distinction becomes irrelevant.

Still, there's a good reason most languages don't do the Loom approach. It's a massive PITA to implement. The Java guys are doing it because of their heavy investment in advanced VMs, and because the Java ecosystem only rarely calls out to native code. It'd be a lot harder in an ecosystem that had less of a focus on "purity".


> The term blocking isn't well defined. How slow does an operation have to be to be considered blocking? There are no rules.

The rule is very simple IMO, I already described it: if other execution can run while you wait for the value, it's blocking. (Edit: to be specific, other observable execution, so in your thread with write access to state you can see)

> Worse, async vs not async is about latency requirements of the caller,

I disagree. To me, async vs not async is about interruptibility. As you already mentioned, the code that computes a trillion digits of Pi is async if its interruptible, and not async if it isn't. If you're in a UI thread doing 60fps animation, async vs not async isn't really all that relevant (barring the slight inherent overhead in calling an async function). What you need is a profiler to tell you where you have a non-interruptible block consuming your 16ms. The compiler can't know this a priori, unless you make significant restrictions to the type of code you write (no unbounded loops, for instance).

> Such toolkits are almost never thread safe because it's too much hassle to program a thread safe renderer and would make it too slow, so that browser-specific implementation constraint propagates all over the JS ecosystem.

From here on you seem to not be aware of the fantastic recent developments in WebWorkers, it is now quite easy to spin up worker threads in JS, which communicate with the main thread via message passing.


> The rule is very simple IMO, I already described it: if other execution can run while you wait for the value, it's blocking. (Edit: to be specific, other observable execution, so in your thread with write access to state you can see)

is gmtime() blocking ? Technically it needs to ask kernel to get the time so it has same potential to block as any other call

> As you already mentioned, the code that computes a trillion digits of Pi is async if its interruptible, and not async if it isn't.

Very bad definition as either

* every single code is because kernel can always reschedule thread to do something else * none of the compute code is if language runtime can't interrupt it (old versions of Go had that behaviour for tight loops IIRC) * every of compute code is if language can arbitrarily interrupt it.

> From here on you seem to not be aware of the fantastic recent developments in WebWorkers, it is now quite easy to spin up worker threads in JS, which communicate with the main thread via message passing.

That's entirely worse approach as sharing any data structures is impossible and where mutex would be enough you're now shoveling tons of data around. It's workaround at best

It's like saying "my language is multithreaded because we started many processess and communicate via redis"...


I'd assume you missed my edit if you hadn't directly quoted it :) The kernel interrupting the thread doesn't meet the criteria of "other execution in your thread with write access to state you can see".


Web workers aren't threads. Threading is where you have two parallel streams of execution that share mutable memory. If there's no shared memory then what you've got is more like the older term "multi-processing".

> if other execution can run while you wait for the value, it's blocking.

How does that not capture any operation with a callback, and why does this definition depend on being "in your thread"? I don't think this is a definition that's widely used or understood.

> async vs not async is about interruptibility

By this definition Java fully supports async functions, because you can call Thread.interrupt() on any thread at any time. But I think most people would say Java doesn't do what is commonly understood by "async" including the Java developers themselves.


Your definition isn't as universal as you'd like to believe:

> Web Workers makes it possible to run a script operation in a background thread separate from the main execution thread of a web application. The advantage of this is that laborious processing can be performed in a separate thread, allowing the main (usually the UI) thread to run without being blocked/slowed down.

(https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers...)

> How does that not capture any operation with a callback

You're right that my "blocking" definition is weak. I don't use the phrase in practice (It was introduced by the parent), preferring to think in terms of interruptibility [1]. The distinction I draw is between code that can assume no other code will interrupt it and much up it's view of global state, and that which cannot. The "in your thread" distinction is there because random kernel code or code from other applications (or code from other web workers) running doesn't muck up the thread-in-question's state. Those changes can only be observed across the boundaries of an `await` ("blocking") or in callbacks from separate events.

> I think most people would say Java doesn't do what is commonly understood by "async" including the Java developers themselves.

There's room in this world for multiple definitions. See above :)

[1] The question of "blocking" is a bit interesting, the execution thread wants to make a "blocking" call, but the runtime doesn't actually execute the syscall from that thread. It spins up a new thread to make the call, that thread gets blocked by the OS, and all the while the main execution thread continues running. Thus the call was "blocking" in some sense, but not to the thread in question, which was only "interrupted". The thread in question however is executing all sorts of other code while the assistant thread is blocked, so when the assistant thread returns and execution of that task resumes from being interrupted, it must ensure all the state it had observed beforehand is still valid.

This makes things confusing: the code which executes a trillion digits of Pi is blocking, but it is not interruptible. The code which reads a file or makes a network request via a syscall might be considered blocking, but it is interruptible.


Given the existence of mmap and NFS, arbitrary reads from memory can end up being network calls. I don't think it's possible to make those async without something like kernel scheduled entities / scheduler activations, which are now a long-forgotten dream.


Yep, or any pluggable file system. Operating systems and programming languages evolved on the assumption that it's OK for operations to take arbitrary amounts of time that you can't necessarily know in advance and which may change out from underneath you, with threads being the solution (run stuff in a background thread and keep the user informed on a foreground thread). Trying to make everything async in a single thread will constantly run into papercuts like the network fs scenario because the whole infrastructure just isn't designed for it.


I think you misunderstand my point slightly. Here’s the difference: in JS everything that needs to be async is actually marked async. So when you use a browser api or library function you know for certain. I agree I like that and want it for Rust. In Rust the standard lib is not async aware so it cant be used safely from async code, period. And tons of Rust code uses the stdlib and it’s not super practical to just not use it so you’re stuck asking whether something is safe to call from async code or not with zero help from the compiler. So we agree not having the compiler do this sucks.

(What I meant by partially skirting the colored function issue is that you don't have to call await on the result of a function if you dint want to. So you can call a blue function from a red function just fine and do stuff with the result. You just can’t await it. This adds back lots of flexibility that some async impls in other languages don’t allow for.)


> you can call a blue function from a red function just fine and do stuff with the result. You just can’t await it

By this do you mean you can choose between waiting for a value in a way that halts the entire rest of the runtime while it resolves and one that will attempt to perform other tasks while waiting for the resolution?


Essentially yes.

Any time you call an async function you get a Future. The future does not resolve until you await it. So you can call an async function from a non-async function normally and do things with the future. You just cant call future.await to yeild until the result is is ready unless you’re in an async context. You can ask the runtime to resolve the future if you want, but that’s a blocking call so it’s up to you to make sure it’s safe to block.


Oh, well that's exactly the same as JS. You can await it to get a value but you don't have to, you could instead fire-and-forget, batch it up over time and await them all later, or attach a function to run with the resolved value whenever it resolves while letting the current execution move along unhampered. If you don't want to `await` it, your function stays "red" or whatever.


The main difference between rust futures and JS promises is that rust futures don't get scheduled if you don't poll them (we high await does under the hood). Promises otoh get to run immediately and can run to completion even if you never touch the promise ever againt


You get the same with e.g. tokio::spawn, which runs the future concurrently and returns something that you can await and get the future's output. Or you can forget that something and the future will still run to completion.

Directly awaiting a future gives you more control, in a sense, as you can defer things until they're actually needed.


The point is that having to think about color of the function is disadvantage and needless complexity

> If I write a block of JS in an async context, I can be 100% guaranteed that the only times when any other task may interfere with my functions execution are explicitly marked with `await`. I love this!

You only need to think about it because runtime isn't parallel. If you want ordering in execution you should be using synchronization primitives anyway.

In Go doing "async" via channels could possibly start running the code the instant you instantiate it, on however cores there are available. Go have problem of that requiring a bunch of boilerplate (as there is no macros), but with generics now it is manageable and I can just say "here is function, run it on all elements of the slice in parallel" very easily and it all be pretty reasonabe, like "out = ParallelMapSlice(mapFunc,8,in)".

All done async without thinking about async.


> I fear writing async code every time I have to do it.

Devil's advocate: can you give an example of something complicated you ran into that wasn't "simply" solved by Arc<Mutex<T>>?


Everything in my example. Scratching my head about whether I can call this part of my code from an async function because it might do filesystem operations or make a blocking network request. If I can’t, jumping through hoops to spawn an async task that can. Oh, now everything has to be static. Oh god async block syntax. I run into this a lot working on an API server written in Rust. It’s part of a larger project that has a CLI and lib crates. So I’ve run into everything I mentioned from poor language support for expressing async traits, to mixing and matching async runtimes and calling conventions.


> Scratching my head about whether I can call this part of my code from an async function because it might do filesystem operations or make a blocking network request. If I can’t, jumping through hoops to spawn an async task that can.

If you have code that needs to wait on IO (regardless of how fast or slow it could be, the point is that it's not predictable), and want to call it from an async function, why not make that code an async function too?

If it's library code, than yeah I agree it can suck. I tend to wrap those in my own API instead of inline spawning. At least it localises the uglyness, and that approach tends to compose well for me in practice. It does require a bit more code though, but my business logic code and plumbing code end up being separated and as a result easie to maintain too.

> Oh, now everything has to be static.

I will grant you that the static lifetime is a bit confusing until it clicks, but the issue is often that people have a wrong intuition about what a static lifetime bound means because it's introduced a bit clumsily and glossed over in the book.

I agree that async blocks feel a bit "smelly" syntax wise (for lack of better word).

I feel like some of these are just async woes that aren't trivial to solve, but I'd be surprised if the language teams weren't aware of this already and working towards solutions for these things. Async is very much MVP-ish in Rust anyway


The core of the problem from my angle is that rust’s stdlib is not async safe. It’s literally incorrect to call parts of the Rust stdlib from async code and the language tells you nothing about this. It doesn't even say “this function blocks are you sure you want to call it?”. So yeah I’ve got a bunch of my own library code (and others’) that I wrote normally and then later wanted to call it from async code and it’s a big mess.

c.f. Swift’s async/await impl. Swift solved a lot of these woes because it ships a stdlib and runtime that supports async code.


> rust’s stdlib is not async safe. It’s literally incorrect to call parts of the Rust stdlib from async code and the language tells you nothing about this.

Can you get more specific about what goes wrong when you call certain parts of the standard lib from async code? This is the first I'm hearing about this, and I'm not fully clear on what you mean by "not async safe"


Not OP, but it won't yield to the runtime. This blocks the executing thread until the operation completes. That's the only sense in which the standard library isn't async safe.

Additionally, according to a core Tokio developer, the time between .await calls should be in the order of 100s of microseconds at most to ensure the best throughput of the runtime.


Isn't that a bit different from it not being safe, though? Ill-advised perhaps, but not necessary unsafe.


If you make too many of these calls your program will stop executing until I/O becomes available because you've exhausted your execution threads. If I/O never comes, which is very possible especially over a network connection, then you're stalled. Same outcome as being deadlocked, essentially.

I'd argue this is very much unsafe because it's not correct async programming and can cause really difficult to debug problems (which is the same reason we care about memory safety--technically I can access any part of the pages mapped to my process, it's just not advisable to make assumptions about the contents of regions that other parts of the program might be concurrently modifying).

The fact that people have never heard of this or don't understand the nuance kinda proves the point (=. You should, ideally, have to opt in to making blocking calls from async contexts. Right now it's all too easy to stumble into doing it and the consequences are gnarly in the edge cases, just like memory corruption.


Hmm. In the worst-case though, wouldn't it just be equivalent to a non-async version of the program? I.e. "blocking" is what every normal function already does. Blocking for IO might take longer, but again, that's what normal Rust programs already do. So to me it sounds like "not async-optimized" rather than "not async-safe"

But none of that sounds anything like a deadlock, so maybe I'm missing some other aspect here

As an aside: "safe" means a very specific thing in Rust contexts, so even if this is analogous to that (preventing a footgun that could have catastrophic consequences), it might be best to use a different word or at least disambiguate. Otherwise people will argue with you about it :)


Correct, there’s no actual problem here, just a potential performance pitfall, but that pitfall is no worse than a naive synchronous implementation would be.

There’s no big problem here, just write code and run it. If it is a really big problem it will show up in a profiler (if you care enough about performance to care about this issue, you are using a profiler, right… right?)


I guess you've never written async code. You can irreparably stall the entire program. I've done it. The person you're responding to is not correct in their analysis.


Can you explain such a case in detail? Genuinely curious to get to the bottom of this


I replied directly to you.


> In the worst-case though, wouldn't it just be equivalent to a non-async version of the program?

No. You can absolutely stall (I'm hesitant to use the phrase deadlock because you're not locking, specifically) an async program. More specifically, you can block all of the available scheduler threads. To see for yourself, spawn a bunch of async tasks in a loop that all increment and print a shared counter and then sleep forever. The counter will stop incrementing at some point. Compare that number to the number of scheduler threads your runtime uses. It should look similar.

I think maybe you're conceptually comparing a single threaded Rust program to an async program, which isn't quite accurate. A "normal" program in my experience has many threads. All of these threads can block. One of them blocking does not inherently slow down the rest of the program. Try the above example but spawn a new thread instead of a new async task during each loop iteration. The number will increment much higher, at least until you've exhausted some system limit on the number of threads a process can have.

Maybe there are fancy async runtimes that dynamically expand their threadpool as the number of blocked tasks increases. But naively they all use a threadpool sized at some multiple of the number of real cores your system has, and so will eventually stall if starved.

In any case, it's not generally async-safe to block any thread, ever (obviously if you could prove that the number of async tasks that block is always less than the number of threads the runtime uses, this doesn't hold, but that's a rather wild assumption). And it's impossible for say a library to know the context in which it's being used, so it's never correct for a library that is to be called in an async context to block. It must always yield.

You're right that in the common case things will just appear to slow down since most things don't block for a long time and eventually continue. But there are gnarly edge cases where the entire program stalls. Hence my comparison to memory safety. In the common case memory corruption and dangling pointers aren't horrible they just sometimes cause spurious errors which most programs can recover from. But every once in awhile they cause really bad problems which programs can't recover. Hence why we care.

For a real world example, imagine a server that processes requests. In the async handler block, for some requests you access the filesystem and the device starts experiencing degraded performance due to media errors and read calls never complete. Should the server stop processing healthchecks because some of the requests are having trouble? The answer is almost certainly no. Instead the server should report in its healthcheck that one of its block devices has an increased error rate and tasks are piling up.

Another case would be one involving locks. If you are dealing with a program that involves many workers/consumers waiting for a producer to produce a value and the workers use a blocking api to wait on a semaphore before reading a next value from memory and continuing, the program will almost certainly deadlock not because of a logic error leading to an imbalanced semaphore but instead because the async runtime will stall and the producer will never produce a value. If instead the tasks yield as they should, the program would progress normally.


> I think maybe you're conceptually comparing a single threaded Rust program to an async program

Ok yeah, I think this was the disconnect

> spawn a bunch of async tasks in a loop that all increment and print a shared counter and then sleep forever

My instinct is "duh, of course sleeping/looping forever will prevent progress". I can see how in a multithreaded program it might not (if it's written a certain way), but that feels like the exception, not a norm/intuitive assumption. Of course these things are highly subjective

In my view it's an exaggeration to call functions that might block for a long time "not async safe". Especially since it's in no way worse than the single-threaded case (are they also "not sync safe"?). I think for most people the blocking behavior will be obvious/intuitive, even if it has the potential to cause undesirable behavior (just like any program logic could)

It may be a matter of changing expectations- async programming is mainly about interlacing tasks on a thread, not multithreading; it's useful even when there's only one single thread available. It just so happens that you can also spread it across multiple threads, but I don't think it should be expected to live up to every expectation we might have around fully, manually multi-threaded logic


I edited my comment and added a few real world scenarios. In short, a "many consumers single producer" program where the consumers use a blocking API to lock on a semaphore/latch gating access to shared memory updated by the producer is bound to stall. The workers will all block waiting for the producer to produce a value but it never will because the executor is starved. This setup is 100% correct in a traditional program where each worker gets a thread. But it's 100% incorrect when using async/await. The async-safe variant of this setup is to use async-aware semaphore/latch APIs which yield instead of block.

I've seen this happen in non-contrived scenarios in the real world. It's not common, I'll give you that, but definitely not contrived.

Edit: I disagree about expectations. Async programs should absolutely work correctly in all scenarios that aren't programmer/logic errors. In my example(s) the solution is not "rewrite the program the program is wrong". The solution is "use the correct async-aware function at the call site and the issue goes away". The fact that Rust is being used to write web servers means that async/await is up to the task of handling real world highly parallel programs. You just have to be careful to avoid some nuanced traps, which the Rust compiler currently doesn't assist you with. It totally should!


Then I would ask, where does it end?

Consider a function that isn't doing IO, but it takes a long, potentially variable amount of time (i.e. it "blocks" as long as a file system request might block)

Is it even possible for that function to be "async-safe"? If you have it spin off a separate async task and yield to the original one, that's still filling up your thread pool. The only full solution then would be to manually create a separate thread outside of the async workers (until the OS hits the thread limit, of course)

This too is a little contrived, but I don't see a hard distinction between this and the IO case

At some point: the computer has finite resources, and those can get overloaded. There's something here around how async runtimes might not be making full use of the machine, which is an interesting thread (no pun intended) to pull on. But I don't think we can expect the language (at least as currently designed) to fully prevent this class of problem. And I also don't think the standard library should orient itself around async use-cases, which are still a minority of Rust uses, especially for the sake of an edge-case like this one


> Then I would ask, where does it end?

Slippery slope fallacy.

> If you have it spin off a separate async task and yield to the original one, that's still filling up your thread pool.

No it's not because the original task yielded so it's just sitting there waiting not taking up resources other than memory. It's not "filling up your thread pool".

> This too is a little contrived, but I don't see a hard distinction between this and the IO case

This is correct. A long running function might as well be blocking. The difference, generally, is that a long running function is presumably doing work whilst a function that blocks is waiting consuming compute resources literally doing nothing. The first case is generally considered acceptable because the function needs the processor/resources. The 2nd case is not because it's a waste. Surely you can see that distinction.

In fact, this is the whole reason async/await exists. It's first class syntax to express a scenario that shows up commonly in application machinery: thread pool executing tasks to avoid the massive numbers of blocked threads which traditionally happens in a thread model. And you should know that by definition async/await is not a good fit for tasks that take a long time to compute exactly for this reason we've been discussing. So much so that async runtimes provide an escape hatch for when your async task needs to do something that blocks the task execution threads: spawn_blocking[1].

As a programmer using async/await, you're supposed to know this to write correct async programs... just like you were supposed to know how to manage your pointers. Hardly any, especially those coming from JS, do. So the result is super sloppy and not very performant async code. And sadly people think it's the runtime's fault for not being good at scheduling or just not being performant or something. It's hilariously sad.

1: https://docs.rs/tokio/latest/tokio/task/fn.spawn_blocking.ht..., https://docs.rs/async-std/1.2.0/async_std/task/fn.spawn_bloc...

> At some point: the computer has finite resources, and those can get overloaded.

Yes, as is the case for any problem of computer engineering, one doesn't run code on an infinitely perfect machine. This isn't news.

> There's something here around how async runtimes might not be making full use of the machine, which is an interesting thread (no pun intended) to pull on.

If you didn't before when claiming that yielded tasks fill up a threadpool, here is where you betray your lack of experience on this topic. async/await (and generally the task/executor paradigm) is actually a solution put forth to maximize the use of available compute resources. That doesn't mean every runtime, or more likely the programer's application code, is good at doing that, but that's besides the point. The problem that tons of threads causes is that historically machine threads (as opposed to green threads like Go) require context switching. Context switching is expensive so if you let tons of them pile up you eventually spend more time context switching than actually computing, thus waste resources. In reality this is only a problem for incredibly highly parallel high throughput systems. Sometimes this means a web server.

> But I don't think we can expect the language (at least as currently designed) to fully prevent this class of problem.

Of course not. The language doesn't fully prevent all instances of memory un-safety either, but it gets pretty darn far.

> And I also don't think the standard library should orient itself around async use-cases, which are still a minority of Rust uses, especially for the sake of an edge-case like this one

Nobody is asking this. It's just not how this does or would work.

You're taking a pretty weak position. Just because we can't make something perfect in all cases is not justification for not improving it in most cases. If it were, we'd not have put any effort towards making a memory safe language in the first place and Rust wouldn't exist (contrary to what you might think, Rust programs don't prevent all memory safety issues). It is entirely possible to inadvertently stall your async program under normal usage and you don't need to delve into endlessly computing tasks to do so. As I've explained this can happen almost comically easily as soon as you introduce any type of waiting e.g. semaphores, sockets that a client keeps open, etc. which are all vastly more common than endless computation and entirely normal.

There are two classes of problems here: (1) programmer errors by using the incorrect blocking version of a call, and (2) trying to run super compute heavy workloads. I'm arguing that steps be taken to vastly reduce the possibility of (1) despite the fact that (2) can still happen. Who cares about (2) if we all but eliminate (1)? (Or, once we do we can take a shot at addressing (2).) And the solution is not black magic or something and it actually exists in some other async/await languages. This isn't fiction. It's 100% possible to mark blocking calls and have the complier tell async callers that this function needs special care, such as use of spawn_blocking, if you want to call it from async code. Let the caller disable it with an attribute if they want to shoot themselves in the foot who cares. Just make it better in the general case of "users don't know that they shouldn't call this blocking thing in an async task". For "normal" Rust usage absolutely nothing changes at all.

But even if it cost semantics, Rust's entire shtick is that it helps users write correct programs at the cost of more annoying semantics. I'm not arguing for polluting the stdlib to "orient it around async use cases". I'm arguing to add semantics so that it works normally in normal use cases and prevents unsuspecting users from shooting themselves in the foot when used in async use cases. async/await is not some "minority use case". It's been in Rust for over 4 years now and is remarkably common in my day to day usage of Rust.


> In my view it's an exaggeration to call functions that might block for a long time "not async safe".

"Not async safe" is a loaded term here that means "not complying with requirements of being a good citizen in the entire forest of async workers". As another poster said: it's expected most workers to return in 0.1 - 1.0 ms. After that the guarantees of async start to fall apart.

It's the same with Erlang's BEAM btw: it has an amazing scheduling primitives and you can literally spawn 50k green threads and you'd barely see a blip of lag anywhere BUT that's because the scheduler is preemptive and aggressively switches off a worker if it hasn't yielded in a while (this is not 100% the truth, I am simplifying it here). But if you start calling native functions that don't respond in 1ms or less, the scheduler is starting to struggle and you do start seeing lags that you normally never would.

So yeah, "not async safe" here means more or less "not being a good citizen".

No abstraction is free. Until we have something like the BEAM on a hardware level and anything and everything is preempted without you ever having a say on it, then such async runtime if-s and but-s will keep existing. It's a fundamental limitation of our hardware and OS-es.


Just to be crystal clear: you can deadlock an otherwise logically sound async program by using a blocking lock instead of a yielding lock.

I’d call that an unforced programmer error.


Would a normal async task block indefinitely like that though? Under non-contrived circumstances, I can see the scheduler becoming starved of threads where all the tasks are blocked, but presumably they will stop being blocked eventually, wouldn't they?


See sibling.


Probably any IO heavy workload, which usually requires async if it should be performant (like in all other programming languages too).


Rust's "fearless concurrency" always referred to traditional thread-based concurrency, which works very well for 99% of usecases. Async is something that was bolted on to the language long afterwards by folks coming from the JS/Node community who didn't understand threads or the value of fearless concurrency.


There is something I don’t understand. socket recv() is blocking by default. If blocked in recv() there is no safe way to exit the thread. One can set a timeout, but I can’t imagine this works well with lots of threads with a recv() on each thread. One can use select() which has mostly the same problems, either block potentially forever or use a timeout. Although one can create an additional 2 sockets, connect them together and put one of them in the select read set to allow controlled unblocking, but this doesn’t feel like a great solution, so I have always used async for socket code, because I just don’t know how it’s possible to use sync code. I have read you can call close(fd) and get recv(fd) to return, but I also hear this is not safe.


  fn f<T: Into<MyType>>(t: T) -> MyType { t.into() }
Picturing a group of nuclear-winter survivors trying to restart a coal plant. Found the control room. Inside, a working diesel generator, a computer and a Rust manual.


The binary would most likely work correctly so they could restart the coal plant, and read the Rust manual later to understand the source code.


I agree my fiction is leaky, but it sounds like a nice prompt.


And who wouldn't want to run a binary that compiles to some unknown effect after a devastating apocalypse?


Are you just being snarky or are you actually assuming that is code you'd write?


Being gently snarky. The code is from the article and frankly looks an irradiated still-born.


I find Rust way more readable than the similarly powerful C++.

  fn f<T: Into<MyType>>(t: T) -> MyType { t.into() }
We've got an introducer keyword 'fn'. We're defining a fn function named f. Cool, in C++ I might have gotten a long way before I found out what the function's name was. Hopefully this is an example name, obviously don't really just name your functions "f"

<T: Into<MyType>> means there's a type parameter here, our f function can work with different types, T, however, it also constrains the type parameter by saying it must implement a Trait, named Into<MyType>. In Rust we're allowed to also write these type constraints separately, and if they're complicated it's bad style to put them here, but this constraint was very simple.

(t: T) means the f function takes a single parameter, named t (again, please give things better names in real life people) of type T, that T is the one we just met, it's some type which implements Into<MyType>

-> MyType means the function returns a value of MyType.

{ t.into() } is the entire function definition.

This function just calls the method into() on the parameter t. How does it know it can call into() ? If you're a C++ programmer you might assume it's just duck typed, C++ just does text replacement here which is how you get those horrible multi-page scrolling template errors, but nope, in Rust it knew because the thing we know about t is that its type is T, and the only thing we know about T is that it implements Into<MyType> which is a trait with exactly this into() method defined, where the return type of that into() method will be MyType. We actually didn't know how to do almost anything else with t except call into()


I wasn't expecting this. Thank you very much.

Beyond my snark, I recognize it's no easy task to allow these abstractions into a friendly syntax.


Thank you for this!


This is a very nice article! And a very nicely designed blog. I’ve run into almost all of these issues myself. Only in Rust have I had to respond to someone’s PR feedback with “yes, that would be better but the compiler won’t let me for xyz reasons”.

I do wonder if someone could design a language with a little more built in logic that would be able to know that an Arc won’t be moved, or that can understand String can be matched to a str, if that could function as the often requested “higher level Rust”


> or that can understand String can be matched to a str

That's called "deref patterns" and it's been made to work on nightly only for String for now, but 1) the implementation is currently a huge hack, 2) development stalled over the holidays but there's multiple interested parties to see this implemented and stabilized and 3) the idea is to make these "just work" (without any extra sigils or keywords) for specific Deref impls, maybe with a new marker trait DerefPure, which might or might not be available for end users to (similar to trait Try). DerefMut is IIRC still an open question.


There are a few crates that abstract the unsafe setup you need for a self referential struct, but I'm not sure which or their status. And they might employ "Pin" for their abstraction, I'm not sure. Pin is basically a wrapper type to keep an object from being moved. The interesting thing is that Pin doesn't receive special compiler treatment for its implementation. It's implemented in normal rust (IIRC).


That language is probably Swift.


Nice article.

- The "functional abstraction" issues could some day be adressed with view types: https://smallcultfollowing.com/babysteps//blog/2021/11/05/vi...

- "Newtype abstraction" can be made nicer with third-party crates, or maybe in the future with project safe transmute

- "Views and bundles" I run into this one all the time, it's annoying

- "Deref patterns" could allow pattern matching to see through boxes one day, but there are unresolved design issues


> We know that moving `Arc<Db>` will not change the location of the `Db` object, but there is no way to communicate this information to rustc.

There is! `std::pin`[0] does just this (and `Arc<T>` even implements `Unpin`, so the overall struct can be `Unpin` if everything else is `Unpin`). The page even includes a self-referential struct example, though it does use `unsafe`.

[0]: https://doc.rust-lang.org/std/pin/index.html


I believe there is a group working on "safe transmute", which would address the newtype issues... safely. Not just memory safety, but invariant safe, to prevent transmutes in safe rust when the newtype wrapper wants to protect its own invariants.


This sounds really interesting (and also eerily similar to haskell's Coercible and roles), is there any material on the matter somewhere? I'd love to see what solution they're thinking of working towards, seeing as this is a pain point that I encounter somewhat frequently.


RFC PR: https://github.com/rust-lang/rfcs/pull/2981

RFC text: https://github.com/jswrenn/rfcs/blob/safer-transmute/text/00...

I don't know what the status quo is, but it is very exciting work.

There are also crates in this domain that work today. The two I'm aware of are https://docs.rs/zerocopy/latest/zerocopy/ and https://docs.rs/bytemuck/latest/bytemuck/


Haskell also solves data races and use-after-free (with immutability and GC), so it's not surprising the author isn't impressed by Rust's fearless concurrency. But debugging deadlocks in threaded code is a piece of cake compared to the hell of data races (attach debugger, and stack traces will show what is deadlocking vs races happening randomly, often impossible to reproduce in a debugger).


For intro and info about Software Transactional Memory which allows real fearless concurrency:

"Beyond Locks: Software Transactional Memory" by Bartosz Milewski

https://bartoszmilewski.com/2010/09/11/beyond-locks-software...

I wonder how far it can be implemented in Rust? There are people trying:

https://github.com/aakoshh/async-stm-rs (updated just days ago)


I have been hurt by issues similar to inc(&mut self) in the "Functional abstraction" section so many times. I have been considering using macros as a workaround, but so far I have just written the same code multiple times. DRY isn't easy in Rust.


Me too. You can make the references at the caller and pass them individually to the function, like inc(&mut self.x), but that's also not very nice. It's weird how this is at odds with the purpose of grouping variables into a logical struct in the first place.


This is a quite nice article. Do not stop at the first example that shows something passed by value that would probably better be passed by reference instead of always cloning.

I really like how the author used green/red backgrounds to show code that doesn't compile.


Some of these are more "When a linear type system hurts:"

> Common expression elimination

I feel as though there should be a warning when a parameter that could be a reference is not. I usually run into this when a function that I've written is incorrectly taking ownership of a parameter.

> Newtype abstraction

The Rust compiler could implicitly call `into` and `inner` (and then possibly optimize those away), but this (explicit or implicit) is required for linear types. What could work is: `Hex(&bytes)`.


I think this article is missing a mention of #[repr(transparent)], which is supposed to help bridge the problem of orphan rules, etc. I'm not sure why there isn't a guarantee that Rust will treat a wrapped type with a single field in the same way as a naked type, but I suspect it's because they Just Haven't Gotten To It Yet.

https://www.reddit.com/r/rust/comments/kbyb6z/what_is_the_pu...

Also, the "reference" issue described in the article later is painful, but can be solved w/the Yoke or Rental crates right now. I hope those make it into the stdlib, however.


> I'm not sure why there isn't a guarantee that Rust will treat a wrapped type with a single field in the same way as a naked type, but I suspect it's because they Just Haven't Gotten To It Yet.

What do you mean by "treat it in the same way" ?

#[repr(transparent)] does promise you something with identical layout and alignment. If whatever is in there is actually the same kind of thing then the transmute will work, but the problem is - what if it's not?

Take nook::BalancedI8 and std::num::NonZeroI8 these both have the same representation as Rust's i8 built-in type, they're one byte signed integers. But, wait a minute Option<BalancedI8> and Option<NonZeroI8> also have the same representation.

Now, if that single byte is the value 9, we're fine, all five of these types agree that's just 9, no problem. Same for -106 or 35 or most other values.

But, for 0 it's a problem. i8, BalancedI8 and Option<BalancedI8> all agree that's zero. However NonZeroI8 can't conceive of this value, we've induced Undefined Behaviour, and Option<NonZeroI8> says this is None.

Similarly at -128, i8, NonZeroI8 and Option<NonZeroI8> all agree that's -128, their minimum possible value. However the minimum value of BalancedI8 is -127 so this representation is Undefined Behaviour, and in Option<BalanacedI8> that's None.


And this is why Python, Java and Javascript rule modern day programming landscape.

Those languages let you just do the frickin' work TM.


If Python or Javascript are even potential candidates for what you should build something in, it’s questionable whether you should use Rust in the first place.


What's the idiomatic way to move the self.x+=1 into a separate function? (From the "Function Abstraction" example)


Construct references to the fields of self inc needs at the call site and pass those instead of all of self, eg. inc(&mut self.x).


I've found success in such cases by splitting the struct in question. This example is a bit contrived, but you could do something like:

    self.counter.inc();
Where `self.counter` is a new struct that holds `x`. Because `counter` is its own struct field, I can call mutable methods on it while holding mutable references to other fields.

What I've found is that often the current "object" naturally decomposes into different parts each of which can have their own methods that only need to work with their particular subset of the fields.



The closest thing to fearless concurrency is the Unix os shell.

Are you afraid of grep asdf 1.txt | gzip ? Probably not, you probably don’t even noticed this is a parallel execution.


I only wrote a short "helloworld.rs" in Rust, but it looks way more complex than any other language I have tried. Which is a shame because I really like the idea of safe(r) C. Is there really no simpler way to achieve memory safety without GC? I'm not talking about the borrower, but the whole language... In other words, is the complexity of Rust a consequence of memory handling or of the design decisions?


Are you doing more than this?

   pub fn main() {
       println!("Hello, world!");
   }
If you're hitting the borrow checker and you haven't found yourself at that point because a profiler or benchmark suggested it, you might just be trying to access too much of Rust's power for what you need.

You can always just throw everything inside of Rc<RefCell<...>> if you're using a single thread, or Arc<Mutex<...>>/Arc<RwLock<...>> if you're doing multi-threading. It looks expensive, but what you end up with is approx. the same accounting you find in higher-level languages, but the same guarantees.


Isn’t it kinda a catch-22 with Arc? That’s considered a form of garbage collection.

It’s like, if you can use Arc than you can use garbage collection, in which case why battle the borrow checker and just use Elixir or something which better guarantees and an easier time.


I have mainly read about rust, not worked with it, but the section about:

    let x = compute_x();
    f(x.clone());
    g(x);
was confusing/surprising to me. Isn't the whole point of default-immutable that you can use x without owning a copy? Forgive me if this is a stupid question.


I'm no Rust expert, but it would be ok if you would do borrows there.

  let x = compute_x();
  f(&x);
  g(&x);
Otherwise you have moves there. Now it depends on the API, it may want the value and not the reference (a borrow).


I see. Is it not common practice for functions to expect references when they can (i.e. when they don't need to modify the argument)?


Not just when they don't need to modify the argument, when they don't need to consume it. The way these parameters are written takes ownership of x, it's gone, not just modified.

For a non-trivial object (which apparently x is), you would always prefer to write &x (immutable reference to x) or at least &mut x (mutable reference, not applicable here because x is not defined as a mutable thing).

So apparently these functions intend to consume x. That's not common but there are a reasonable number of Rust APIs which consume larger things on purpose.


> So apparently these functions intend to consume x.

I guess in that case I don't really get the complaint. If there's a good reason for f or g to consume x, I would think I would always want the copy to be explicit. But I could still be missing some subtlety I guess.


Rust can be like this, because it tends to expose a lot of information, and not everyone cares about every type of information.


The common scenario for f and g to consume x is that x is a smart pointer type e.g. Rc/Arc.

to make it clear that the clone is "cheap", some Rust developer prefer using `Arc::Clone(&x)` over `x.clone()`.


Yeah, it feels reasonable to me, but I'm a happy Rust programmer, perhaps if you're mostly working in functional languages this feels very wrong, I would not know.


Let's consider a couple of scenarios since we don't know x's type here presumably on purpose

1. Suppose x is a Teapot, some complicated object with lots of internal state - it has about two cups of Early Grey in it, and is a lovely blue and white pattern.

If we call f(x) then we're giving our Teapot, x, to this f() function. It apparently doesn't give anything back, so the Teapot is gone now! In Rust we say x was moved into f.

Maybe f empties the tea out of the teapot, then smashes it and uses the broken pieces to fill a pothole in the road. Weird.

So we use the clone() call to ask for an exact duplicate of the Teapot, now f(x.clone()) only gives the clone to this function f, our original Teapot remains unaltered in our possession.

Because we've still got x, we can call g() which apparently like f also wants the actual Teapot, maybe this function plants a cactus in it and puts it on a high shelf. Once again our Teapot, x, is gone because we gave it to g().

Now, most Rust functions on complicated objects like a Teapot (or more realistically, a Vec or a String or File) don't move parameters like this, they want either a mutable reference which you'd write &mut x or an immutable reference, which is written &x. If these functions f() and g() only needed immutable references, our Teapot x is fine. We can't use mutable references because x itself is not labelled as mutable, if it was, the Teapot might be altered, but it's still a Teapot, the functions don't own it, they were just given a reference and so they aren't allowed to destroy it for example.

OK 2. Suppose x is just a 32-bit unsigned integer, u32 in Rust

Now things are different, because the u32 type implements Copy, a trait which tells the Rust compiler, "My value is literally the value of the bytes in RAM, if you copy those bytes, that's all I am, that's a copy of me".

So now even though f(x) moves x into f, the compiler knows x is just this 4 byte value, it can give f the same 4 byte value and keep the one in x, and everybody is happy. f() can do whatever it wants with that value, x is unaffected. We can call x.clone() if we want, but it just makes another identical 4 byte value, kinda pointless.

Then g(x) does the same, even if it wants to "move" the parameter, the compiler just gives it a copy anyway, no harm done.

Does this help?


It all makes sense, sure. It's not that I don't understand why rust would distinguish these things. The question is really, do people writing rust code not mostly default to passing the immutable reference when that's an option (i.e. when the function doesn't need to modify the input). And why not? (Edit: just saw your other comment on the other thread, will respond there)


Among things mentioned in the article, pattern matching of strings is already available in the nightly.


Found a soul mate regarding my point of view on fearless concurrency.


Also, leaking memory with reference cycles from shared ownership


This seems to be a problem that even Swift can’t solve. The only languages that avoid this issue are ones with active GCs. Not that I don't agree and wouldn't love to see efforts made here.


It'd be possible to add an active GC to Rust. There are some library attempts at that:

- https://lib.rs/crates/gc

- https://github.com/withoutboats/shifgrethor

But they don't look particularly usable.

Beyond that, yeah, it sucks.


There are local cycle collection algorithms, but Rust doesn't expose enough metadata to write them in Rust itself. They're also not cheap to run.


The part about the filesystem is completely ironic, because the C++ version of the same API uses no pointers at all.


I think you misunderstood the article. That section is not talking about actual pointers. Filenames are like pointers, opening a file is like dereferencing the filename to get the actual file. Any other process could "free your pointer" (by deleting the file) at any point in time. That's true no matter which language you use.


>Any other process could "free your pointer" (by deleting the file) at any point in time.

Files are garbage collected. There is no use after free if someone deletes your file because the OS sees that you are still using it


Yeah, common confusion to be out of free space, rm some huge file, and still be out of space because something has the file open.


There's a TOCTTOU going from the filename to the file handle, but once you have a handle,

- on Windows, another process deleting the file is prevented (unless you explicitly opted-in to cooperative FILE_SHARE_DELETE); and

- on Linux, another process deleting the file only unlinks its name, and your handle continues working with the nameless file and its content




Guidelines | FAQ | Lists | API | Security | Legal | Apply to YC | Contact

Search: