Как правильно отменить net.Listener с помощью контекста?

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 — одно из таких мест.

Ссылки

  1. Интерфейс net.Listener: https://pkg.go.dev/net#Listener
  2. Пакет context: https://pkg.go.dev/context
  3. Тип sync.WaitGroup: https://pkg.go.dev/sync#WaitGroup
  4. Тип net.ListenConfig: https://pkg.go.dev/net#ListenConfig
  5. Обсуждение правильной отмены net.Listener: https://stackoverflow.com/questions/66755407/cancelling-a-net-listener-via-context-in-golang

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *