golang

Netpoll: пишем сервера, которые не умирают от нагрузки

  • четверг, 28 ноября 2024 г. в 00:00:11
https://habr.com/ru/companies/otus/articles/859964/

Вы знаете, что обычные сетевые библиотеки Go начинают «тяжело дышать», если их нагрузить десятками тысяч соединений? Неважно, делали вы HTTP API или свой TCP сервер — дефолтные инструменты вроде net всегда имеют свои лимиты. Тут-то хорош зайдет Netpoll — библиотека, которая позволяет серверам обрабатывать сотни тысяч соединений одновременно и при этом не терять в производительности.

Почему Netpoll?

Если вы уже успели поиграться со стандартной библиотекой net, то знаете: она классная... до поры до времени. Как только подключений становится больше тысячи, начинается боль: блокировки, миллионы горутин и изматывающее профилирование.

А вот Netpoll решает всё это за счёт асинхронности и низкоуровневого доступа к системе. Она использует epoll на Linux и kqueue на macOS. Для нас это значит:

  • Асинхронная работа: никаких блокировок. Всё крутится на событиях.

  • Нагрузоустойчивость: сервер может держать сотни тысяч соединений и даже не вспотеть.

  • Без лишнего копирования: данные читаются напрямую из буфера. Память не страдает, производительность радует.

  • Гибкая настройка: можно тонко подогнать под конкретные нужды.

Но не всё так идеально. Если вы пишете простой REST API, Netpoll вам вряд ли нужен. Зато если у вас чаты, игровые серверы, вебсокеты или TCP-прокси — это для вас.

Сразу к делу: пишем сервер

Создаём Listener и EventLoop

Netpoll делит работу между Listener (точка входа для соединений) и EventLoop (мозг, который обрабатывает события). Настроим этот тандем:

package main

import (
	"fmt"
	"time"
	"os"
	"os/signal"
	"syscall"
	"github.com/cloudwego/netpoll"
)

// Обработчик запросов
func handleRequest(ctx netpoll.Context, conn netpoll.Connection) error {
	reader := conn.Reader()
	data, err := reader.Next(512) // Читаем до 512 байт
	if err != nil {
		fmt.Printf("Ошибка чтения: %v\n", err)
		return err
	}
	defer reader.Release() // Освобождаем буфер

	fmt.Printf("Получено: %s\n", string(data))

	writer := conn.Writer()
	writer.WriteString("Привет! Это сервер на Netpoll!\n")
	return writer.Flush() // Отправляем ответ
}

func main() {
	// Настраиваем Listener
	listener, err := netpoll.CreateListener("tcp", ":8080")
	if err != nil {
		panic(fmt.Sprintf("Ошибка создания Listener: %v", err))
	}
	defer listener.Close()

	// Настраиваем EventLoop
	eventLoop, err := netpoll.NewEventLoop(
		handleRequest,
		netpoll.WithReadTimeout(10*time.Second),
		netpoll.WithIdleTimeout(5*time.Minute),
	)
	if err != nil {
		panic(fmt.Sprintf("Ошибка создания EventLoop: %v", err))
	}

	fmt.Println("Сервер запущен на порту 8080...")
	go func() {
		err := eventLoop.Serve(listener)
		if err != nil {
			fmt.Printf("Ошибка EventLoop: %v\n", err)
		}
	}()

	// Грейсфул-шатдаун
	c := make(chan os.Signal, 1)
	signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
	<-c
	fmt.Println("Завершаю работу...")

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	eventLoop.Shutdown(ctx)
}

handleRequest обрабатывает запросы, так же настроили тайм-ауты для чтения и простоя. Плюсом реализовали грейсфул-шатдаун, сервер корректно завершает работу, закрывая соединения.

nocopy API

Netpoll позволяет работать с памятью напрямую. Т.е можно лишнего копирования, но здесь есть нюансы. Например, забудете вызвать Release() — получите утечку памяти.

Чтение данных:

func handleRequest(ctx netpoll.Context, conn netpoll.Connection) error {
	reader := conn.Reader()
	buf, err := reader.Next(512)
	if err != nil {
		return fmt.Errorf("Ошибка чтения: %v", err)
	}
	defer reader.Release() // Буфер обязательно освобождаем

	fmt.Printf("Получено: %s\n", string(buf))
	return nil
}

Запись данных:

func writeResponse(conn netpoll.Connection, message string) error {
	writer := conn.Writer()
	buf, err := writer.Malloc(len(message)) // Выделяем память
	if err != nil {
		return fmt.Errorf("Ошибка выделения памяти: %v", err)
	}
	copy(buf, message)
	return writer.Flush() // Отправляем данные
}

Клиентская часть Netpoll

Серверы — это круто, но часто нужен еще и мощный клиент. Пример:

package main

import (
	"fmt"
	"time"
	"github.com/cloudwego/netpoll"
)

func main() {
	conn, err := netpoll.DialConnection("tcp", "127.0.0.1:8080", 5*time.Second)
	if err != nil {
		panic(fmt.Sprintf("Ошибка подключения: %v", err))
	}
	defer conn.Close()

	writer := conn.Writer()
	writer.WriteString("Привет, сервер!")
	writer.Flush()

	reader := conn.Reader()
	response, _ := reader.Next(512)
	fmt.Printf("Ответ сервера: %s\n", string(response))
}

Продвинутые настройки

Поллеры

Настраиваем количество поллеров:

package main

import (
	"runtime"
	"github.com/cloudwego/netpoll"
)

func init() {
	runtime.GOMAXPROCS(runtime.NumCPU())  // Используем все ядра
	netpoll.SetNumLoops(runtime.NumCPU()) // Поллер на каждое ядро
}\

Балансировка нагрузки

Netpoll поддерживает стратегии распределения:

package main

import "github.com/cloudwego/netpoll"

func init() {
	netpoll.SetLoadBalance(netpoll.RoundRobin) // Равномерное распределение
}

Тайм-ауты

Тайм-ауты защищают сервер от зависших соединений:

package main

import (
	"time"
	"github.com/cloudwego/netpoll"
)

func main() {
	var conn netpoll.Connection

	conn.SetReadTimeout(10 * time.Second)  // Тайм-аут чтения
	conn.SetIdleTimeout(5 * time.Minute)  // Тайм-аут простоя
}

Мониторинг: метрики и логи

Используем logrus или zap для структурированного логирования:

package main

import (
	"github.com/sirupsen/logrus"
)

var log = logrus.New()

func handleRequest(ctx netpoll.Context, conn netpoll.Connection) error {
	reader := conn.Reader()
	data, err := reader.Next(512)
	if err != nil {
		log.WithError(err).Error("Ошибка чтения")
		return err
	}
	defer reader.Release()

	log.WithField("data", string(data)).Info("Получены данные")
	return nil
}

Далее подключаем Prometheus для мониторинга соединений:

package main

import (
	"github.com/prometheus/client_golang/prometheus"
	"net/http"
)

var (
	activeConnections = prometheus.NewGauge(prometheus.GaugeOpts{
		Name: "active_connections",
		Help: "Количество активных соединений",
	})
)

func init() {
	prometheus.MustRegister(activeConnections)
}

func main() {
	go func() {
		http.Handle("/metrics", prometheus.Handler())
		http.ListenAndServe(":9090", nil)
	}()
}

Возможные проблемы

  1. nocopy API и утечки памяти. Не забываем (!!!) вызывать Release() после чтения.

  2. Перегрузка. Если сервер перегружен, ограничиваем количество соединений через WithMaxConnections.

  3. Грейсфул-шатдаун. Всегда освобождайте ресурсы корректно.


Заключение

Netpoll — это идеальный инструмент для высоконагруженных серверов.

Попробуйте, внедряйте и делитесь своими успехами в комментариях. И помните: хороший сервер — это сервер, который не падает.

Больше инструментов и практических кейсов эксперты OTUS рассматривают в рамках практических онлайн курсов.Также напомню о том, что в календаре мероприятий вы можете зарегистрироваться на ряд интересных бесплатных вебинаров.