Skip to content

Commit 85e849c

Browse files
doc: add new blog about concurrency
1 parent 84300be commit 85e849c

File tree

2 files changed

+170
-0
lines changed

2 files changed

+170
-0
lines changed

website/blog/fearless-concurrency.md

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
---
2+
author:
3+
- name: Herrington Darkholme
4+
date: 2025-01-01
5+
head:
6+
- - meta
7+
- property: og:type
8+
content: website
9+
- - meta
10+
- property: og:title
11+
content: An Example of Rust's Fearless Concurrency
12+
- - meta
13+
- property: og:url
14+
content: https://ast-grep.github.io/blog/fearless-concurrency.html
15+
- - meta
16+
- property: og:description
17+
content: ast-grep shows how Rust's fearless concurrency works in practice. Learn how to design concurrent systems in Rust and the trade-offs involved.
18+
- - meta
19+
- property: og:image
20+
content: https://ast-grep.github.io/image/blog/concurrent.jpg
21+
---
22+
23+
# An Example of Rust's Fearless Concurrency
24+
25+
Rust is famous for its "fearless concurrency." It's a bold claim, but what does it actually *mean*? How does Rust let you write concurrent code without constantly battling race conditions? [ast-grep](https://ast-grep.github.io/)'s [recent refactor](https://github.com/ast-grep/ast-grep/discussions/1710) is a great example of Rust's concurrency model in action.
26+
27+
## Old Architecture of ast-grep's Printer
28+
29+
`ast-grep` is basically a syntax-aware `grep` that understands code. It lets you search for specific patterns within files in a directory. To make things fast, it uses multiple worker threads to churn through files simultaneously. The results then need to be printed to the console, and that's where our concurrency story begins.
30+
31+
Initially, ast-grep had a single `Printer` object, shared by *all* worker threads. This was designed for maximum parallelism – print the results as soon as you find them! Therefore, the `Printer` had to be thread-safe, meaning it had to implement the `Send + Sync` traits in Rust. These traits are like stamps of approval, saying "this type is safe to move between threads (`Send`) and share between threads (`Sync`)."
32+
33+
```rust
34+
trait Printer: Send + Sync {
35+
fn print(&self, result: ...);
36+
}
37+
38+
// demo Printer implementation
39+
struct StdoutPrinter {
40+
// output is shared between threads
41+
output: Mutex<Stdout>,
42+
}
43+
impl Printer for StdoutPrinter {
44+
fn print(&self, result: ...) {
45+
// lock the output to print
46+
let stdout = self.output.lock().unwrap();
47+
writeln!(stdout, "{}", result).unwrap();
48+
}
49+
}
50+
```
51+
52+
And `Printer` would be used in worker threads like this:
53+
54+
```rust
55+
// in the worker thread
56+
struct Worker<P: Printer> {
57+
// printer is shareable between threads
58+
// because it implements Send + Sync
59+
printer: P,
60+
}
61+
impl<P> Worker<P> {
62+
fn search(&self, file: &File) {
63+
let results = self.search_in_file(file);
64+
self.printer.print(results);
65+
}
66+
// other methods not using printer...
67+
}
68+
```
69+
70+
While this got results quickly, it wasn't ideal from a user experience perspective. Search results were printed all over the place, not grouped by file, and often out of order. Not exactly user-friendly.
71+
72+
## Migrate to Message-Passing Model
73+
74+
The architecture needed a shift. Instead of sharing a printer, we moved to a message-passing model, using an [`mpsc` channel](https://doc.rust-lang.org/std/sync/mpsc/). `mpsc` stands for Multi-Producer, Single-Consumer FIFO queue, where a `Sender` is used to send data to a `Receiver`.
75+
76+
Now, worker threads would send search results to a single dedicated *printer thread*. This printer thread then handles the printing sequentially and neatly.
77+
78+
Here's the magic: because the printer is no longer shared between threads, we could remove the `Send + Sync` constraint! No more complex locking mechanisms! The printer could be a simple struct with a mutable reference to the standard output.
79+
80+
81+
![concurrent programming bell curve](/image/blog/concurrent.jpg)
82+
83+
84+
Here are some more concrete changes we made:
85+
86+
### Remove Generics
87+
88+
The printer used to be a field of `Worker`. Now, we had to move it out to the main thread.
89+
90+
```rust
91+
struct Worker {
92+
sender: Sender<...>,
93+
}
94+
95+
impl Worker {
96+
fn search(&self, file: &File) {
97+
let results = self.search_in_file(file);
98+
self.sender.send(results).unwrap();
99+
}
100+
// other methods, no generic used
101+
}
102+
103+
fn main() {
104+
let (sender, receiver) = mpsc::channel();
105+
let mut printer = StdoutPrinter::new();
106+
let printer_thread = thread::spawn(move || {
107+
for result in receiver {
108+
printer.print(result);
109+
}
110+
});
111+
// spawn worker threads
112+
}
113+
```
114+
115+
So, what did we gain? **Smaller binary size**.
116+
117+
Previously, the worker struct was generic over the printer trait, which meant that the compiler had to generate code for each printer implementation. This resulted in a larger binary size. By removing generics over the printer trait, the worker struct no longer needs multiple copies.
118+
119+
### Remove `Send + Sync` Bounds
120+
121+
The `Send + Sync` bounds on the printer trait were no longer needed. The CLI changed the printer signature to use a mutable reference instead of an immutable reference.
122+
123+
In the previous version, we couldn't use `&mut self` because it cannot be shared between threads. So we had to use `&self` and wrap the output in a `Mutex`. Now we can simply use a mutable reference since it is no longer shared between threads.
124+
125+
```rust
126+
trait Printer {
127+
fn print(&mut self, result: ...);
128+
}
129+
// stdout printer implementation
130+
struct StdoutPrinter {
131+
output: Stdout, // no more Mutex
132+
}
133+
impl Printer for StdoutPrinter {
134+
fn print(&mut self, result: ...) {
135+
writeln!(self.output, "{}", result).unwrap();
136+
}
137+
}
138+
```
139+
140+
Without the need to lock the printer object, the code became **faster** in a single thread, without data-racing.
141+
142+
143+
Thanks to Rust, this big architectural change was relatively painless. The compiler caught all the places where we were trying to share the printer between threads. It forced us to think about the design and make the necessary changes.
144+
145+
## What Rust Teaches Us
146+
147+
148+
This experience with `ast-grep` really highlights Rust's approach to concurrency. Rust forces you to _think deeply_ about your design and _encode_ it in the type system.
149+
150+
You can't just haphazardly add threads and hope it works. Without clearly **designing the process architecture upfront**, you will soon find yourself trapped in a maze of the compiler's error messages.
151+
152+
Rust then forces you to express the concurrency design in code via **type system enforcement**.
153+
You need to use concurrency primitives, ownership rules, borrowing, and the `Send`/`Sync` traits to encode your design constraints. The compiler acts like a strict project manager, not allowing you to ship code if it doesn't meet the concurrency requirements.
154+
155+
In other languages, concurrency is often treated as an afterthought. It is up to the programmer's discretion to design the architecture correctly. And it is also the programmer's responsibility to conscientiously and meticulously ensure the architecture is correctly implemented.
156+
157+
## The Trade-off of Fearless Concurrency
158+
159+
[And what, Rust, must we give in return?](https://knowyourmeme.com/memes/guldan-offer) Rust's approach comes with a trade-off:
160+
161+
* **Upfront design investment:** You need to design your architecture thoroughly before you start writing actual production code. While the compiler could be helpful when you explore options or ambiguous design ideas, it can also be a hindrance when you need to iterate quickly.
162+
* **Refactoring can be hard:** If you need to change your architectural design, it can be an invasive change across your codebase, because you need to change the type signatures, the concurrency primitives, and data flows. Other languages might be more flexible in this regard.
163+
164+
Rust feels a bit like a mini theorem prover, like [Lean](https://lean-lang.org/). You are using the compiler to prove that your concurrent model is correct and safe.
165+
166+
If you are still figuring out your product market fit and need rapid iteration, other languages might be [a better choice](https://x.com/charliermarsh/status/1867927883421032763). But if you need the safety and performance that Rust provides, it is definitely worth the effort!
167+
168+
## The Fun to Play with Rust
169+
170+
ast-grep is a hobby project. Even though it might be a bit more work to get started, this small project shows that building concurrent applications in Rust can be [fun and rewarding](https://x.com/charliermarsh/status/1873402334967173228). I hope this gave you a glimpse into Rust's fearless concurrency and maybe inspires you to take the plunge!
58.9 KB
Loading

0 commit comments

Comments
 (0)