Сервис на виртуальной машине с двумя гигабайтами памяти начинает задыхаться при двухстах одновременных запросах. Профилирование показывает, что дело не в базе данных и не в логике расчёта: половину памяти съедают тысячи потоков, каждый из которых ждёт ответа от внешней службы. Есть подход, при котором ожидание не занимает ресурсов, и язык, где такой подход встроен в саму модель работы.
Язык появился в 2012 году и решает проблему управления памятью
Первый публичный выпуск компилятора состоялся в январе 2012 года, хотя разработка велась с 2006 года на другом языке. Проект задумывался как замена языку системного программирования, но с защитой от ошибок работы с памятью.
Главная особенность состоит в правилах владения. Каждое значение имеет одного владельца, а заимствование проверяется на этапе сборки. Программа, где возможна утечка или обращение к освобождённой памяти, просто не соберётся.
Второе свойство это отсутствие сборщика мусора. Проверки выполняются при компиляции, а не во время работы, поэтому пауз в исполнении не возникает. Для сервиса с жёсткими требованиями к задержке это важно.
Третье свойство это строгая типизация с выводом типов. Указывать типы везде не нужно, но проверка остаётся полной.
Четвёртое это единая система сборки и управления зависимостями. Инструмент сборки работает из коробки, что заметно снижает объём настройки.
Плата за эти свойства известна: порог входа выше, чем в языках со сборщиком мусора, а время компиляции больше. Первые недели уходят на борьбу с проверками заимствования, и это нормально.
Асинхронная модель позволяет обслуживать тысячи соединений одним потоком
Ключевое понятие для сетевого сервиса это асинхронность. Обычный подход блокирует поток на время операции ввода-вывода. Пока поток ждёт ответа от базы, он не делает ничего полезного, но занимает память.
Асинхронный подход устроен иначе. Операция запускается, а управление немедленно возвращается. Когда результат готов, система сообщает об этом. Поток успевает обработать другие задачи.
Разница в цифрах впечатляет. Операция чтения с диска занимает около десяти миллисекунд. За это время процессор с частотой один гигагерц успевает выполнить десять миллионов тактов. Ожидание в потоке это почти полное расточительство.
Отсюда экономия памяти. Тысяча потоков, каждый со своим стеком, занимает сотни мегабайт. Тысяча асинхронных задач обходится заметно дешевле, потому что не требует отдельного стека на каждую.
Обратная сторона тоже есть. Асинхронный код сложнее читать, ошибки в нём проявляются хитрее, а блокирующая операция внутри асинхронной задачи задерживает всю очередь.
Каркас веб-сервиса строится вокруг обработчиков и общего состояния
Перейдём к практике. Каркас для веб-сервисов на этом языке строится из нескольких понятий: маршрут, обработчик, общее состояние и средство запуска.
Маршрут связывает адрес и метод с функцией, которая формирует ответ. Обработчик получает данные запроса и возвращает результат.
Общее состояние это данные, доступные всем обработчикам: пул соединений с базой, настройки, кэш. Состояние передаётся в обработчики через специальный признак типа.
use actix_web::{get, post, web, App, HttpResponse, HttpServer, Responder};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
#[derive(Serialize)]
struct Order {
number: i64,
total: f64,
}
#[get("/orders/{number}")]
async fn get_order(
pool: web::Data<PgPool>,
number: web::Path<i64>,
) -> impl Responder {
let row = sqlx::query_as::<_, (i64, f64)>(
"SELECT number, total FROM orders WHERE number = $1",
)
.bind(number.into_inner())
.fetch_optional(pool.get_ref())
.await;
match row {
Ok(Some((number, total))) => HttpResponse::Ok().json(Order { number, total }),
Ok(None) => HttpResponse::NotFound().finish(),
Err(_) => HttpResponse::InternalServerError().finish(),
}
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let pool = PgPool::connect("postgres://localhost/shop").await.unwrap();
HttpServer::new(move || {
App::new()
.app_data(web::Data::new(pool.clone()))
.service(get_order)
})
.bind(("127.0.0.1", 8080))?
.run()
.await
}
Здесь видно несколько важных моментов. Пул соединений создаётся один раз и передаётся в состояние. Обработчик асинхронный и потому не блокирует поток. Ошибка базы превращается в ответ с кодом пятьсот, а не в падение процесса.
Второй момент касается клонирования. Пул соединений клонируется при создании приложения, но клонирование здесь дешёвое: внутри лежит счётчик ссылок, а не новые соединения.
Третий момент это разбор пути. Тип параметра указывается явно, и преобразование выполняется до вызова обработчика. Если в адресе не число, запрос не доходит до кода.
Приём данных требует проверки и аккуратной обработки ошибок
Отдельная тема это входные данные. Прежде чем что-то делать, их нужно разобрать и проверить.
#[derive(Deserialize)]
struct CreateOrder {
customer_id: i64,
items: Vec<CreateItem>,
}
#[derive(Deserialize)]
struct CreateItem {
product_id: i64,
quantity: i32,
}
#[post("/orders")]
async fn create_order(
pool: web::Data<PgPool>,
payload: web::Json<CreateOrder>,
) -> impl Responder {
let data = payload.into_inner();
if data.items.is_empty() {
return HttpResponse::BadRequest().body("Список позиций пуст");
}
if data.items.iter().any(|item| item.quantity <= 0) {
return HttpResponse::BadRequest().body("Количество должно быть положительным");
}
if data.items.len() > 200 {
return HttpResponse::BadRequest().body("Слишком много позиций в одном заказе");
}
HttpResponse::Created().finish()
}
Ограничение на длину списка здесь не формальность. Без него один запрос может занять ресурсы на минуты, и это простейший способ устроить отказ в обслуживании.
Второй приём это ограничение размера тела запроса. По умолчанию каркас задаёт разумный предел, но для сервисов с загрузкой файлов его стоит настроить явно.
Третий приём это проверка типов на уровне разбора. Если поле ожидается числом, а пришла строка, каркас вернёт ошибку сам, и обработчик не выполнится.
Четвёртый приём это ясные сообщения. Ответ с кодом четыреста без пояснения заставляет разработчика клиента угадывать. Короткое описание проблемы экономит часы.
Производительность зависит от размера среды исполнения и работы с памятью
Пятая тема это настройка под нагрузку. Каркас позволяет запускать несколько рабочих потоков, обычно по числу ядер.
HttpServer::new(|| App::new().service(get_order))
.workers(4)
.max_connections(10_000)
.keep_alive(std::time::Duration::from_secs(15))
.bind(("0.0.0.0", 8080))?
.run()
.await
Число рабочих потоков подбирается по числу ядер процессора. Больше ставить смысла нет, а меньше означает простаивающие ядра.
Ограничение числа соединений защищает от перегрузки. Значение стоит выбирать с запасом, но не бесконечно большим.
Время удержания соединения влияет на расход памяти. Долгое удержание удобно для клиентов, но занимает ресурсы. Значение в пятнадцать секунд это разумный компромисс.
Второй параметр это размер стека задач. Уменьшение экономит память при большом числе соединений, но слишком маленькое значение приведёт к переполнению на сложных обработчиках.
Третья настройка касается сжатия ответов. Для текстовых данных оно даёт выигрыш, для уже сжатых изображений только тратит процессор.
Четвёртая настройка это ведение журнала. Полный журнал на каждый запрос при высокой нагрузке становится узким местом. Уровень подробности стоит понижать в рабочем режиме.
Сборка выпускается с оптимизацией и проверяется отдельно
Шестая тема это подготовка к выпуску.
Первое: сборка с оптимизацией. В режиме разработки компилятор собирает быстро, но код работает медленно. Выпуск требует включения оптимизаций.
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 1
panic = "abort"
Объединение модулей при компиляции ускоряет работу за счёт времени сборки. Отказ от разворачивания стека при панике уменьшает размер двоичного файла, но лишает возможности перехватить панику.
Второе: статическая сборка. Один исполняемый файл без внешних зависимостей упрощает развёртывание и уменьшает образ контейнера до десятков мегабайт.
Третье: проверка утечек. Даже при строгих правилах возможны ошибки логики, при которых память растёт. Наблюдение за потреблением в течение суток обязательно.
Четвёртое: тесты под нагрузкой. Синтетическая нагрузка показывает предел и помогает подобрать число потоков.
Пятое: наблюдение. Число активных соединений, задержка, потребление памяти, число ошибок. Без этих данных настройка превращается в гадание.
Сравнение с привычными платформами помогает принять решение
Седьмая тема это выбор. Стоит сопоставить подходы по нескольким признакам.
Первый признак это потребление памяти. Асинхронный сервис на этом языке обычно занимает в разы меньше памяти, чем платформа с отдельным потоком на запрос.
Второй признак это предсказуемость задержки. Отсутствие пауз сборщика мусора даёт ровные показатели.
Третий признак это скорость разработки. Здесь выигрывают языки с динамической типизацией: код пишется быстрее, библиотек больше.
Четвёртый признак это число ошибок в рабочем контуре. Строгая проверка ловит значительную часть проблем до выпуска.
Пятый признак это доступность специалистов. Найти разработчика на распространённом языке проще.
Практический вывод выглядит так. Переходить стоит там, где узким местом стала память или задержка, либо где важна надёжность при высокой нагрузке. Для внутреннего сервиса с сотней запросов в день выгода не покроет затрат на освоение.
Отдельный разбор касается типичных ошибок новичков
Первое: блокирующая операция внутри асинхронного обработчика. Вызов синхронной функции чтения файла или тяжёлого расчёта задерживает всю очередь задач. Решение состоит в переносе такой работы в отдельный пул потоков.
Второе: удержание ссылки на состояние через изменяемую ссылку. Правила заимствования не позволяют менять общие данные из нескольких задач одновременно. Для общих счётчиков применяются атомарные типы или средства синхронизации.
Третье: клонирование больших структур на каждый запрос. Копирование всего состояния ради доступа к одному полю съедает память и время. Правильно передавать ссылку в обёртке, рассчитанной на совместное использование.
Четвёртое: игнорирование ошибок через разворачивание. Прямое разворачивание результата при сбое завершает рабочий поток. В сервисе почти всегда нужен осмысленный ответ вместо падения.
Пятое: отсутствие ограничений на размер запроса. Один клиент может отправить тело в сотни мегабайт и занять память.
Шестое: слишком мелкие задачи. Создание асинхронной задачи на каждую операцию с базой даёт накладные расходы. Иногда последовательное выполнение быстрее.
Числовой пример показывает экономию памяти
Сравним два подхода к обработке двух тысяч одновременных запросов, каждый из которых ждёт ответа внешней службы.
При потоке на запрос требуется два мегабайта стека на поток. Две тысячи потоков дают около четырёх гигабайт только на стеки, не считая данных приложения. Сервер с двумя гигабайтами не выдержит.
При асинхронной модели состояние задачи занимает килобайты. Две тысячи задач укладываются в десятки мегабайт. Даже с запасом на буферы и структуры остаётся много свободной памяти.
Разница объясняется тем, что при ожидании не нужно сохранять стек. Сохраняется только состояние задачи, а стек не выделяется.
Из этого расчёта следует и практический вывод: асинхронная модель выигрывает там, где преобладает ожидание. Если обработка состоит из тяжёлых вычислений без ожидания, выигрыша не будет, и число рабочих потоков остаётся ключевым.
Короткая проверка перед выпуском
Первое: убедитесь, что все обработчики асинхронные и не содержат блокирующих вызовов.
Второе: проверьте ограничения на размер тела запроса и на длину списков.
Третье: посмотрите, что происходит при недоступности базы данных.
Четвёртое: замерьте потребление памяти под нагрузкой в течение часа.
Пятое: проверьте логи на наличие ошибок при пиковой нагрузке.
Шестое: убедитесь, что настройки потоков и соединений соответствуют железу.
Проверки занимают несколько часов, а дают уверенность, что сервис выдержит нагрузку, для которой создавался.
Бэкенд на этом языке с асинхронным каркасом получился решением для конкретных условий. Он даёт плотность обслуживания и ровную задержку, требуя взамен дисциплины и времени на освоение. Там, где нагрузка высока, а память ограничена, эта сделка выгодна. Там, где нагрузка скромная, проще взять привычный инструмент и не усложнять себе жизнь.