С такой фразой мне кинули ссылку на статью компании Mail.Ru Group от 2015 «Как выбрать язык программирования?». Если кратко, они сравнили производительность Go, Rust, Scala и Node.js. За первое место боролись Go и Rust, но Go победил.
Как написал автор статьи gobwas (здесь и далее орфография сохранена):
В статье будет много копипасты из исходной статьи, но я надеюсь, что мне это простят.
Второй — приветствие клиента по его имени, переданному в пути URL:
Я спрятал ошибку под спойлер, дабы дать желающим возможность проверить свои навыки.
Я умышленно исправил только лишь багу, а стиль кода оставил без изменений.
Все тесты запускались на локалхосте, без всяких виртуалок, ибо лень. Будет замечательно, если автор предоставит бенчмарки со своего железа, я вставлю апдейтом, вот специально для него репозиторий с тестами, где, кстати, зафиксированы растовые библиотеки на момент написания оригинальной статьи 2015.12.17 (я надеюсь, что все).
ab
Попробуем выполнить 50 000 запросов за 10 секунд, с 256 возможными параллельными запросами.
— Подожди, — скажет читатель. — И стоило тебе строчить статью ради каких-то 500rps?! Ведь это доказывает, что не важно на чем писать, все языки одинаковые!
И тут вступает в дело мой шнур. Шнур для зарядки ноутбука, разумеется.
Стой, Go, ты куда?
Я допускаю, что статья компании Mail.Ru Group содержала непреднамеренную ошибку. Тем не менее, за 1.5 года ее прочитали 45 тысяч раз, и её выводы могли сформировать предвзятое отношение в пользу Go при выборе инструментов, ведь Mail.Ru Group, несомненно, прогрессивная и технологичная компания, к чьим словам стоит прислушаться.
И всё это время Rust совершенствовался, посмотрите на «The Computer Language Benchmarks Game» Rust vs Go за 2015 и 2017 года. Отрыв в производительности только растет.
Если тебе, дорогой читатель, по нраву Go, пиши на нём. Но не стоит сравнивать его производительность с Rust, ибо она будет не в пользу твоего любимого языка, уж точно не на таких синтетических тестах.
Я надеюсь, что я был объективен и непредвзят, справедливость восторжествовала, а моя статья не содержит ошибок.
Let the Holy War begin!
Как написал автор статьи gobwas (здесь и далее орфография сохранена):
Эти тесты показывают, как ведут себя голые серверы, без «прочих нюансов» которые зависят от рук программистов.К моему большому сожалению, тесты не были эквивалентными, ошибка всего лишь в 1 строчке кода поставила под сомнение объективность и вывод статьи.
В статье будет много копипасты из исходной статьи, но я надеюсь, что мне это простят.
Суть тестов
При тестировании выяснилось, что все претенденты работают примерно с одинаковой производительностью в такой постановке — все упиралось в производительность V8. Однако реализация задания не была лишней — разработка на каждом из языков позволила составить значительную часть субъективных оценок, которые так или иначе могли бы повлиять на окончательный выбор.Итак, мы имеем два сценария. Первый — это просто приветствие по корневому URL:
GET / HTTP/1.1
Host: service.host
HTTP/1.1 200 OK
Hello World!
Второй — приветствие клиента по его имени, переданному в пути URL:
GET /greeting/user HTTP/1.1
Host: service.host
HTTP/1.1 200 OK
Hello, user
Первоначальный исходный код тестов
Node.js
var cluster = require('cluster');
var numCPUs = require('os').cpus().length;
var http = require("http");
var debug = require("debug")("lite");
var workers = [];
var server;
cluster.on('fork', function(worker) {
workers.push(worker);
worker.on('online', function() {
debug("worker %d is online!", worker.process.pid);
});
worker.on('exit', function(code, signal) {
debug("worker %d died", worker.process.pid);
});
worker.on('error', function(err) {
debug("worker %d error: %s", worker.process.pid, err);
});
worker.on('disconnect', function() {
workers.splice(workers.indexOf(worker), 1);
debug("worker %d disconnected", worker.process.pid);
});
});
if (cluster.isMaster) {
debug("Starting pure node.js cluster");
['SIGINT', 'SIGTERM'].forEach(function(signal) {
process.on(signal, function() {
debug("master got signal %s", signal);
process.exit(1);
});
});
for (var i = 0; i < numCPUs; i++) {
cluster.fork();
}
} else {
server = http.createServer();
server.on('listening', function() {
debug("Listening %o", server._connectionKey);
});
var greetingRe = new RegExp("^\/greeting\/([a-z]+)$", "i");
server.on('request', function(req, res) {
var match;
switch (req.url) {
case "/": {
res.statusCode = 200;
res.statusMessage = 'OK';
res.write("Hello World!");
break;
}
default: {
match = greetingRe.exec(req.url);
res.statusCode = 200;
res.statusMessage = 'OK';
res.write("Hello, " + match[1]);
}
}
res.end();
});
server.listen(8080, "127.0.0.1");
}
Go
package main
import (
"fmt"
"net/http"
"regexp"
)
func main() {
reg := regexp.MustCompile("^/greeting/([a-z]+)$")
http.ListenAndServe(":8080", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/":
fmt.Fprint(w, "Hello World!")
default:
fmt.Fprintf(w, "Hello, %s", reg.FindStringSubmatch(r.URL.Path)[1])
}
}))
}
Rust
extern crate hyper;
extern crate regex;
use std::io::Write;
use regex::{Regex, Captures};
use hyper::Server;
use hyper::server::{Request, Response};
use hyper::net::Fresh;
use hyper::uri::RequestUri::{AbsolutePath};
fn handler(req: Request, res: Response<Fresh>) {
let greeting_re = Regex::new(r"^/greeting/([a-z]+)$").unwrap();
match req.uri {
AbsolutePath(ref path) => match (&req.method, &path[..]) {
(&hyper::Get, "/") => {
hello(&req, res);
},
_ => {
greet(&req, res, greeting_re.captures(path).unwrap());
}
},
_ => {
not_found(&req, res);
}
};
}
fn hello(_: &Request, res: Response<Fresh>) {
let mut r = res.start().unwrap();
r.write_all(b"Hello World!").unwrap();
r.end().unwrap();
}
fn greet(_: &Request, res: Response<Fresh>, cap: Captures) {
let mut r = res.start().unwrap();
r.write_all(format!("Hello, {}", cap.at(1).unwrap()).as_bytes()).unwrap();
r.end().unwrap();
}
fn not_found(_: &Request, mut res: Response<Fresh>) {
*res.status_mut() = hyper::NotFound;
let mut r = res.start().unwrap();
r.write_all(b"Not Found\n").unwrap();
}
fn main() {
let _ = Server::http("127.0.0.1:8080").unwrap().handle(handler);
}
Scala
package lite
import akka.actor.{ActorSystem, Props}
import akka.io.IO
import spray.can.Http
import akka.pattern.ask
import akka.util.Timeout
import scala.concurrent.duration._
import akka.actor.Actor
import spray.routing._
import spray.http._
import MediaTypes._
import org.json4s.JsonAST._
object Boot extends App {
implicit val system = ActorSystem("on-spray-can")
val service = system.actorOf(Props[LiteActor], "demo-service")
implicit val timeout = Timeout(5.seconds)
IO(Http) ? Http.Bind(service, interface = "localhost", port = 8080)
}
class LiteActor extends Actor with LiteService {
def actorRefFactory = context
def receive = runRoute(route)
}
trait LiteService extends HttpService {
val route =
path("greeting" / Segment) { user =>
get {
respondWithMediaType(`text/html`) {
complete("Hello, " + user)
}
}
} ~
path("") {
get {
respondWithMediaType(`text/html`) {
complete("Hello World!")
}
}
}
}
Подлый удар в спину
Я спрятал ошибку под спойлер, дабы дать желающим возможность проверить свои навыки.
Don't click
Дело в том, что в примере Node.js и Go компиляция регулярного выражения происходит единожды, тогда как в Rust компиляция выполняется на каждый запрос. Про Scala ничего сказать не могу.
Выдержка из документации к regex для Rust:
Выдержка из документации к regex для Go:
Как допустили такую ошибку? Я не знаю… Для такого прямолинейного теста это является существенной просадкой в производительности, ведь даже в комментариях автор указал на тормознутость регулярок:
Упс.
Выдержка из документации к regex для Rust:
Example: Avoid compiling the same regex in a loop
It is an anti-pattern to compile the same regular expression in a loop since compilation is typically expensive. (It takes anywhere from a few microseconds to a few milliseconds depending on the size of the regex.) Not only is compilation itself expensive, but this also prevents optimizations that reuse allocations internally to the matching engines.
In Rust, it can sometimes be a pain to pass regular expressions around if they're used from inside a helper function. Instead, we recommend using the lazy_static crate to ensure that regular expressions are compiled exactly once.
For example:
#[macro_use] extern crate lazy_static; extern crate regex; use regex::Regex; fn some_helper_function(text: &str) -> bool { lazy_static! { static ref RE: Regex = Regex::new("...").unwrap(); } RE.is_match(text) } fn main() {}
Specifically, in this example, the regex will be compiled when it is used for the first time. On subsequent uses, it will reuse the previous compilation.
Выдержка из документации к regex для Go:
But you should avoid the repeated compilation of a regular expression in a loop for performance reasons.
Как допустили такую ошибку? Я не знаю… Для такого прямолинейного теста это является существенной просадкой в производительности, ведь даже в комментариях автор указал на тормознутость регулярок:
Спасибо! Я тоже думал было переписать на split во всех примерах, но потом показалось, что с regexp будет более жизненно. При оказии попробую прогнать wrk со split.
Упс.
Восстанавливаем справедливость
Исправленный тест Rust
extern crate hyper;
extern crate regex;
#[macro_use] extern crate lazy_static;
use std::io::Write;
use regex::{Regex, Captures};
use hyper::Server;
use hyper::server::{Request, Response};
use hyper::net::Fresh;
use hyper::uri::RequestUri::{AbsolutePath};
fn handler(req: Request, res: Response<Fresh>) {
lazy_static! {
static ref GREETING_RE: Regex = Regex::new(r"^/greeting/([a-z]+)$").unwrap();
}
match req.uri {
AbsolutePath(ref path) => match (&req.method, &path[..]) {
(&hyper::Get, "/") => {
hello(&req, res);
},
_ => {
greet(&req, res, GREETING_RE.captures(path).unwrap());
}
},
_ => {
not_found(&req, res);
}
};
}
fn hello(_: &Request, res: Response<Fresh>) {
let mut r = res.start().unwrap();
r.write_all(b"Hello World!").unwrap();
r.end().unwrap();
}
fn greet(_: &Request, res: Response<Fresh>, cap: Captures) {
let mut r = res.start().unwrap();
r.write_all(format!("Hello, {}", cap.at(1).unwrap()).as_bytes()).unwrap();
r.end().unwrap();
}
fn not_found(_: &Request, mut res: Response<Fresh>) {
*res.status_mut() = hyper::NotFound;
let mut r = res.start().unwrap();
r.write_all(b"Not Found\n").unwrap();
}
fn main() {
let _ = Server::http("127.0.0.1:3000").unwrap().handle(handler);
}
Я умышленно исправил только лишь багу, а стиль кода оставил без изменений.
Окружение
Все тесты запускались на локалхосте, без всяких виртуалок, ибо лень. Будет замечательно, если автор предоставит бенчмарки со своего железа, я вставлю апдейтом, вот специально для него репозиторий с тестами, где, кстати, зафиксированы растовые библиотеки на момент написания оригинальной статьи 2015.12.17 (я надеюсь, что все).
- Ноут
- Intel® Core(TM) i7-6820HQ CPU @ 2.70GHz, 4+4
- CPU Cache L1: 128 KB, L2: 1 MB, L3: 8 MB
- 8+8 GB 2133MHz DDR3
- Десктоп
- Intel® Core(TM) i3 CPU 560 @ 3.33GHz, 2+2
- CPU Cache L1: 64 KB, L2: 4 MB
- 4+4 GB 1333MHz DDR3
- go 1.6.2, released 2016/04/20
- rust 1.5.0, released 2015/12/10. Да, я специально взял старую версию Rust.
- Простите, любители Scala и Node.js, этот холивар не про вас.
Интрига
ab
Попробуем выполнить 50 000 запросов за 10 секунд, с 256 возможными параллельными запросами.
Десктоп
ab -n50000 -c256 -t10 "http://127.0.0.1:3000/
Label | Time per request, ms | Request, #/sec |
---|---|---|
Rust | 11.729 | 21825.65 |
Go | 13.992 | 18296.71 |
ab -n50000 -c256 -t10 "http://127.0.0.1:3000/greeting/hello"
Label | Time per request, ms | Request, #/sec |
---|---|---|
Rust | 11.982 | 21365.36 |
Go | 14.589 | 17547.04 |
Ноут
ab -n50000 -c256 -t10 "http://127.0.0.1:3000/"
Label | Time per request, ms | Request, #/sec |
---|---|---|
Rust | 8.987 | 28485.53 |
Go | 9.839 | 26020.16 |
ab -n50000 -c256 -t10 "http://127.0.0.1:3000/greeting/hello"
Label | Time per request, ms | Request, #/sec |
---|---|---|
Rust | 9.148 | 27984.13 |
Go | 9.689 | 26420.82 |
— Подожди, — скажет читатель. — И стоило тебе строчить статью ради каких-то 500rps?! Ведь это доказывает, что не важно на чем писать, все языки одинаковые!
И тут вступает в дело мой шнур. Шнур для зарядки ноутбука, разумеется.
Ноут на подзарядке
ab -n50000 -c256 -t10 "http://127.0.0.1:3000/"
Label | Time per request, ms | Request, #/sec |
---|---|---|
Rust | 5.601 | 45708.98 |
Go | 6.770 | 37815.62 |
ab -n50000 -c256 -t10 "http://127.0.0.1:3000/greeting/hello"
Label | Time per request, ms | Request, #/sec |
---|---|---|
Rust | 5.736 | 44627.28 |
Go | 6.451 | 39682.85 |
Стой, Go, ты куда?
Выводы
Я допускаю, что статья компании Mail.Ru Group содержала непреднамеренную ошибку. Тем не менее, за 1.5 года ее прочитали 45 тысяч раз, и её выводы могли сформировать предвзятое отношение в пользу Go при выборе инструментов, ведь Mail.Ru Group, несомненно, прогрессивная и технологичная компания, к чьим словам стоит прислушаться.
И всё это время Rust совершенствовался, посмотрите на «The Computer Language Benchmarks Game» Rust vs Go за 2015 и 2017 года. Отрыв в производительности только растет.
Если тебе, дорогой читатель, по нраву Go, пиши на нём. Но не стоит сравнивать его производительность с Rust, ибо она будет не в пользу твоего любимого языка, уж точно не на таких синтетических тестах.
Я надеюсь, что я был объективен и непредвзят, справедливость восторжествовала, а моя статья не содержит ошибок.
Let the Holy War begin!