Как выбрать язык программирования?
Именно таким вопросом задалась команда Почты Mail.Ru перед написанием очередного сервиса. Основная цель такого выбора — высокая эффективность процесса разработки в рамках выбранного языка/технологии. Что влияет на этот показатель?
- Производительность;
- Наличие средств отладки и профилирования;
- Большое сообщество, позволяющее быстро найти ответы на вопросы;
- Наличие стабильных библиотек и модулей, необходимых для разработки веб-приложений;
- Количество разработчиков на рынке;
- Возможность разработки в современных IDE;
- Порог вхождения в язык.
Кроме этого, разработчики приветствовали немногословность и выразительность языка. Лаконичность, безусловно, так же влияет на эффективность разработки, как отсутствие килограммовых гирь на вероятность успеха марафонца.
Претенденты
Так как многие серверные микротаски нередко рождаются в клиентской части почты, то первый претендент — это, конечно, Node.js с ее родным JavaScript и V8 от Google.
После обсуждения и исходя из предпочтений внутри команды были определены остальные участники конкурса: Scala, Go и Rust.
В качестве теста производительности предлагалось написать простой HTTP-сервер, который получает от общего сервиса шаблонизации HTML и отдает клиенту. Такое задание диктуется текущими реалиями работы почты — вся шаблонизация клиентской части происходит на V8 с помощью шаблонизатора fest.
При тестировании выяснилось, что все претенденты работают примерно с одинаковой производительностью в такой постановке — все упиралось в производительность 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
Окружение
Все тесты проводились на виртуальной машине VirtualBox.
Хост, MacBook Pro:
- 2,6 GHz Intel Core i5 (dual core);
- CPU Cache L1: 32 KB, L2: 256 KB, L3: 3 MB;
- 8 GB 1600 MHz DDR3.
VM:
- 4 GB RAM;
- VT-x/AMD-v, PAE/NX, KVM.
Программное обеспечение:
- CentOS 6.7 64bit;
- Go 1.5.1;
- Rustc 1.4.0;
- Scala 2.11.7, sbt 0.13.9;
- Java 1.8.0_65;
- Node 5.1.1;
- Node 0.12.7;
- nginx 1.8.0;
- wrk 4.0.0.
Помимо стандартных модулей, в примерах на Rust использовался hyper, на Scala — spray. В Go и Node.js использовались только нативные пакеты/модули.
Производительность сервисов тестировалась при помощи следующих инструментов:
В данной статье рассматриваются бенчмарки wrk и ab.
Производительность
wrk
Ниже представлены данные пятиминутного теста, с 1000 соединений и 50 потоками: wrk -d300s -c1000 -t50 --timeout 2s http://service.host
Label | Average Latency, ms | Request, #/sec |
---|---|---|
Go | 104,83 | 36 191,37 |
Rust | 0,02906 | 32 564,13 |
Scala | 57,74 | 17 182,40 |
Node 5.1.1 | 69,37 | 14 005,12 |
Node 0.12.7 | 86,68 | 11 125,37 |
wrk -d300s -c1000 -t50 --timeout 2s http://service.host/greeting/hello
Label | Average Latency, ms | Request, #/sec |
---|---|---|
Go | 105,62 | 33 196,64 |
Rust | 0,03207 | 29 623,02 |
Scala | 55,8 | 17 531,83 |
Node 5.1.1 | 71,29 | 13 620,48 |
Node 0.12.7 | 90,29 | 10 681,11 |
Столь хорошо выглядящие, но, к сожалению, неправдоподобные цифры в результатах Average Latency у Rust свидетельствуют об одной особенности, которая присутствует в модуле hyper. Все дело в том, что параметр -c в wrk говорит о количестве подключений, которые wrk откроет на каждом треде и не будет закрывать, т. е. keep-alive подключений. Hyper работает с keep-alive не совсем ожидаемо — раз, два.
Более того, если вывести через Lua-скрипт распределение запросов по тредам, отправленным wrk, мы увидим, что все запросы отправляет только один тред.
Для интересующихся Rust также стоит отметить, что эти особенности привели вот к чему.
Поэтому, чтобы тест был достоверным, было решено провести аналогичный тест, поставив перед сервисом nginx, который будет держать соединения с wrk и проксировать их в нужный сервис:
upstream u_go {
server 127.0.0.1:4002;
keepalive 1000;
}
server {
listen 80;
server_name go;
access_log off;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 300;
keepalive_requests 10000;
gzip off;
gzip_vary off;
location / {
proxy_pass http://u_go;
}
}
wrk -d300s -c1000 -t50 --timeout 2s http://nginx.host/service
Label | Average Latency, ms | Request, #/sec |
---|---|---|
Rust | 155,36 | 9 196,32 |
Go | 145,24 | 7 333,06 |
Scala | 233,69 | 2 513,95 |
Node 5.1.1 | 207,82 | 2 422,44 |
Node 0.12.7 | 209,5 | 2 410,54 |
wrk -d300s -c1000 -t50 --timeout 2s http://nginx.host/service/greeting/hello
Label | Average Latency, ms | Request, #/sec |
---|---|---|
Rust | 154,95 | 9 039,73 |
Go | 147,87 | 7 427,47 |
Node 5.1.1 | 199,17 | 2 470,53 |
Node 0.12.7 | 177,34 | 2 363,39 |
Scala | 262,19 | 2 218,22 |
Как видно из результатов, overhead с nginx значителен, но в нашем случае нас интересует производительность сервисов, которые находятся в равных условиях, независимо от задержки nginx.
ab
Утилита от Apache ab, в отличие от wrk, не держит keep-alive соединений, поэтому nginx нам тут не пригодится. Попробуем выполнить 50 000 запросов за 10 секунд, с 256 возможными параллельными запросами.ab -n50000 -c256 -t10 http://service.host/
Label | Completed requests, # | Time per request, ms | Request, #/sec |
---|---|---|---|
Go | 50 000,00 | 22,04 | 11 616,03 |
Rust | 32 730,00 | 78,22 | 3 272,98 |
Node 5.1.1 | 30 069,00 | 85,14 | 3 006,82 |
Node 0.12.7 | 27 103,00 | 94,46 | 2 710,22 |
Scala | 16 691,00 | 153,74 | 1 665,17 |
ab -n50000 -c256 -t10 http://service.host/greeting/hello
Label | Completed requests, # | Time per request, ms | Request, #/sec |
---|---|---|---|
Go | 50 000,00 | 21,88 | 11 697,82 |
Rust | 49 878,00 | 51,42 | 4 978,66 |
Node 5.1.1 | 30 333,00 | 84,40 | 3 033,29 |
Node 0.12.7 | 27 610,00 | 92,72 | 2 760,99 |
Scala | 27 178,00 | 94,34 | 2 713,59 |
Стоит отметить, что для Scala-приложения характерен некоторый «прогрев» из-за возможных оптимизаций JVM, которые происходят во время работы приложения.
Как видно, без nginx hyper в Rust по-прежнему плохо справляется даже без keep-alive соединений. А единственный, кто успел за 10 секунд обработать 50 000 запросов, был Go.
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");
}
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])
}
}))
}
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) {
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) {
let mut r = res.start().unwrap();
r.write_all(b"Hello World!").unwrap();
r.end().unwrap();
}
fn greet(_: &Request, res: Response, 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) {
*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);
}
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!")
}
}
}
}
Представим определенные в начале статьи критерии успеха в виде таблицы. Все претенденты имеют средства дебага и профилирования, поэтому соответствующие столбцы в таблице отсутствуют.
Label | Performance Rate0 | Community size1 | Packages count | IDE Support | Developers5 |
---|---|---|---|---|---|
Go | 100,00% | 12 759 | 104 3832 | + | 315 |
Rust | 89,23% | 3 391 | 3 582 | +4 | 21 |
Scala | 52,81% | 44 844 | 172 5933 | + | 407 |
Node 5.1.1 | 41,03% | 102 328 | 215 916 | + | 654 |
Node 0.12.7 | 32,18% | 102 328 | 215 916 | + | 654 |
0 Производительность считалась на основании пятиминутных тестов wrk без nginx, по параметру RPS.
1 Размер сообщества оценивался по косвенному признаку — количеству вопросов с соответствующим тегом на StackOverflow.
2 Количество пакетов, индексированных на godoc.org.
3 Очень приблизительно — поиск по языкам Java, Scala на github.com.
4 Под многими любимую Idea плагина до сих пор нет.
5 По данным hh.ru.
Наглядно о размерах сообщества могут говорить вот такие графики количества вопросов по тегам за день:
Go
Rust
Scala
Node.js
Для сравнения, PHP:
Понимая, что бенчмарки производительности — вещь достаточно зыбкая и неблагодарная, сделать какие-то однозначные выводы только на основании таких тестов сложно. Безусловно, все диктуется типом задачи, которую нужно решать, требованиями к показателям программы и другим нюансам окружения.
В нашем случае по совокупности определенных выше критериев и, так или иначе, субъективных взглядов мы выбрали Go.
Содержание субъективных оценок было намеренно опущено в этой статье, дабы не делать очередной наброс и не провоцировать холивар. Тем более что если бы такие оценки не учитывались, то по критериям, указанным выше, результат остался бы прежним.