net/http: Разбираем работу сервера «под капотом»
- четверг, 19 июня 2025 г. в 00:00:14
В мире разработки веб‑приложений (да, да, это тот самый хайп про «хайлоад», «легковесные потоки», «io‑bound нагрузки» и другое) Go завоевал популярность благодаря своей производительности, легкости и надежности. Одним из ключевых компонентов экосистемы Go является стандартный пакет net/http, который предоставляет инструменты для создания HTTP‑серверов и клиентов. Однако, несмотря на кажущуюся простоту использования этого пакета, понимание внутренних механизмов работы сервера может значительно повысить эффективность вашего кода и помочь избежать распространенных ошибок.
Мы вместе в вами, дорогие читатели, рассмотрим ключевые аспекты архитектуры net/http, разберем весь процесс обработки запросов, а также обсудим, каждый из этапов подробнее. Независимо от уровня ваших знаний, будь вы новичком или опытным разработчиком, вы найдете здесь полезную информацию, которая поможет вам лучше понимать и использовать возможности Go для создания высокопроизводительных и надежных веб‑приложений.
Конечно же, для тех, кто хочет глубже понять, как работает сервер на основе пакета net/http в Go. Но! Она также будет полезна тем, кто уже пишет HTTP‑сервисы на Go и стремится улучшить свои навыки. Начинающие разработчики найдут здесь много полезной информации, а уже опытные программисты смогут освежить знания и, возможно, найти для себя что‑то новое. Чтобы извлечь максимум пользы от этой статьи, желательно иметь базовые знания языка Go, но даже без них вы сможете понять суть изложения.
Хочу также отметить, что изучение стандартной библиотеки, не только позволяет понимать работу net/http, но и служит хорошим примером того, как правильно и корректно писать ПО на языке Go. Ведь познание каких‑либо начальных подходов, заложенных родоначальниками какого‑либо дела — полезно, и, более того, это относится к любой области изучения, будь то конкретный язык программирования, научная дисциплина, творчество и другие направления.
Я предлагаю идти поэтапно, сверху — вниз, для того, чтобы, даже новичку в Go, была понятна «магия» работы сервера.
Здесь я расскажу о том, каким образом мы будем пробираться в эти «таинственные глубины» языка и познавать их. Пойдём по пунткам:
Краткий экскурс в HTTP
Старт сервера: функция http.ListenAndServe()
, метод server.ListenAndServe().
Обработка входящих запросов: метод server.Serve().
Планируется также разобрать в следующих частях:
Роутинг запросов.
Архитектурный взгляд на модуль net/http.
Работа листенера «под капотом»
И многое другое...
Начнём с того, что данные передаются в сети (интернет). Чтобы как‑то согласовывать порядок передачи, решать определенные проблемы и т. д., были созданы протоколы, каждый из которых разделен по уровням/ Эта идея реализована в 7-уровневой модели OSI (базовой теоретической модели) и упрощенной 4-уровневой модели TCP/IP (практически используемой в интернете). Чем ниже уровень, тем ближе абстракции уровня к физическим принципам, и наоборот. Так вот, в мире используются два основных протокола:
TCP (от англ. Transmission Control Protocol - протокол переда чи гипертекста) – транспортный протокол передачи данных в сетях TCP/IP, предварительно устанавливающий соединение с сетью.
HTTP (от англ. HyperText Transfer Protocol - протокол переда чи гипертекста) — это прикладной протокол передачи данных в сети. На текущий момент используется для получения информации с веб-сайтов.
Протокол HTTP работает "поверх" TCP и основан на использовании технологии «клиент-сервер»: клиент, отправляющий запрос, является инициатором соединения; сервер, получающий запрос, выполняет его и отправляет клиенту результат. В свою очередь, TCP в данной цепочке играет роль транспортного средства данных HTTP.
Представим работу всех протоколов (по модели OSI) в виде ЖД системы:
Пассажир (уровень 7) говорит: «Хочу зайти на главную страницу сайта» (HTTP-запрос).
Уровень представления (6) сжимает текст главной страницы и шифрует его.
Сеансовый уровень (5) открывает «сеанс» связи с сервером.
Транспортный уровень (4) разбивает текст на пакеты и нумерует их (TCP).
Сетевой уровень (3) прокладывает маршрут через интернет-маршрутизаторы («Город С (сервер, на котором хостится сайт) → Город П → ... → Город Ц (наш посетитель сайта) »).
Канальный уровень (2) передает пакеты между соседними роутерами (Ethernet, Wi-Fi).
Физический уровень (1) отправляет биты по кабелю или воздуху (свет в оптоволокне, радиосигналы).
Начнём с написания функции main()
в которой будет находиться вызов http.ListenAndServe
- той самой функции, которая запускает сервер.
// main.go
package main
import (
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello world!"))
})
_ := http.ListenAndServe("localhost:5000", mux)
}
Всё, что делает наше приложение, это при запросе по адресу http://localhost:5000/hello выдаёт нам надпись «Hello world!»
Посмотрим на функцию ListenAndServe
в стандартном модуле net/http:
// net/http/server.go
func ListenAndServe(addr string, handler Handler) error {
server := &Server{Addr: addr, Handler: handler}
return server.ListenAndServe()
}
addr:
Строка, указывающая сетевой адрес для прослушивания. Адрес должен быть в формате 'Хост:Порт' (например, "localhost:5000").
handler:
Объект, который реализует http.Handler - интерфейс обработчика.
В комментариях указано:
«Функция прослушивает сетевой TCP адрес и затем вызывает Serve с Handler для обработки запросов на входящих соединениях. Принятые соединения настроены таким образом, чтобы включить функцию TCP keep‑alive. Handler обычно равен нулю, и в этом случае используется DefaultServeMux. Функция всегда возвращает не нулевую ошибку».
Чтобы полностью осознать данное пояснение выделим и разберемся с его ключевыми моментами:
Serve (обслуживать) - пока мы пропустим данный пункт, т.к. его работа будет разобрана в дальнейшем.
Handler (обработчик) - интерфейс, содержащий в себе функцию ServeHTTP. Как указано в документации, функция предназначена для ответа на HTTP-запрос (подробнее можно почитать здесь: Handler).
DefaultServeMux - это экземпляр ServeMux, который используется по умолчанию для обработки HTTP-запросов, если явно не указан другой маршрутизатор (прим. - Маршрутизатор, это система, которая определяет маршрут для обработки запросов и направляет их по нужным ресурсам). Отвечает за маршрутизацию входящих HTTP-запросов на соответствующие обработчики (handlers) на основе пути URL. В нашем примере, есть зарегистрированный обработчик для пути /hello, следовательно, запросы к http://localhost:5000/hello будут направлены на этот обработчик.
TCP keep-alive - это механизм в протоколе TCP, который позволяет поддерживать соединение между клиентом и сервером активным, даже если данные не передаются. Он используется для обнаружения "мертвых" соединений (например, когда одна из сторон отключилась или произошел сбой сети) и их закрытия. Примечание: разбираться в том, как работает TCP - мы не будем, однако, по этому поводу есть множество статей (например, данная).
Тут можно заметить, что в теле функции создаётся экземпляр структуры типа Server, использующий для конфигурации и управления HTTP‑сервером. Данная структура инкапсулирует все параметры, необходимые для запуска и работы сервера, такие как адрес, обработчики, таймауты, TLS‑конфигурация и другие настройки. Каждый параметр, соответственно, влияет на работу сервера, позволяя гибко настраивать и управлять его поведением.
Я думаю, не будет лишним разобраться со структурой сервера:
// A Server defines parameters for running an HTTP server.
// The zero value for Server is a valid configuration.
type Server struct {
// Addr optionally specifies the TCP address for the server to listen on,
// in the form "host:port". If empty, ":http" (port 80) is used.
// The service names are defined in RFC 6335 and assigned by IANA.
// See net.Dial for details of the address format.
Addr string
Handler Handler // handler to invoke, http.DefaultServeMux if nil
// DisableGeneralOptionsHandler, if true, passes "OPTIONS *" requests to the Handler,
// otherwise responds with 200 OK and Content-Length: 0.
DisableGeneralOptionsHandler bool
// TLSConfig optionally provides a TLS configuration for use
// by ServeTLS and ListenAndServeTLS. Note that this value is
// cloned by ServeTLS and ListenAndServeTLS, so it's not
// possible to modify the configuration with methods like
// tls.Config.SetSessionTicketKeys. To use
// SetSessionTicketKeys, use Server.Serve with a TLS Listener
// instead.
TLSConfig *tls.Config
// ReadTimeout is the maximum duration for reading the entire
// request, including the body. A zero or negative value means
// there will be no timeout.
//
// Because ReadTimeout does not let Handlers make per-request
// decisions on each request body's acceptable deadline or
// upload rate, most users will prefer to use
// ReadHeaderTimeout. It is valid to use them both.
ReadTimeout time.Duration
// ReadHeaderTimeout is the amount of time allowed to read
// request headers. The connection's read deadline is reset
// after reading the headers and the Handler can decide what
// is considered too slow for the body. If zero, the value of
// ReadTimeout is used. If negative, or if zero and ReadTimeout
// is zero or negative, there is no timeout.
ReadHeaderTimeout time.Duration
// WriteTimeout is the maximum duration before timing out
// writes of the response. It is reset whenever a new
// request's header is read. Like ReadTimeout, it does not
// let Handlers make decisions on a per-request basis.
// A zero or negative value means there will be no timeout.
WriteTimeout time.Duration
// IdleTimeout is the maximum amount of time to wait for the
// next request when keep-alives are enabled. If zero, the value
// of ReadTimeout is used. If negative, or if zero and ReadTimeout
// is zero or negative, there is no timeout.
IdleTimeout time.Duration
// MaxHeaderBytes controls the maximum number of bytes the
// server will read parsing the request header's keys and
// values, including the request line. It does not limit the
// size of the request body.
// If zero, DefaultMaxHeaderBytes is used.
MaxHeaderBytes int
// TLSNextProto optionally specifies a function to take over
// ownership of the provided TLS connection when an ALPN
// protocol upgrade has occurred. The map key is the protocol
// name negotiated. The Handler argument should be used to
// handle HTTP requests and will initialize the Request's TLS
// and RemoteAddr if not already set. The connection is
// automatically closed when the function returns.
// If TLSNextProto is not nil, HTTP/2 support is not enabled
// automatically.
TLSNextProto map[string]func(*Server, *tls.Conn, Handler)
// ConnState specifies an optional callback function that is
// called when a client connection changes state. See the
// ConnState type and associated constants for details.
ConnState func(net.Conn, ConnState)
// ErrorLog specifies an optional logger for errors accepting
// connections, unexpected behavior from handlers, and
// underlying FileSystem errors.
// If nil, logging is done via the log package's standard logger.
ErrorLog *log.Logger
// BaseContext optionally specifies a function that returns
// the base context for incoming requests on this server.
// The provided Listener is the specific Listener that's
// about to start accepting requests.
// If BaseContext is nil, the default is context.Background().
// If non-nil, it must return a non-nil context.
BaseContext func(net.Listener) context.Context
// ConnContext optionally specifies a function that modifies
// the context used for a new connection c. The provided ctx
// is derived from the base context and has a ServerContextKey
// value.
ConnContext func(ctx context.Context, c net.Conn) context.Context
inShutdown atomic.Bool // true when server is in shutdown
disableKeepAlives atomic.Bool
nextProtoOnce sync.Once // guards setupHTTP2_* init
nextProtoErr error // result of http2.ConfigureServer if used
mu sync.Mutex
listeners map[*net.Listener]struct{}
activeConn map[*conn]struct{}
onShutdown []func()
listenerGroup sync.WaitGroup
}
Что интересного можно почерпнуть? - Пишите в комментариях.
После создания server идёт вызов соответствующего метода ListenAndServe
:
// net/http/server.go
func (srv *Server) ListenAndServe() error {
if srv.shuttingDown() {
return ErrServerClosed
}
addr := srv.Addr
if addr == "" {
addr = ":http"
}
ln, err := net.Listen("tcp", addr)
if err != nil {
return err
}
return srv.Serve(ln)
}
Кратко разберёмся с тем, что происходит внутри метода:
Проверка состояния сервера: если сервер находится в процессе завершения (shuttingDown), функция возвращает ошибку ErrServerClosed. Примечание: shuttingDown это флаг типа atomic.Bool, в данном случае, atomic используется для того, чтобы синхронизировать обращения из разных горутин к данному флагу (устройство atomic)
Определение адреса: если адрес сервера (srv.Addr) не указан, используется адрес по умолчанию :http (порт 80).
Создание TCP-листенера на указанном адресе. При возникновении ошибки (например, порт занят), она возвращается.
После успешного создания листенера, вызывается метод srv.Serve(ln)
, который начинает принимать входящие соединения и обрабатывать запросы - об этом и будет разговор дальше.
И наконец, после рассмотрения основных аспектов запуска HTTP-сервера в Go, мы подошли к самому интересному — к подробному обзору функции Serve
, которая является ключевым элементом для запуска веб-приложения и обработки входящих запросов.
Разработчики оставили для нас комментарий, в котором указано следующее:
«Serve принимает входящие соединения на Listener l, создавая для каждого из них новый сервис, запущенный в отдельной горутине. Сервисы в горутинах читают запросы и затем вызывает srv.Handler для ответа на них.
Поддержка HTTP/2 включена только в том случае, если Listener возвращает соединения типа *tls.Conn и если они были сконфигурированы с параметром «h2» в TLS Config.NextProtos.
Serve всегда возвращает ненулевую ошибку и закрывает l. После вызова [Server.Shutdown] или [Server.Close] возвращаемая ошибка это ErrServerClosed».
Глянем на содержимое данной функции для понимания дальнейшего контекста объяснения:
// net/http/server.go
func (srv *Server) Serve(l net.Listener) error {
if fn := testHookServerServe; fn != nil {
fn(srv, l) // call hook with unwrapped listener
}
origListener := l
l = &onceCloseListener{Listener: l}
defer l.Close()
if err := srv.setupHTTP2_Serve(); err != nil {
return err
}
if !srv.trackListener(&l, true) {
return ErrServerClosed
}
defer srv.trackListener(&l, false)
baseCtx := context.Background()
if srv.BaseContext != nil {
baseCtx = srv.BaseContext(origListener)
if baseCtx == nil {
panic("BaseContext returned a nil context")
}
}
var tempDelay time.Duration // how long to sleep on accept failure
ctx := context.WithValue(baseCtx, ServerContextKey, srv)
for {
rw, err := l.Accept()
if err != nil {
if srv.shuttingDown() {
return ErrServerClosed
}
if ne, ok := err.(net.Error); ok && ne.Temporary() {
if tempDelay == 0 {
tempDelay = 5 * time.Millisecond
} else {
tempDelay *= 2
}
if max := 1 * time.Second; tempDelay > max {
tempDelay = max
}
srv.logf("http: Accept error: %v; retrying in %v", err, tempDelay)
time.Sleep(tempDelay)
continue
}
return err
}
connCtx := ctx
if cc := srv.ConnContext; cc != nil {
connCtx = cc(connCtx, rw)
if connCtx == nil {
panic("ConnContext returned nil")
}
}
tempDelay = 0
c := srv.newConn(rw)
c.setState(c.rwc, StateNew, runHooks) // before Serve can return
go c.serve(connCtx)
}
}
Разобьём функцию на этапы и разберём их поочередно:
Подготовка листенера:
Листенер оборачивается в onceCloseListener, чтобы предотвратить повторное закрытие. При этом, предварительно listener сохраняется в переменную origListener.
Настраивается HTTP/2 (если включено).
Листенер добавляется в активные (для отслеживания состояния сервера). Также добавляется отложенный вызов на удаление из активных листенеров. В документации сказано, что srv.trackListener
необходим для сообщения о работе сервера (не Shutdown или Closed).
Вы можете подумать: «Почему речь идёт о листенерах, он же один?». А я вам отвечу, что метод Serve может вызываться не только из метода ListenAndServe, но ещё и из метода ServeTLS (важно: это именно методы одного экземпляра типа Server, а не функции) — это значит, что один и тот экземпляр сервера может быть запущен для различной работы, в данном случае с http и https. Но это плохой пример и крайний случай. Основные причины для отслеживания множественных листенеров, это: динамическое добавление listener'ов (например, hot‑reload), сервер работает на разных протоколах (например, IPv4 и IPv6); но данные пункты требуют ручной реализации (по крайней мере, я не встретил такого в документации).
Зачем нужен onceCloseListener? - Он гарантирует, что метод Close() будет вызван только один раз, даже если его вызовут из нескольких мест. Это предотвращает панику из-за двойного закрытия листенера.
Создание базового контекста:
Если BaseContext задан, он используется для создания корневого контекста. Иначе — context.Background().
В контекст добавляется ссылка на сервер (ServerContextKey).
Можно заметить, что ключ контектса добавляемого значения публичный, значит можно получить. И правда, если мы добавим в наш обработчик пути "/hello" код ниже:
srv := r.Context().Value(http.ServerContextKey).(*http.Server)
log.Printf("%#+v", srv)
Мы получим следующий вывод в консоли с отображением всей информации о сервере:
2025/03/03 12:18:30 &http.Server{Addr:"localhost:5000", ...
Пишите в комментариях, каким образом можно применить данную находку.
Цикл обработки соединений:
Принятие соединения: l.Accept().
Обработка ошибок:
Если сервер завершает работу — возвращается ErrServerClosed.
При временных ошибках (например, нехватка файловых дескрипторов) — экспоненциальная задержка перед повторной попыткой (но не больше 1 секунды). Если ошибки нет, далее происходит сброс задержки tempDelay = 0
.
Создание контекста соединения:
Если задан ConnContext, контекст модифицируется (например, для добавления метаданных).
Запуск обработки:
Создается обертка типа conn над соединением net.Conn, которая представляет серверную часть HTTP-соединения.
// net/http/server.go
func (srv Server) newConn(rwc net.Conn) conn {
c := &conn{
server: srv,
rwc: rwc,
}
if debugServerConnections {
c.rwc = newLoggingConn("server", c.rwc)
}
return c
}
По сути, данная абстракция позволяет работать с соединением и модифицировать его, не изменяя напрямую структуру net.Conn. Получается, что логика HTTP-обработки инкапсулирована в conn, это делает код «более модульным» и безопасным.
Состояние соединения устанавливается в StateNew.
В отдельной горутине запускается c.serve()
. Данный метод мы обозревать не будем, т.к. для его разбора потребуется ещё одна статья, однако если данный материал хорошо "зайдёт", то я буду рад сделать это.
Ключевые механизмы:
Что же за механизмы мы можем выделить? Можно разбить их на следующие:
каждое соединение обрабатывается в своей горутине, что позволяет серверу масштабироваться .
экспоненциальная задержка при временных сбоях (например, accept: too many open files).
контекст позволяет передавать данные (логирование, таймауты) через обработчики.
поддержка HTTP/2, которая активируется автоматически при наличии TLS-конфигурации.
Пример потока данных:
Инициализация листенера (установка параметров) → Ожидание запросов (net.Listen("tcp", addr)) → Запрос соединения (со стороны клиента) → Принятие соединения (l.Accept()) → Соединение → Создание сервиса для обработки коннекта в отдельной горутине (go c.serve()) → Обработчик → Ответ клиенту
Исходя из полученной информации, мы можем сделать следующий вывод:
«Функция Serve — это ядро HTTP‑сервера в Go. Она обеспечивает конкурентную обработку соединений, устойчивость к ошибкам и гибкую настройку через структуру Server.»
В дополнение к разбору функции Serve также посмотрим некоторые механизмы, которые позволят сформировать полноценное представление о работе HTTP сервера.
Как работает отслеживание соединений?
Сервер хранит активные соединения в мапе activeConn map[*conn]struct{}. Использование мапы позволяет находить соединения за константное время O(1), а в качестве значений используется пустая структура struct{}, которая не занимает памяти, что делает хранение эффективным (подробнее о том, занимает пустая структура память или нет здесь).
setState обновляет состояние соединения (например, StateNew → StateActive → StateClosed).
При завершении сервера можно корректно закрыть все соединения.
Где происходит обработка HTTP-запросов?
В методе c.serve(), который читает запрос из соединения, создает объект http.Request, вызывает обработчик (srv.Handler), записывает ответ в соединение и, наконец, закрывает соединение (если не используется keep-alive).
Механизм завершения работы сервера.
При вызове srv.Shutdown(ctx) или закрытии листенера сервер помечается как isShutdown (атомарный флаг).
Сервер останавливает прием новых соединений (блокировка в l.Accept()). Итерирует по activeConn и вызывает close() для каждого соединения, принудительно разрывая его.
Доступ к activeConn защищен мьютексом (srv.mu), чтобы избежать гонок данных при одновременном добавлении/удалении соединений. Сервер дожидается завершения всех горутин через listenerGroup.Wait() (реализовано через sync.WaitGroup).
Все соединения закрываются явно, даже если обработчик завис (через SetDeadline в net.Conn).
При завершении сервер вызывает хуки onShutdown, чтобы дать возможность выполнить cleanup-логику. Примечание:
Хук (от англ. hook — "крючок") — это механизм, позволяющий встроить пользовательский код в определенные моменты выполнения программы. Хуки используются для расширения функциональности без изменения основной логики.
Cleanup-логика (от англ. cleanup — "очистка") — это код, который выполняется для освобождения ресурсов или приведения системы в стабильное состояние перед завершением работы. Это важно для предотвращения утечек ресурсов (памяти, файловых дескрипторов, соединений с БД и так далее).
В этой статье я с вами рассмотрели внутренние механизмы работы HTTP‑сервера в пакете net/http языка Go. По факту разобрали шаг за шагом путь: от вызова функции до анализа процесса обработки запросов. Теперь можно сказать, что у вас есть минимальные знания для того, чтобы понимать то, как работают эффективные, надежные и безопасные веб‑приложения на основе Go.
Понимание того, как работает сервер «под капотом», открывает перед вами новые горизонты возможностей и костылей :) Тут важно, что вы можете не только писать код, но и начать понимать принципы его работы.
Надеюсь, эта статья была полезной и дала вам новые знания, которые вы сможете применить в своём обучении. Желаю успехов в дальнейшем изучении! Надеюсь, скоро встретимся вновь!