Почему Rust — это новый король сетевого программирования?
Вы когда-нибудь писали сетевой сервер на C или C++ и ловили segmentation fault из-за забытого мьютекса? Или боролись с гонками данных в Go, пытаясь понять, почему горутина «зависла»? Rust решает эти проблемы раз и навсегда. Его система владения (ownership) и модель параллелизма «без страха» (fearless concurrency) делают сетевое программирование на Rust не только безопасным, но и невероятно производительным.
В этой статье мы разберём, как создавать TCP- и UDP-серверы на Rust, используя как стандартную библиотеку (std::net), так и современный async-стек на базе Tokio. Вы увидите, что Rust — это не только язык для системного программирования, но и отличный инструмент для написания высоконагруженных сетевых сервисов.
Основы: std::net — TCP-эхо-сервер за 5 минут
Начнём с классики — эхо-сервера. Он принимает TCP-соединение, читает данные и отправляет их обратно клиенту. В Rust это делается на удивление лаконично.
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::thread;
fn handle_client(mut stream: TcpStream) {
let mut buffer = [0; 1024];
loop {
match stream.read(&mut buffer) {
Ok(0) => {
println!("Клиент отключился");
break;
}
Ok(n) => {
if let Err(e) = stream.write_all(&buffer[..n]) {
eprintln!("Ошибка записи: {}", e);
break;
}
}
Err(e) => {
eprintln!("Ошибка чтения: {}", e);
break;
}
}
}
}
fn main() -> std::io::Result<()> {
let listener = TcpListener::bind("127.0.0.1:7878")?;
println!("Сервер запущен на порту 7878");
for stream in listener.incoming() {
match stream {
Ok(stream) => {
thread::spawn(|| handle_client(stream));
}
Err(e) => {
eprintln!("Ошибка соединения: {}", e);
}
}
}
Ok(())
}
Что здесь происходит? TcpListener::bind создаёт слушающий сокет. Метод incoming() возвращает итератор по входящим соединениям. Для каждого нового клиента мы запускаем отдельный поток — thread::spawn. Внутри handle_client мы читаем данные в буфер и отправляем их обратно через write_all.
Важно: Rust гарантирует, что TcpStream не будет случайно использоваться из двух потоков одновременно — компилятор просто не даст этого сделать. Это и есть fearless concurrency.
Проблемы этого подхода
- Масштабирование: каждый клиент — отдельный поток. При 10 000 клиентов ОС просто захлебнётся.
- Ресурсы: стек потока по умолчанию — 2 МБ. 10 000 клиентов = 20 ГБ памяти.
На помощь приходит асинхронное программирование.
Async-революция: Tokio и async/await
Rust не имеет встроенной асинхронной среды выполнения (как Go или Node.js). Вместо этого он предоставляет трейты Future и ключевые слова async/await, а выбор рантайма — за вами. Самый популярный — Tokio. Это не просто библиотека, это целая экосистема: TCP/UDP, файловый ввод-вывод, таймеры, каналы и даже собственный планировщик.
Давайте перепишем наш эхо-сервер на Tokio.
Tokio-эхо-сервер
Добавьте в Cargo.toml:
[dependencies]
tokio = { version = "1", features = ["full"] }
Теперь код:
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let listener = TcpListener::bind("127.0.0.1:7878").await?;
println!("Tokio-сервер запущен на порту 7878");
loop {
let (mut socket, addr) = listener.accept().await?;
println!("Новый клиент: {}", addr);
tokio::spawn(async move {
let mut buf = [0; 1024];
loop {
match socket.read(&mut buf).await {
Ok(0) => {
println!("Клиент {} отключился", addr);
return;
}
Ok(n) => {
if let Err(e) = socket.write_all(&buf[..n]).await {
eprintln!("Ошибка записи: {}", e);
return;
}
}
Err(e) => {
eprintln!("Ошибка чтения: {}", e);
return;
}
}
}
});
}
}
Разберём магию:
#[tokio::main]— макрос, который превращает обычную функцию в асинхронную и запускает Tokio-рантайм..await— ключевое слово, которое приостанавливает выполнение текущей задачи, пока не завершится Future. Поток при этом не блокируется!tokio::spawn— создаёт лёгкую задачу (task), которая выполняется конкурентно. Тысячи таких задач могут работать на одном потоке.
Зачем это нужно? Вместо того чтобы создавать поток под каждого клиента, мы создаём задачу. Задачи Tokio весят всего несколько килобайт, и их можно иметь сотни тысяч на одном ядре.
UDP-чат на Rust: не только TCP
TCP — это про надежность, но иногда нужна скорость. UDP идеален для игр, стриминга или IoT. На Rust работа с UDP тоже проста.
use tokio::net::UdpSocket;
#[tokio::main]
async fn main() -> std::io::Result<()> {
let socket = UdpSocket::bind("127.0.0.1:8888").await?;
println!("UDP-сервер слушает на порту 8888");
let mut buf = [0; 1024];
loop {
let (len, src) = socket.recv_from(&mut buf).await?;
let msg = std::str::from_utf8(&buf[..len]).unwrap_or("невалидный UTF-8");
println!("Получено от {}: {}", src, msg);
// Отправляем обратно (эхо)
socket.send_to(&buf[..len], &src).await?;
}
}
Обратите внимание: recv_from возвращает не только данные, но и адрес отправителя. Это позволяет отвечать конкретному клиенту. UDP-соединения не устанавливаются — вы просто шлёте пакеты.
Когда использовать UDP вместо TCP?
- Допустима потеря пакетов (видеозвонки, игры).
- Низкая задержка критичнее надёжности.
- Широковещательная расс