net.Listener — это интерфейс в языке Go, который используется при реализации приёма подключения по протоколу TCP. Его метод Accept является блокирующим и не имеет входных параметров. Поэтому возникает вопрос: как правильно прервать ожидание подключения с помощью контекста?
Более того, невнимательно читая документацию, можно прийти к решению, которое выглядит как правильное, но таковым не является. По этой причине я и решил написать этот пост.
Постановка задачи
Ниже приводится наипростейший пример использования интерфейса net.Listener [1].
package main
import (
"fmt"
"net"
)
func main() {
l, err := net.Listen("tcp", ":8080")
if err != nil {
fmt.Printf("Listen failed, %v\n", err)
return
}
defer l.Close()
conn, err := l.Accept()
if err != nil {
fmt.Printf("Accept failed, %v\n", err)
return
}
defer conn.Close()
fmt.Println("New connection has been accepted")
}
Экземпляр интерфейса net.Listener создаётся в строке 9. Мы указываем, что хотим принимать соединения по протоколу TCP на порту 8080.
В строке 16 мы начинаем ожидать подключения со стороны клиентов. Это блокирующий вызов. Наша программа ждёт до тех пор, пока кто-нибудь не подключится к указанному порту, или, что более вероятно, не завершит программу принудительно. Как нам прервать это ожидание, если метод Accept ничего не принимает?
Да, можно (и нужно) вынести вызов этого метода в отдельную горутину и особо не париться. Когда программа завершит работу, горутина будет автоматически прибита.
Такой приём будет работать, но нам он не подходит. Мы ведь пришли в Go из C++. А значит, привыкли заботиться о корректном освобождении всех ресурсов.
Неправильное решение
Ниже приводится исходный код решения, которое выглядит как будто бы правильно. Но на самом деле оно ошибочно.
package main
import (
"context"
"fmt"
"net"
"sync"
"time"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
lc := net.ListenConfig{}
l, err := lc.Listen(ctx, "tcp", ":8080")
if err != nil {
fmt.Printf("Listen failed, %v\n", err)
return
}
defer l.Close()
conn, err := l.Accept()
if err != nil {
fmt.Printf("Accept failed, %v\n", err)
return
}
defer conn.Close()
}()
done := make(chan struct{})
go func() {
defer close(done)
wg.Wait()
}()
cancel()
tctx, tcancel :=
context.WithTimeout(context.Background(), 5*time.Second)
defer tcancel()
select {
case <-done:
fmt.Println("Done is closed")
case <-tctx.Done():
fmt.Println("Time elapsed")
}
}
Рассмотрим его подробнее.
В строке 12 создаётся контекст [2], который будет использоваться для отмены ожидания нового подключения к порту.
В строке 15 создаётся sync.WaitGroup [3], на которой мы будем ожидать завершения горутины, реализующей TCP сервер.
В строке 18 запускается горутина, реализующая TCP сервер. В ней (в строке 20) создаётся net.ListenConfig [4], из которого впоследствии (21 строка) создаётся net.Listener. Поскольку при создании net.Listener указывается созданный ранее контекст ctx, мы предполагаем, что он будет использоваться внутри метода Accept (27 строка).
В строке 36 запускается горутина, которая ждёт завершения горутины, реализующей TCP сервер. Для этого используется созданная ранее sync.WaitGroup. Как только горутина дождётся завершения TCP сервера, она закроет канал done (37 строка).
В строке 41 мы отменяем созданный ранее контекст. Тем самым, как мы ожидаем, мы запускаем остановку TCP сервера.
В строке 43 мы создаём еще один контекст, который автоматически отменится через 5 секунд.
В строке 47 мы блокируемся до наступления одного из двух событий. Либо будет закрыт канал done. Это значит, что наш TCP сервер корректно завершился. Либо истечет таймаут в 5 секунд. Это значит, что наш TCP сервер не завершился в течение 5 секунд.
Мы ожидаем, что произойдет первое событие (корректная остановка TCP сервера). Мы же всё сделали правильно. Но… Нет. На самом деле произойдет второе событие. Сервер не завершится, и мы выйдем с истёкшим таймаутом.
Обсуждение такого поведения можно почитать на stackoverflow [5].
Если почитать описание метода net.ListenConfig::Listen [4], то там говорится:
The ctx argument is used while resolving the address on which to listen; it does not affect the returned Listener.
Или в моём переводе на русский:
Аргумент ctx используется при разрешении прослушиваемого адреса; он не влияет на возвращаемый Listener
Хорошо. Как тогда правильно отменить net.Listener?
Правильное решение
В обсуждении [5] говорится, что для того чтобы отменить net.Listener нужно… Его закрыть. То есть вызвать метод Close(). И это автоматически прервет работу метода Accept.
Ниже приводится возможный вариант правильного решения нашей задачи.
package main
import (
"context"
"fmt"
"net"
"sync"
"time"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
l, err := net.Listen("tcp", ":8080")
if err != nil {
fmt.Printf("Listen failed, %v\n", err)
return
}
go func() {
defer l.Close()
<-ctx.Done()
}()
conn, err := l.Accept()
if err != nil {
select {
case <-ctx.Done():
default:
fmt.Printf("Accept failed, %v\n", err)
}
return
}
defer conn.Close()
}()
done := make(chan struct{})
go func() {
defer close(done)
wg.Wait()
}()
cancel()
tctx, tcancel :=
context.WithTimeout(context.Background(), 5*time.Second)
defer tcancel()
select {
case <-done:
fmt.Println("Done is closed")
case <-tctx.Done():
fmt.Println("Time elapsed")
}
}
Приведённый выше пример работает правильно, и мы видим на экране ожидаемую строку "Done is closed".
Рассмотрим его основные отличия от якобы правильного решения.
В строке 26 запускается отдельная горутина. Она блокируется на созданном ранее контексте. Когда контекст будет отменён, горутина закроет созданный ранее net.Listener (строка 27). Это приведет к прерыванию работы метода Accept (строка 31).
Когда метод Accept вернёт управление, нам нужно как-то понять, завершились мы из-за ошибки, или из-за отмены контекста. Для этого используется оператор select (строка 33). Если контекст отменён, то это не ошибка, и мы просто завершаем работу. Если же контекст не отменён, то значит произошла ошибка, и мы выводим информацию о ней.
Заключение
Как отмечают в обсуждении [5], поддержка контекстов в стандартной библиотеке языка Go не всегда последовательна и единообразна. Существуют места, в которых правильное использование контекстов неочевидно. Отмена net.Listener — одно из таких мест.
Ссылки
- Интерфейс
net.Listener: https://pkg.go.dev/net#Listener - Пакет
context: https://pkg.go.dev/context - Тип
sync.WaitGroup: https://pkg.go.dev/sync#WaitGroup - Тип
net.ListenConfig: https://pkg.go.dev/net#ListenConfig - Обсуждение правильной отмены
net.Listener: https://stackoverflow.com/questions/66755407/cancelling-a-net-listener-via-context-in-golang
