I'm sorry, but you still have to think
本文为原文前 6,000 字符的节选翻译,完整内容请查看原文。
Ruby on Rails 框架的创建者 DHH 决定将 Campfire Once 从 Ruby on Rails 重写为 Rust。或者更准确地说,是让一个 AI 代理(clanker)代劳,因为他自己无法忍受阅读或编写 Rust 代码。此后,他又增加了使用 Elixir 和 Go 等其他语言的重写版本。
这对我来说非常有趣,因为它很好地洞察了那些在不阅读代码的情况下使用 AI 代理的人所得到的结果。在这种情况下,此人还拥有丰富的编程经验。结果是……好吧,如果你希望很快就能停止阅读代码或完全停止思考,那么这些结果并不令人鼓舞。这些重写版本存在多个问题,但让我们先从非功能性差异开始,因为它们很好地展示了许多人似乎没有意识到的事实:如果你的提示词不够具体,许多决策就如同抛硬币。这是因为大量问题并没有唯一的正确答案。我们想要保持向后兼容性吗?我们更关心延迟还是吞吐量?系统在负载下可以使用多少内存?崩溃后丢失新内容通知是可以接受的吗?你可能不在乎这些问题,或者至少不在乎其中一些,但当大语言模型(LLM)实现你自认为想要的东西时,这些问题会被隐式地回答。
If you look into the rewrites closer, you will quickly see that they treat backwards compatibility and other constraints differently. The Rust version doesn’t maintain 100% backwards compatibility, for example it drops the CSRF token so that caching is easier. It also dropped Redis in favour of in-process queues, for example for notifications. Elixir rewrite is much closer to the Rails original. This already makes the comparison pretty much useless, as these differences are language independent. It’s not like a clanker heard “rewrite in Elixir” and chose to keep 100% backwards compatibility because of the language that has been used. But it gets even better!
如果你仔细观察这些重写版本,会很快发现它们对向后兼容性和其他约束的处理方式不同。例如,Rust 版本没有保持 100% 的向后兼容性,它放弃了 CSRF 令牌以简化缓存。它还放弃了 Redis,转而使用进程内队列来处理通知等功能。Elixir 的重写版本则更接近 Rails 原版。这使得比较变得毫无意义,因为这些差异与语言本身无关。并不是说 AI 代理听到了“用 Elixir 重写”就因为所使用的语言而选择了保持 100% 的向后兼容性。但情况甚至变得更糟了!
If you looked at the code, and yeah, I know, we should not be reading code anymore, you would quickly notice a lot of the things are not great. For example, I’ve seen people complaining that Elixir’s version got a single process sequentially processing all of the SQL queries, even if these were reads that could be running concurrently. That’s a fair complaint, but DHH seems to think it’s not the best look for Elixir that the clanker couldn’t write performant code. Not sure if I agree when I look at the Rust version. Cause in the Rust version some of the database operations are not async, which in some cases may be worse. You see, in Rust, when using an async runtime, the scheduling is not preemptive, but rather cooperative. If a task doesn’t yield, no other task can run on the same worker thread. What this means in practice, is that the time spent in the task should be as short as possible. For example, when you run an SQL query that takes 100ms, you don’t want all the other tasks to wait as the task issuing the query is waiting anyway. Thus, you should ideally use an async I/O operation that yields to the runtime while waiting for a response from the database. In the Rust rewrite some queries run on worker threads, but some run as blocking operations in async tasks. Same with locks. When using an async runtime, the safest bet is to use async locks like tokio::sync::Mutex. It’s fine to use a non-async version if you’re sure that the lock is held for a very short time, but if you hold a sync lock for 10ms, all the tasks on the same thread wait for it, while the blocking task itself is not doing any work. Thus, I regret to inform you, that no, coding is not likely “solved”, and you still need to know what you’re doing.
如果你查看了代码——是的,我知道,我们现在应该不再阅读代码了——你会很快发现很多地方并不理想。例如,我看到有人抱怨 Elixir 版本使用单个进程按顺序处理所有 SQL 查询,即使这些读取操作本可以并发运行。这是一个合理的抱怨,但 DHH 似乎认为 AI 代理无法编写高性能代码对 Elixir 来说不是什么好形象。当我看到 Rust 版本时,我不确定是否同意这一点。因为在 Rust 版本中,一些数据库操作不是异步的,这在某些情况下可能更糟。你看,在 Rust 中使用异步运行时,调度不是抢占式的,而是协作式的。如果一个任务不主动让出控制权,同一工作线程上的其他任务就无法运行。在实践中,这意味着任务花费的时间应尽可能短。例如,当你运行一个耗时 100 毫秒的 SQL 查询时,你不希望所有其他任务都在等待,因为发出查询的任务本身也在等待。因此,理想情况下,你应该使用异步 I/O 操作,在等待数据库响应时让出控制权给运行时。在 Rust 重写版本中,一些查询在工作线程上运行,但有些作为异步任务中的阻塞操作运行。锁也是如此。使用异步运行时,最稳妥的做法是使用像 tokio::sync::Mutex 这样的异步锁。如果你确定锁持有的时间非常短,使用非异步版本是可以的,但如果你持有一个同步锁 10 毫秒,同一线程上的所有任务都会等待它,而阻塞任务本身却什么也没做。因此,我很遗憾地通知你,不,编程不太可能被“解决”了,你仍然需要知道自己在做什么。
Going further, it turned out that slop benchmarks are also, well, slop, as they only measure throughput, disregarding other properties of the system. For example, Zach Daniels measured new post notifications delivery rate under load, and discovered a 1% successful delivery rate in the Rust version under heavy load. Not a great look. Benchmarks are hard. But even the improved benchmark may not be actually testing what you want to test, depending on the characteristics of your system. You see, when you test a system you may want to emphasize different properties under load. Both DHH’s and Zach’s benchmarks were closed-loop benchmarks, so they were testing “how many requests can the system handle in a specific amount of time?”. The test was using N clients, each sending a new message as soon as it gets a response for the previous one. In many cases, though, the increased load on the system may come from a big number of users performing an action at the same time, who will not wait for other users to finish their actions. In this case you would prefer constant arrival rate, ie. you want to send requests at a constant rate rather than making the rate depend on how fast the system can respond. If you want to check how reliable the events delivery is, you would ideally want to compare the same number of events. Here, if you look at the results, it shows that Rust delivered 1% of notifications out of 6-7k, whereas Elixir delivered 100% of notifications out of ~1.7k. What it shows is that the Elixir version handles backpressure better, but this only holds as long as the number of requests don’t overload the system.
更进一步说,事实证明这些草率的基准测试也确实很“草率”,因为它们只测量吞吐量,而忽略了系统的其他属性。例如,Zach Daniels 测量了负载下新帖子通知的送达率,发现 Rust 版本在高负载下的成功送达率仅为 1%。这看起来不太好。基准测试很难。但即使是改进后的基准测试,也可能无法真正测试你想要测试的内容,这取决于你系统的特性。你看,当你测试一个系统时,你可能希望在负载下强调不同的属性。DHH 和 Zach 的基准测试都是闭环测试,因此它们测试的是“系统在特定时间内能处理多少请求?”。测试使用了 N 个客户端,每个客户端在收到上一个请求的响应后立即发送新消息。然而在许多情况下,系统负载的增加可能来自大量用户同时执行操作,他们不会等待其他用户完成操作。在这种情况下,你更倾向于恒定的到达率,即你希望以恒定的速率发送请求,而不是让速率取决于系统的响应速度。如果你想检查事件传递的可靠性,理想情况下你应该比较相同数量的事件。在这里,如果你查看结果,会发现 Rust 在 6-7 千条通知中仅送达了 1%,而 Elixir 在约 1.7 千条通知中送达了 100%。这表明 Elixir 版本能更好地处理背压,但这仅在请求数量没有使系统过载的情况下成立。
But let’s leave benchmarks just for a moment and talk about trade-offs, cause programming is all about trade-offs. Sure, there are situations where a tool or a solution is clearly better, without any downsides, but it’s quite rare, especially when you get to complex systems that have to be reliable. I’ve seen quite a lot of people from the Elixir community coming to various conclusions after they’ve seen the 1% delivery rate of the Rust version, without really trying to understand why it happens. The general consensus? Elixir is just better at concurrency! In Rust the scheduler is cooperative, how can you even live like this? I like Elixir, and I’ve succe
但让我们暂时放下基准测试,谈谈权衡,因为编程的核心就是权衡。当然,在某些情况下,某种工具或解决方案明显更好且没有任何缺点,但这非常罕见,尤其是在涉及必须可靠的复杂系统时。我看到很多 Elixir 社区的人在看到 Rust 版本 1% 的送达率后得出了各种结论,却没有真正试图去理解为什么会发生这种情况。普遍的共识是什么?Elixir 在并发方面就是更好!在 Rust 中调度是协作式的,你怎么能忍受这种方式?我喜欢 Elixir,而且我已经成功地……