четверг, 31 октября 2019 г.

Миграция на модули Go

В проектах Go используется широкий спектр стратегий управления зависимостями. Вендоринг инструменты, такие как dep и glide, популярны, но они имеют большие различия в поведении и не всегда хорошо работают вместе. Некоторые проекты хранят весь каталог GOPATH в одном Git-репозитории. Другие просто полагаются на go get и ожидают, что в GOPATH будут установлены довольно свежие версии зависимостей.

Система модулей Go, представленная в Go 1.11, предоставляет официальное решение для управления зависимостями, встроенное в команду go. В этом посте описываются инструменты и методы для миграции проекта на модули.

Обратите внимание: если ваш проект уже помечен как v2.0.0 или выше, вам потребуется обновить путь к модулю при добавлении файла go.mod.

Миграция на Go модули в вашем проекте

Проект может находиться в одном из трех состояний при начале перехода к модулям Go:

  • Новый проект Go.
  • Созданный проект Go с немодульным менеджером зависимостей.
  • Созданный проект Go без какого-либо менеджера зависимостей.

Первый случай описан в посте модули Go; мы рассмотрим последние два в этом посте.

С менеджером зависимостей

Чтобы преобразовать проект, который уже использует инструмент управления зависимостями, выполните следующие команды:

$ git clone https://github.com/my/project
[...]
$ cd project
$ cat Godeps/Godeps.json
{
    "ImportPath": "github.com/my/project",
    "GoVersion": "go1.12",
    "GodepVersion": "v80",
    "Deps": [
        {
            "ImportPath": "rsc.io/binaryregexp",
            "Comment": "v0.2.0-1-g545cabd",
            "Rev": "545cabda89ca36b48b8e681a30d9d769a30b3074"
        },
        {
            "ImportPath": "rsc.io/binaryregexp/syntax",
            "Comment": "v0.2.0-1-g545cabd",
            "Rev": "545cabda89ca36b48b8e681a30d9d769a30b3074"
        }
    ]
}
$ go mod init github.com/my/project
go: creating new go.mod: module github.com/my/project
go: copying requirements from Godeps/Godeps.json
$ cat go.mod
module github.com/my/project

go 1.12

require rsc.io/binaryregexp v0.2.1-0.20190524193500-545cabda89ca
$

go mod init создает новый файл go.mod и автоматически импортирует зависимости из Godeps.json, Gopkg.lock или ряда других поддерживаемых форматов. Аргументом для перехода mod init является путь модуля, где можно найти модуль.

Это хорошее время, чтобы сделать паузу и запустить go build ./... и go test ./... прежде чем продолжить. Последующие шаги могут изменить ваш файл go.mod, поэтому, если вы предпочитаете использовать итеративный подход, этот файл go.mod будет ближе всего к вашей спецификации зависимостей перед модулями.

$ go mod tidy
go: downloading rsc.io/binaryregexp v0.2.1-0.20190524193500-545cabda89ca
go: extracting rsc.io/binaryregexp v0.2.1-0.20190524193500-545cabda89ca
$ cat go.sum
rsc.io/binaryregexp v0.2.1-0.20190524193500-545cabda89ca h1:FKXXXJ6G2bFoVe7hX3kEX6Izxw5ZKRH57DFBJmHCbkU=
rsc.io/binaryregexp v0.2.1-0.20190524193500-545cabda89ca/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
$

go mod tidy находит все пакеты, транзитивно импортированные пакетами в вашем модуле. Он добавляет новые требования к модулям для пакетов, не предоставляемых ни одним из известных модулей, и устраняет требования к модулям, которые не предоставляют никаких импортированных пакетов. Если модуль предоставляет пакеты, которые импортируются только проектами, которые еще не мигрировали в модули, требование к модулю будет помечено // indirect комментарием. Хорошей практикой всегда является запуск go mod tidy перед передачей файла go.mod в систему управления версиями.

Закончим, убедившись, что сборка кода и тесты пройдены:

$ go build ./...
$ go test ./...
[...]
$

Обратите внимание, что другие менеджеры зависимостей могут указывать зависимости на уровне отдельных пакетов или целых репозиториев (не модулей) и, как правило, не распознают требования, указанные в файлах зависимостей go.mod. Следовательно, вы можете не получить точно такую же версию каждого пакета, как раньше, и существует некоторый риск обновления последних изменений. Поэтому важно следовать приведенным выше командам с проверкой полученных зависимостей. Для этого запустите

$ go list -m all
go: finding rsc.io/binaryregexp v0.2.1-0.20190524193500-545cabda89ca
github.com/my/project
rsc.io/binaryregexp v0.2.1-0.20190524193500-545cabda89ca
$

и сравните полученные версии с вашим старым файлом управления зависимостями, чтобы убедиться, что выбранные версии соответствуют. Если вы нашли версию, которая была не той, которую вы хотели, вы можете узнать, почему с помощью go mod why -m и/или go mod graph, и обновите или понизьте зависмость до нужной версии с помощью go get. (Если запрашиваемая вами версия старше, чем ранее выбранная, go get понизит другие зависимости по мере необходимости для поддержания совместимости.) Например,

$ go mod why -m rsc.io/binaryregexp
[...]
$ go mod graph | grep rsc.io/binaryregexp
[...]
$ go get rsc.io/binaryregexp@v0.2.0
$

Без менеджера зависимостей

Для проекта Go без системы управления зависимостями начните с создания файла go.mod:

$ git clone https://go.googlesource.com/blog
[...]
$ cd blog
$ go mod init golang.org/x/blog
go: creating new go.mod: module golang.org/x/blog
$ cat go.mod
module golang.org/x/blog

go 1.12
$

Без файла конфигурации из предыдущего менеджера зависимостей go mod init создаст файл go.mod только с директивами module и go. В этом примере мы устанавливаем путь к модулю golang.org/x/blog, потому что это его собственный путь импорта. Пользователи могут импортировать пакеты с этим путем, и мы должны быть осторожны, чтобы не изменить его.

Директива module объявляет путь к модулю, а директива go объявляет ожидаемую версию языка Go, используемого для компиляции кода в модуле.

Затем запустите go mod tidy, чтобы добавить зависимости модуля:

$ go mod tidy
go: finding golang.org/x/website latest
go: finding gopkg.in/tomb.v2 latest
go: finding golang.org/x/net latest
go: finding golang.org/x/tools latest
go: downloading github.com/gorilla/context v1.1.1
go: downloading golang.org/x/tools v0.0.0-20190813214729-9dba7caff850
go: downloading golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7
go: extracting github.com/gorilla/context v1.1.1
go: extracting golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7
go: downloading gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637
go: extracting gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637
go: extracting golang.org/x/tools v0.0.0-20190813214729-9dba7caff850
go: downloading golang.org/x/website v0.0.0-20190809153340-86a7442ada7c
go: extracting golang.org/x/website v0.0.0-20190809153340-86a7442ada7c
$ cat go.mod
module golang.org/x/blog

go 1.12

require (
    github.com/gorilla/context v1.1.1
    golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7
    golang.org/x/text v0.3.2
    golang.org/x/tools v0.0.0-20190813214729-9dba7caff850
    golang.org/x/website v0.0.0-20190809153340-86a7442ada7c
    gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637
)
$ cat go.sum
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
git.apache.org/thrift.git v0.0.0-20180902110319-2566ecd5d999/go.mod h1:fPE2ZNJGynbRyZ4dJvy6G277gSllfV2HJqblrnkyeyg=
git.apache.org/thrift.git v0.0.0-20181218151757-9b75e4fe745a/go.mod h1:fPE2ZNJGynbRyZ4dJvy6G277gSllfV2HJqblrnkyeyg=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
[...]
$

go mod tidy добавил требования к модулю для всех пакетов, транзитивно импортируемых пакетами в вашем модуле, и создал go.sum с контрольными суммами для каждой библиотеки в конкретной версии. Закончим, убедившись, что код все еще собирается и тесты все еще проходят:

$ go build ./...
$ go test ./...
ok      golang.org/x/blog    0.335s
?       golang.org/x/blog/content/appengine    [no test files]
ok      golang.org/x/blog/content/cover    0.040s
?       golang.org/x/blog/content/h2push/server    [no test files]
?       golang.org/x/blog/content/survey2016    [no test files]
?       golang.org/x/blog/content/survey2017    [no test files]
?       golang.org/x/blog/support/racy    [no test files]
$

Обратите внимание, что когда go mod tidy добавляет требование, оно добавляет последнюю версию модуля. Если ваш GOPATH содержал более старую версию зависимости, которая впоследствии опубликовала критическое изменение, вы можете увидеть ошибки в go mod tidy, go build или go test. Если это произойдет, попробуйте перейти на более старую версию с помощью go get (например, go get github.com/broken/module@v1.1.0) или найдите время, чтобы сделать ваш модуль совместимым с последней версией каждой зависимости.

Тесты в режиме модуля

Некоторые тесты могут нуждаться в настройках после перехода на модули Go.

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

Если тест использует относительные пути (../package-in-another-module) для поиска и чтения файлов в другом пакете, он не будет выполнен, если пакет находится в другом модуле, который будет расположен в кэше версионного подкаталога модуля или по пути, указанном в директиве replace. В этом случае вам может потребоваться скопировать входные данные теста в ваш модуль или преобразовать входные данные теста из необработанных файлов в данные, встроенные в исходные файлы .go.

Если тест предполагает выполнение команд go в тесте в режиме GOPATH, он может завершиться неудачей. В этом случае вам может понадобиться добавить файл go.mod в исходное дерево для тестирования или явно отключить GO111MODULE = off.

Публикация релиза

Наконец, вы должны пометить (навесить tag) и опубликовать версию для вашего нового модуля. Это необязательно, если вы еще не выпустили ни одной версии, но без официального выпуска последующие пользователи будут зависеть от конкретных коммитов, использующих псевдо-версии, которые могут быть более сложными для поддержки.

$ git tag v1.2.0
$ git push origin v1.2.0

Ваш новый файл go.mod определяет канонический путь импорта для вашего модуля и добавляет новые минимальные требования к версии. Если ваши пользователи уже используют правильный путь импорта, и ваши зависимости не внесли существенных изменений, то добавление файла go.mod обратно совместимо, но это существенное изменение, которое может выявить существующие проблемы. Если у вас есть теги версий, вам следует увеличить минорную версию.

Импорт и канонические пути модулей

Каждый модуль объявляет свой путь к модулю в своем файле go.mod. Каждый оператор импорта, который ссылается на пакет в модуле, должен иметь путь к модулю в качестве префикса пути к пакету. Однако команда go может столкнуться с репозиторием, содержащим модуль, через множество различных путей удаленного импорта. Например, и golang.org/x/lint, и github.com/golang/lint разрешают использовать репозитории, содержащие код, размещенный на go.googlesource.com/lint. Файл go.mod, содержащийся в этом хранилище, объявляет его путь golang.org/x/lint, поэтому только этот путь соответствует допустимому модулю.

Go 1.4 предоставил механизм для объявления канонических путей импорта с использованием // import комментариев, но авторы пакетов не всегда предоставляли их. В результате код, написанный до модулей, мог использовать неканонический путь импорта для модуля, не обнаруживая ошибки для несоответствия. При использовании модулей путь импорта должен совпадать с каноническим путем к модулю, поэтому вам может потребоваться обновить утверждения import: например, вам может потребоваться изменить import "github.com/golang/lint" на import "golang.org/x/lint".

Другой сценарий, в котором канонический путь модуля может отличаться от пути к хранилищу, возникает для модулей Go в основной версии 2 или выше. Модуль Go с основной версией выше 1 должен включать суффикс основной версии в путь к модулю: например, версия v2.0.0 должна иметь суффикс /v2. Однако операторы импорта могли ссылаться на пакеты в модуле без этого суффикса. Например, немодульные пользователи github.com/russross/blackfriday/v2 версии 2.0 могут вместо этого импортировать его как github.com/russross/blackfriday, и им потребуется обновить путь импорта, включив суффикс /v2.

Заключение

Преобразование модулей в Go должно быть простым процессом для большинства пользователей. Случайные проблемы могут возникать из-за неканонических путей импорта или ломающих изменений в зависимостях.


Читайте также:


среда, 30 октября 2019 г.

Работа с ошибками в Go 1.13

Обработка ошибок Go как значений хорошо послужила за последнее десятилетие. Хотя поддержка ошибок в стандартной библиотеке была минимальной - только функции errors.New и fmt.Errorf, которые выдают ошибки, содержащие только сообщение, - встроенный интерфейс error позволяет программистам Go добавлять любую информацию, которую они пожелают. Все, что требуется, это тип, который реализует метод Error:

type QueryError struct {
    Query string
    Err   error
}

func (e *QueryError) Error() string { 
    return e.Query + ": " + e.Err.Error() 
}

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

Шаблон одной ошибки, содержащей другую, настолько распространен в коде Go, что после всестороннего обсуждения в Go 1.13 добавлена явная поддержка. В этом посте описываются дополнения к стандартной библиотеке, обеспечивающие эту поддержку: три новые функции в пакете errors и новый глагол форматирования для fmt.Errorf.

Перед подробным описанием изменений рассмотрим, как ошибки анализируются и конструируются в предыдущих версиях языка.

Ошибки до Go 1.13


Проверки ошибок

Ошибки в Go являются значениями. Программы принимают решения на основе этих значений несколькими способами. Наиболее распространенным является сравнение ошибки с nil, чтобы увидеть, не потерпела ли операция неудачу.

if err != nil {
    // что-то пошло не так
}

Иногда мы сравниваем ошибку с известным сторожевым значением, чтобы увидеть, произошла ли конкретная ошибка.

var ErrNotFound = errors.New("not found")

if err == ErrNotFound {
    // что-то не найдено
}

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

type NotFoundError struct {
    Name string
}

func (e *NotFoundError) Error() string { 
    return e.Name + ": not found" 
}

if e, ok := err.(*NotFoundError); ok {
    // e.Name не найдено
}

Добавление информации

Часто функция передает ошибку в стек вызовов при добавлении в нее информации, например, краткое описание того, что происходило, когда произошла ошибка. Простой способ сделать это - создать новую ошибку, которая включает в себя текст предыдущего сообщения:

if err != nil {
    return fmt.Errorf("decompress %v: %v", name, err)
}

Создание новой ошибки с помощью fmt.Errorf удаляет все из исходной ошибки, кроме текста. Как мы видели выше с QueryError, иногда мы можем захотеть определить новый тип ошибки, который содержит основную ошибку, сохранив ее для проверки кодом. Опять QueryError:

type QueryError struct {
    Query string
    Err   error
}

Программы могут заглянуть внутрь значения *QueryError, чтобы принимать решения на основе подлежащей ошибки. Иногда вы встретите, что это называется "разворачиванием" ("unwrapping") ошибки.

if e, ok := err.(*QueryError); ok && e.Err == ErrPermission {
    // запрос не выполнен из-за проблем с правами доступа
}

Тип os.PathError в стандартной библиотеке является одним из примеров ошибки, которая содержит другую.

Ошибки в Go 1.13


Метод Unwrap

Go 1.13 представляет новые функции для errors и fmt пакетов стандартной библиотеки для упрощения работы с ошибками, которые содержат другие ошибки. Наиболее важным из них является соглашение, а не изменение: ошибка, которая содержит другую ошибку, может реализовать метод Unwrap, возвращающий основную ошибку. Если e1.Unwrap() возвращает e2, то мы говорим, что e1 оборачивает e2 (e1 wraps e2), и вы можете развернуть e1 (unwrap e1), чтобы получить e2.

Следуя этому соглашению, мы можем задать типу QueryError метод Unwrap, который возвращает содержащуюся в QueryError ошибку:

func (e *QueryError) Unwrap() error { return e.Err }

Результат развертывания ошибки может сам по себе иметь метод Unwrap; это названо последовательностью ошибок, вызванных повторным развертыванием цепочки ошибок.

Проверка ошибок с Is и As

Пакет errors в Go 1.13 включает две новые функции для проверки ошибок: Is и As.

Функция errors.Is сравнивает ошибку со значением.

// Похоже на:
//   if err == ErrNotFound { … }
if errors.Is(err, ErrNotFound) {
    // что-то не найдено
}

Функция As проверяет, является ли ошибка определенным типом.

// Похоже на:
//   if e, ok := err.(*QueryError); ok { … }
var e *QueryError
if errors.As(err, &e) {
    // err является *QueryError, 
    // а в e устанавливается значение ошибки
}

В простейшем случае функция errors.Is ведет себя как сравнение с дозорной ошибкой, а error.As действует как утверждение типа. Однако при работе с обернутыми ошибками эти функции учитывают все ошибки в цепочке. Еще раз посмотрим на приведенный выше пример развертывания QueryError, чтобы изучить основную ошибку:

if e, ok := err.(*QueryError); ok && e.Err == ErrPermission {
    // запрос не выполнен из-за проблем с правами доступа
}

Используя функцию errors.Is, мы можем записать это как:

if errors.Is(err, ErrPermission) {
    // err, или какая-то ошибка, которую она содержит, 
    // является ошибкой прав доступа
}

Пакет errors также включает новую функцию Unwrap, которая возвращает результат вызова метода Unwrap ошибки или nil, если у ошибки нет метода Unwrap. Обычно лучше использовать errors.Is или errors.As, поскольку эти функции будут проверять всю цепочку за один вызов.

Оборачивание ошибок с %w

Как упоминалось ранее, обычно используется функция fmt.Errorf для добавления дополнительной информации об ошибке.

if err != nil {
    return fmt.Errorf("decompress %v: %v", name, err)
}

В Go 1.13 функция fmt.Errorf поддерживает новый глагол %w. Когда этот глагол присутствует, ошибка, возвращаемая fmt.Errorf, будет иметь метод Unwrap, возвращающий аргумент %w, который должен быть ошибкой. В остальном, %w идентичен %v.

if err != nil {
    // Возвращаем ошибку, которая разворачивается в err.
    return fmt.Errorf("decompress %v: %w", name, err)
}

Оборачивание ошибки с %w делает ее доступной для errors.Is и errors.As:

err := fmt.Errorf("access denied: %w”, ErrPermission)
...
if errors.Is(err, ErrPermission) ...

Оборачивать ли ошибки?

При добавлении дополнительного контекста к ошибке, либо с помощью fmt.Errorf, либо путем реализации пользовательского типа, вам необходимо решить, должна ли новая ошибка оборачивать оригинал. На этот вопрос нет однозначного ответа; это зависит от контекста, в котором создается новая ошибка. Оберните ошибку, чтобы показать ее вызывающей стороне. Не оборачивайте ошибку, если это приведет к раскрытию деталей реализации.

В качестве примера представим функцию Parse, которая считывает сложную структуру данных из io.Reader. Если возникает ошибка, мы хотим сообщить номер строки и столбца, на котором она произошла. Если ошибка возникает при чтении из io.Reader, мы хотим обернуть эту ошибку, чтобы разрешить проверку основной проблемы. Так как вызывающая сторона предоставила функции io.Reader, имеет смысл показать ошибку, вызванную ею.

Напротив, функция, которая делает несколько обращений к базе данных, вероятно, не должна возвращать ошибку, которая разворачивается в результате одного из этих вызовов. Если база данных, используемая функцией, является деталью реализации, то раскрытие этих ошибок является нарушением абстракции. Например, если функция LookupUser вашего пакета pkg использует пакет Go database/sql, то может возникнуть ошибка sql.ErrNoRows. Если вы вернете эту ошибку с помощью fmt.Errorf("accessing DB: %v", err), то вызывающая сторона не сможет заглянуть внутрь, чтобы найти sql.ErrNoRows. Но если функция вместо этого возвращает fmt.Errorf("accessing DB: %w", err), то вызывающая сторона может разумно написать

err := pkg.LookupUser(...)
if errors.Is(err, sql.ErrNoRows) …

На этом этапе функция всегда должна возвращать sql.ErrNoRows, если вы не хотите ломать своих клиентов, даже если вы переключаетесь на другой пакет базы данных. Другими словами, оборачивание ошибки делает эту ошибку частью вашего API. Если вы не хотите поддерживать эту ошибку как часть вашего API в будущем, вам не следует оборачивать ошибку.

Важно помнить, что независимо от того, оборачиваете вы или нет, текст ошибки будет одинаковым. Человек, пытающийся понять ошибку, будет иметь ту же информацию в любом случае; выбор заключается в том, предоставлять ли программам дополнительную информацию, чтобы они могли принимать более обоснованные решения, или скрывать эту информацию для сохранения уровня абстракции.

Настройка тестов ошибок с помощью методов Is и As

Функция errors.Is проверяет каждую ошибку в цепочке на соответствие целевому значению. По умолчанию ошибка соответствует цели, если они равны. Кроме того, ошибка в цепочке может объявить, что она соответствует цели путем реализации метода Is.

В качестве примера рассмотрим следующую ошибку, вдохновленную пакетом ошибок Upspin, который сравнивает ошибку с шаблоном, рассматривая только поля, отличные от нуля в шаблоне:

type Error struct {
    Path string
    User string
}

func (e *Error) Is(target error) bool {
    t, ok := target.(*Error)
    if !ok {
        return false
    }
    return (e.Path == t.Path || t.Path == "") &&
           (e.User == t.User || t.User == "")
}

if errors.Is(err, &Error{User: "someuser"}) {
    // поле User в err равно "someuser".
}

Функция errors.As аналогично обращается к методу As при его наличии.

Ошибки и пакеты API

Пакет, который возвращает ошибки (а большинство возвращает) должен описывать, на какие свойства этих ошибок могут положиться программисты. Хорошо разработанный пакет также позволит избежать возврата ошибок со свойствами, на которые нельзя полагаться.

Самая простая спецификация состоит в том, чтобы сказать, что операции либо завершаются успешно, либо завершаются неудачей, возвращая нулевую или ненулевую ошибку соответственно. Во многих случаях дополнительная информация не требуется.

Если мы хотим, чтобы функция возвращала идентифицируемое условие ошибки, такое как "item not found", мы могли бы вернуть ошибку, оборачивающую дозорную ошибку.

var ErrNotFound = errors.New("not found")

// FetchItem возвращает именованный элемент.
//
// Если элемент с таким именем не существует, 
// FetchItem возвращает ошибку оборачивающую ErrNotFound.
func FetchItem(name string) (*Item, error) {
    if itemNotFound(name) {
        return nil, fmt.Errorf("%q: %w", name, ErrNotFound)
    }
    // ...
}

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

Во всех случаях следует соблюдать осторожность, чтобы не раскрывать внутренние детали пользователю. Как мы уже говорили в разделе "Оборачивать ли ошибку" выше, когда вы возвращаете ошибку из другого пакета, вы должны преобразовать ошибку в форму, которая не раскрывает основную ошибку, если только вы не захотите вернуть эту конкретную ошибку в будущем.

f, err := os.Open(filename)
if err != nil {
    // *os.PathError возвращенная от os.Open 
    // это внутренняя деталь. Чтобы не показывать ее 
    // вызывающей стороне, упакем ее как новую ошибку
    // с тем же текстом. 
    // Используем глагол форматирования %v, так как
    // %w позволит вызывающей стороне 
    // развернуть исходную *os.PathError.
    return fmt.Errorf("%v", err)
}

Если функция определена как возвращающая ошибку, заключающую в себе какую-то дозорную ошибку или тип, не возвращайте основную ошибку напрямую.

var ErrPermission = errors.New("permission denied")
// DoSomething возвращает ошибку, 
// оборачивающую ErrPermission, если пользователь
// не имеет прав доступа к чему-либо.
func DoSomething() {
    if !userHasPermission() {
        // Если возвращаем ErrPermission напрямую, 
        // вызывающая сторона может прийти
        // к зависимости от точного значения ошибки, 
        // написав подобный код:
        //
        //     if err := pkg.DoSomething(); err == pkg.ErrPermission { … }
        //
        // Это может вызвать проблемы 
        // если мы хотим добавить дополнительный
        // контекст к ошибке в будущем. 
        // Чтобы избежать этого возвращаем ошибку,
        // оборачивающую дозорную ошибку,
        // так что пользователи всегда 
        // должны развернуть ее:
        //
        //     if err := pkg.DoSomething(); errors.Is(err, pkg.ErrPermission) { ... }
        return fmt.Errorf("%w", ErrPermission)
    }
    // ...
}

Заключение

Несмотря на то, что обсуждаемые изменения касаются только трех функций и глагола форматирования, ожидается, что они значительно улучшат способ обработки ошибок в программах Go. Ожидается, что оборачивание для обеспечения дополнительного контекста станет обычным явлением, помогая программам принимать лучшие решения и помогая программистам быстрее находить ошибки.


Читайте также:


вторник, 29 октября 2019 г.

Константы в Golang

Go - это статически типизированный язык, который не разрешает операции, которые смешивают числовые типы. Вы не можете добавить float64 к int или даже int32 к int. Тем не менее, допустимо писать 1e6*time.Second или math.Exp(1) или даже 1<<('\t'+2.0). В Go константы, в отличие от переменных, ведут себя почти как обычные числа. Этот пост объясняет, почему это так и что это значит.

Background: C

В первые дни размышления о Go говорили о ряде проблем, вызванных тем, как C и его потомки позволяют смешивать и сопоставлять числовые типы. Многие загадочные ошибки, сбои и проблемы с переносимостью вызваны выражениями, которые объединяют целые числа разных размеров и "знаковость". Хотя для опытного программиста C результат вычисления, как

unsigned int u = 1e9;
long signed int i = -1;
... i + u ...

может быть знаком, но это априори не очевидно. Насколько велик результат? Каково его значение? Результат со знаком или без?

Здесь прячутся неприятные ошибки.

C имеет набор правил, называемых "обычными арифметическими преобразованиями", и это показатель их тонкости, что они изменились за годы (вводя еще больше ошибок, задним числом).

При разработке Go было решено избежать этого минного поля, обязав не смешивать числовые типы. Если вы хотите добавить i и u, вы должны четко указать, каким должен быть результат. Указав

var u uint
var i int

вы можете написать либо uint(i)+u, либо i+int(u), с четко выраженным значением и типом сложения, но в отличие от C вы не можете написать i+u. Вы даже не можете смешивать int и int32, даже если int 32-битный тип.

Эта строгость устраняет общую причину ошибок и других сбоев. Это жизненно важное свойство Go. Но это имеет свою цену: иногда программистам приходится украшать свой код неуклюжими числовыми преобразованиями, чтобы ясно выразить свое значение.

А как насчет констант? Учитывая приведенные выше заявления, что может быть законным для записи i = 0 или u = 0? Какой тип 0? Было бы неразумно требовать, чтобы константы имели преобразования типов в простых контекстах, таких как i = int(0).

Вскоре стало ясно, что ответ заключается в том, чтобы заставить числовые константы работать не так, как они ведут себя в других C-подобных языках. После долгих размышлений и экспериментов, был придуман дизайн, который, почти всегда прав, освобождая программиста от постоянного преобразования констант, но при этом он может писать такие вещи, как math.Sqrt(2), без упреков от компилятора.

Кратко, константы в Go просто работают, по крайней мере в большинстве случаев. Посмотрим, как это происходит.

Терминология

Сначала быстрое определение. В Go const - это ключевое слово, вводящее имя для скалярного значения, такого как 2 или 3.14159 или "scrumptious". Такие значения, именованные или нет, называются константами в Go. Константы также могут быть созданы выражениями, построенными из констант, таких как 2+3 или 2+3i или math.Pi/2 или ("go"+"pher").

Некоторые языки не имеют констант, а другие имеют более общее определение константы или применение слова const. Например, в C и C++ const является классификатором типа (type qualifier), который может кодировать более сложные свойства более сложных значений.

Но в Go константа - это просто неизменное значение, и с этого момента мы говорим только о Go.

Строковые константы

Существует много видов числовых констант - целые числа, числа с плавающей запятой, руны, знаковые, беззнаковые, мнимые, сложные - поэтому начнем с более простой формы констант: строк. Строковые константы просты для понимания и предоставляют меньше места для изучения проблем с типами констант в Go.

Строковая константа заключает некоторый текст в двойные кавычки. (Go также имеет необработанные строковые литералы, заключенные в обратные кавычки ``, но для целей этого обсуждения они имеют все те же свойства.) Вот строковая константа:

"Hello, 世界"

Какой тип у этой строковой константы? Очевидный ответ - строка, но это неправильно.

Это нетипизированная строковая константа, то есть постоянная текстовая величина, которая еще не имеет фиксированного типа. Да, это строка, но это не значение Go типа string. Она остается нетипизированной строковой константой, даже если ей присвоено имя:

const hello = "Hello, 世界"

После этого объявления hello также является нетипизированной строковой константой. Нетипизированная константа - это просто значение, тип которого еще не определен, что заставило бы его подчиняться строгим правилам, предотвращающим объединение значений различных типов.

Именно это понятие нетипизированной константы позволяет использовать константы в Go с большой свободой.

Так что же тогда является типизированной строковой константой? Это та, которая получила тип, как следующая:

const typedHello string = "Hello, 世界"

Обратите внимание, что объявление typedHello имеет явный строковый тип перед знаком равенства. Это означает, что typedHello имеет строку типа Go и не может быть назначен переменной Go другого типа. То есть этот код работает:

var s string
s = typedHello
fmt.Println(s)

но этот код не работает

type MyString string
var m MyString
m = typedHello // Type error
fmt.Println(m)

Переменная m имеет тип MyString, и ей нельзя присвоить значение другого типа. Ему могут быть назначены только значения типа MyString, например:

const myStringHello MyString = "Hello, 世界"
m = myStringHello // OK
fmt.Println(m)

или путем принудительного решения проблемы с помощью преобразования, например так:

m = MyString(typedHello)
fmt.Println(m)

Возвращаясь к нашей нетипизированной строковой константе, у нее есть полезное свойство, заключающееся в том, что, поскольку у нее нет типа, ее присвоение типизированной переменной не вызывает ошибку типа. То есть мы можем написать

m = "Hello, 世界"

или

m = hello

потому что, в отличие от типизированных констант typedHello и myStringHello, нетипизированные константы "Hello, 世界" и hello не имеют типа. Присвоение их переменной любого типа, совместимой со строками, работает без ошибок.

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

Тип по умолчанию

Как программист на Go, вы наверняка видели много объявлений вроде

str := "Hello, 世界"

и теперь вы можете спросить: "Если константа нетипизирована, как str получает тип в этом объявлении переменной?" Ответ заключается в том, что нетипизированная константа имеет тип по умолчанию, неявный тип, который она передает значению, если нужен тип, в котором ничего не указано. Для нетипизированных строковых констант этот тип по умолчанию, очевидно, является string, поэтому

str := "Hello, 世界"

или

var str = "Hello, 世界"

означает точно то же, что и

var str string = "Hello, 世界"

Один из способов думать о нетипизированных константах заключается в том, что они живут в неком идеальном пространстве значений, пространстве, менее ограничивающем, чем система полного типа Go. Но чтобы что-то с ними сделать, нам нужно присвоить их переменным, и когда это произойдет, переменная (а не сама константа) нуждается в типе, и эта константа может сообщить переменной, какой у нее тип. В этом примере str становится значением типа string, потому что нетипизированная строковая константа дает объявлению тип по умолчанию, string.

В таком объявлении переменная объявляется с типом и начальным значением. Однако иногда, когда мы используем константу, назначение значения не так ясно. Например, рассмотрим это утверждение:

fmt.Printf("%s", "Hello, 世界")

Сигнатура fmt.Printf

func Printf(format string, a ...interface{}) (n int, err error)

то есть его аргументы (после format string) являются значениями интерфейса. Что происходит, когда fmt.Printf вызывается с нетипизированной константой, так это то, что значение интерфейса создается для передачи в качестве аргумента, а конкретный тип, хранимый для этого аргумента, является типом константы по умолчанию. Этот процесс аналогичен тому, что мы видели ранее, когда объявляли инициализированное значение, используя нетипизированную строковую константу.

Вы можете увидеть результат в этом примере, который использует формат %v для печати значения и %T для печати типа значения, передаваемого в fmt.Printf:

fmt.Printf("%T: %v\n", "Hello, 世界", "Hello, 世界")
// string: Hello, 世界

fmt.Printf("%T: %v\n", hello, hello)
// string: Hello, 世界

Если константа имеет тип, который входит в интерфейс, как показано в следующем примере:

fmt.Printf("%T: %v\n", myStringHello, myStringHello)
// main.MyString: Hello, 世界

Таким образом, типизированная константа подчиняется всем правилам типизированных значений в Go. С другой стороны, нетипизированная константа не несет тип Go таким же образом и может смешиваться и сопоставляться более свободно. Однако у нее есть тип по умолчанию, который предоставляется, когда никакая другая информация о типе не доступна.

Тип по умолчанию определенный синтаксисом

Тип по умолчанию для нетипизированной константы определяется ее синтаксисом. Для строковых констант единственным возможным неявным типом является строка. Для числовых констант неявный тип имеет большее разнообразие. Целочисленные константы по умолчанию равны int, константы с плавающей точкой - float64, константы рун - rune (псевдоним для int32), мнимые константы - complex128. Вот канонический оператор print, который неоднократно использовался для отображения типов по умолчанию в действии:

fmt.Printf("%T %v\n", 0, 0)
fmt.Printf("%T %v\n", 0.0, 0.0)
fmt.Printf("%T %v\n", 'x', 'x')
fmt.Printf("%T %v\n", 0i, 0i)

int 0
float64 0
int32 120
complex128 (0+0i)

Булевы константы (Booleans)

Все, что мы говорили о нетипизированных строковых константах, можно сказать и о нетипизированных булевых константах. Значения true и false являются нетипизированными логическими константами, которые могут быть назначены любой логической переменной, но как только задан тип, логические переменные не могут быть смешаны:

type MyBool bool
const True = true
const TypedTrue bool = true
var mb MyBool
mb = true      // OK
mb = True      // OK
mb = TypedTrue // Ошибка
fmt.Println(mb)

Константы с плавающей точкой

Константы с плавающей точкой во многом похожи на логические константы.

type MyFloat64 float64
const Zero = 0.0
const TypedZero float64 = 0.0
var mf MyFloat64
mf = 0.0       // OK
mf = Zero      // OK
mf = TypedZero // Ошибка
fmt.Println(mf)

Есть недостаток в том, что в Go есть два типа с плавающей точкой: float32 и float64. Тип по умолчанию для константы с плавающей точкой - float64, хотя нетипизированная константа с плавающей точкой может быть назначена значению float32:

var f32 float32
f32 = 0.0
f32 = Zero      // OK: Zero нетипизированна
f32 = TypedZero // Ошибка: TypedZero это float64, 
                // а не float32.
fmt.Println(f32)

Значения с плавающей точкой являются хорошим местом для представления концепции переполнения или диапазона значений.

Числовые константы живут в числовом пространстве произвольной точности; они просто обычные числа. Но когда они назначены переменной, значение должно соответствовать цели. Мы можем объявить константу с очень большим значением:

const Huge = 1e1000

В конце концов, это просто число, но мы не можем присвоить его или даже напечатать. Это утверждение даже не скомпилируется:

fmt.Println(Huge)

Ошибка: constant 1e+1000 overflows float64 (константа 1e+1000 переполняет float64), что верно. Но Huge может быть полезен: мы можем использовать его в выражениях с другими константами и использовать значение этих выражений, если результат может быть представлен в диапазоне float64. Утверждение,

fmt.Println(Huge / 1e999)

печатает 10, как и следовало ожидать.

Аналогичным образом, константы с плавающей точкой могут иметь очень высокую точность, так что арифметика с ними более точна. Константы, определенные в пакете math, даны с гораздо большим количеством цифр, чем доступно в float64. Вот определение math.Pi:

Pi = 3.14159265358979323846264338327950288419716939937510582097494459

Когда это значение присваивается переменной, часть точности будет потеряна; присваивание создаст значение float64 (или float32), наиболее близкое к значению высокой точности. Этот фрагмент

pi := math.Pi
fmt.Println(pi)

печатает 3.141592653589793.

Наличие такого большого количества цифр означает, что вычисления, такие как Pi/2 или другие более сложные вычисления, могут нести большую точность до тех пор, пока не будет присвоен результат, что облегчает запись вычислений с использованием констант без потери точности. Это также означает, что не бывает случаев, когда краевые случаи с плавающей точкой, такие как бесконечности, мягкие переполнения и NaN, возникают в постоянных выражениях. (Деление на постоянный ноль - это ошибка времени компиляции, и когда все является числом, нет такой вещи, как "не число".)

Сложные числа (Complex numbers)

Сложные константы ведут себя во многом как константы с плавающей точкой.

type MyComplex128 complex128
const I = (0.0 + 1.0i)
const TypedI complex128 = (0.0 + 1.0i)
var mc MyComplex128
mc = (0.0 + 1.0i) // OK
mc = I            // OK
mc = TypedI       // Ошибка
fmt.Println(mc)

Типом комплексного числа по умолчанию является complex128, версия с большей точностью, состоящая из двух значений float64.

Для ясности в нашем примере мы выписали полное выражение (0.0+1.0i), но это значение можно сократить до 0.0+1.0i, 1.0i или даже 1i.

Попробуем выполнить трюк. Мы знаем, что в Go числовая константа - это просто число. Что, если это число является комплексным числом без мнимой части, то есть является действительным? Вот одно из них:

const Two = 2.0 + 0i

Это нетипизированная комплексная константа. Даже если она не имеет мнимой части, синтаксис выражения определяет, что она имеет тип по умолчанию complex128. Поэтому, если мы используем его для объявления переменной, тип по умолчанию будет complex128. Фрагмент

s := Two
fmt.Printf("%T: %v\n", s, s)

печатает complex128: (2+0i). Но численно Two можно хранить в скалярном числе с плавающей точкой, float64 или float32, без потери информации. Таким образом, мы можем без проблем назначить Two как float64, либо в инициализации, либо в присваивании:

var f float64
var g float64 = Two
f = Two
fmt.Println(f, "and", g)

Выходными данными являются 2 и 2. Несмотря на то, что Two является сложной константой, ее можно назначить скалярным переменным с плавающей точкой. Эта способность константы "скрещивать" типы, подобные этой, окажется полезной.

Целые числа

Наконец мы приходим к целым числам. У них больше движущихся частей - много размеров, со знаком или без знака и т.д., но они играют по тем же правилам.

type MyInt int
const Three = 3
const TypedThree int = 3
var mi MyInt
mi = 3          // OK
mi = Three      // OK
mi = TypedThree // Ошибки
fmt.Println(mi)

Тот же пример может быть построен для любого целочисленного типа, а именно:

int int8 int16 int32 int64
uint uint8 uint16 uint32 uint64
uintptr

(плюс byte - псевдоним для uint8, и rune - псевдоним для int32). Это много, но модель работы констант должна быть уже достаточно знакома, чтобы вы могли видеть, как все будет происходить.

Как упоминалось выше, целые числа бывают нескольких форм, и каждая форма имеет свой собственный тип по умолчанию: int для простых констант, таких как 123 или 0xFF или -14, и rune для символов в кавычках, таких как 'a', '世' или '\r'.

Никакая форма константы не имеет в качестве типа по умолчанию целочисленный тип без знака (uint). Тем не менее, гибкость нетипизированных констант означает, что мы можем инициализировать целочисленные переменные без знака, используя простые константы, если нам все ясно о типе. Это аналогично тому, как мы можем инициализировать float64, используя комплексное число с нулевой мнимой частью. Вот несколько разных способов инициализации uint; все они эквивалентны, но все должны явно указывать тип, чтобы результат был без знака.

var u uint = 17
var u = uint(17)
u := uint(17)

Подобно проблеме диапазона, упомянутой в разделе о значениях с плавающей точкой, не все целочисленные значения могут вписываться во все целочисленные типы. Могут возникнуть две проблемы: значение может быть слишком большим или значение может быть отрицательным, назначаемым целому типу без знака. Например, int8 имеет диапазон от -128 до 127, поэтому константы вне этого диапазона никогда не могут быть назначены переменной типа int8:

var i8 int8 = 128 // Ошибка: слишком большое.

Аналогично, uint8, также известный как byte, имеет диапазон от 0 до 255, поэтому большая или отрицательная постоянная не может быть назначена для uint8:

var u8 uint8 = -1 // Ошибка: отрицательное значение.

Эта проверка типов может отловить такие ошибки:

type Char byte
var c Char = '世' // Ошибка: '世' имеет значение 0x4e16, 
                 // слишком большое.

Если компилятор жалуется на использование вами константы, это, вероятно, настоящая ошибка, подобная этой.

Упражнение: самый большой беззнаковый int

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

const MaxUint32 = 1<<32 - 1

но мы хотим uint, а не uint32. Типы int и uint имеют одинаковые неопределенные количества битов, 32 или 64. Поскольку количество доступных битов зависит от архитектуры, мы не можем просто записать одно значение.

Поклонники арифметики с двумя дополняющими компонентами, которые определены для использования целыми числами Go, знают, что представление -1 имеет все свои биты, установленные на 1, поэтому битовая комбинация -1 внутренне такая же, как и у наибольшего целого числа без знака. Поэтому мы можем подумать, что мы могли бы написать

const MaxUint uint = -1 // Ошибка: отрицательное значение

но это недопустимо, потому что -1 не может быть представлен беззнаковой переменной; -1 не находится в диапазоне значений без знака. Конверсия тоже не поможет по той же причине:

const MaxUint uint = uint(-1) // Ошибка: отрицательное значение

Несмотря на то, что во время выполнения значение -1 может быть преобразовано в целое число без знака, правила для константных преобразований запрещают этот тип приведения во время компиляции. То есть это работает:

var u uint
var v = -1
u = uint(v)

но только потому, что v является переменной; если бы мы сделали v константой, даже нетипизированной константой, мы вернулись бы на запрещенную территорию:

var u uint
const v = -1
u = uint(v) // Ошибка: отрицательное значение

Мы возвращаемся к нашему предыдущему подходу, но вместо -1 мы пробуем ^0, побитовое отрицание произвольного числа нулевых битов. Но это тоже не получается по той же причине: в пространстве числовых значений ^0 представляет бесконечное число единиц, поэтому мы теряем информацию, если присвоим ее любому целому числу фиксированного размера:

const MaxUint uint = ^0 // Ошибка: переполнение

Как тогда мы представляем самое большое целое число без знака как константу?

Ключ заключается в том, чтобы ограничить операцию количеством битов в uint и избегать значений, таких как отрицательные числа, которые не могут быть представлены в uint. Простейшим значением uint является типизированная константа uint(0). Если uint'ы имеют 32 или 64 бита, uint(0) имеет 32 или 64 нулевых бита соответственно. Если мы инвертируем каждый из этих битов, мы получим правильное количество битов, которое является наибольшим значением uint.

Поэтому мы не переворачиваем биты нетипизированной константы 0, мы переворачиваем биты типизированной константы uint(0). Вот наша константа:

const MaxUint = ^uint(0)
fmt.Printf("%x\n", MaxUint)
// Вывод: ffffffff 

Какое бы количество бит не потребовалось для представления uint в текущей среде исполнения (в песочнице это 32), эта константа правильно представляет наибольшее значение, которое может содержать переменная типа uint.

Числа

Концепция нетипизированных констант в Go означает, что все числовые константы, будь то целочисленные, с плавающей точкой, комплексные или даже символьные значения, живут в неком объединенном пространстве. Именно когда мы приводим их в вычислительный мир переменных, присваиваний и операций, фактические типы имеют значение. Но пока мы остаемся в мире числовых констант, мы можем смешивать и сопоставлять значения по своему усмотрению. Все следующие константы имеют числовое значение 1:

1
1.000
1e3-99.0*10-9
'\x01'
'\u0001'
'b' - 'a'
1.0+3i-3.0i

Поэтому, хотя они имеют разные неявные типы по умолчанию, записанные как нетипизированные константы, они могут быть назначены переменной любого целочисленного типа:

var f float32 = 1
var i int = 1.000
var u uint32 = 1e3 - 99.0*10.0 - 9
var c float64 = '\x01'
var p uintptr = '\u0001'
var r complex64 = 'b' - 'a'
var b byte = 1.0 + 3i - 3.0i

fmt.Println(f, i, u, c, p, r, b)

Выходные данные из этого фрагмента: 1 1 1 1 1 (1+0i) 1.

Вы даже можете делать такие сумасшедшие вещи, как

var f = 'a' * 1.5
fmt.Println(f)

что дает 145,5, что бессмысленно, кроме как доказать концепцию.

Но настоящая суть этих правил - гибкость. Эта гибкость означает, что, несмотря на то, что в Go в одном выражении запрещено смешивать переменные с плавающей точкой и целые числа, или даже переменные int и int32, можно писать

sqrt2 := math.Sqrt(2)

или

const millisecond = time.Second/1e3

или

bigBufferWithHeader := make([]byte, 512+1e6)

и результаты означают то, что вы ожидаете.

Потому что в Go числовые константы работают так, как вы ожидаете: как числа.


Читайте также:


четверг, 24 октября 2019 г.

Паттерны конкурентности в Golang: Context

На Go серверах каждый входящий запрос обрабатывается в своей собственной goroutine. Обработчики запросов часто запускают дополнительные goroutine для доступа к бэкэндам, таким как базы данных и службы RPC. Множеству goroutine, работающих над запросом, обычно требуется доступ к специфическим для запроса значениям, таким как личность (identity) конечного пользователя, токены авторизации и крайний срок запроса (request's deadline). Когда запрос отменяется или истекает время ожидания, все goroutine, работающие над этим запросом, должны быстро завершиться, чтобы система могла вернуть любые ресурсы, которые они используют.

В Google разработали пакет context, который позволяет легко передавать значения в области видимости запроса, сигналы отмены и крайние сроки (deadlines) через границы API всем goroutine, участвующим в обработке запроса. Пакет общедоступен как context. В этой посте описывается, как использовать пакет, и приводится полный пример.

Context (контекст)

Ядром пакета контекста является тип Context:

// Context переносит крайний срок (deadline), 
// сигнал отмены и значения в области видимости запроса 
// через границы API. 
// Его методы безопасны для одновременного использования 
// несколькими goroutine.
type Context interface {
    // Done возвращает канал, 
    // который закрыт при отмене этого Context
    // или при истечении времени ожидания.
    Done() <-chan struct{}

    // Err указывает, почему этот контекст 
    // был отменен после закрытия канала Done.
    Err() error

    // Deadline возвращает время, 
    // когда этот Context будет отменен, 
    // если таковой имеется.
    Deadline() (deadline time.Time, ok bool)

    // Value возвращает значение, 
    // связанное с key или nil, если нет.
    Value(key interface{}) interface{}
}

Метод Done возвращает канал, который действует как сигнал отмены для функций, выполняющихся от имени Context: когда канал закрыт, функции должны отказаться от своей работы и вернуться. Метод Err возвращает ошибку, указывающую, почему был отменен Context.

Context не имеет метода Cancel по той же причине, по которой канал Done является каналом только для приема (receive-only): функция, принимающая сигнал отмены, обычно не является той, которая отправляет сигнал. В частности, когда родительская операция запускает go-процедуры для подопераций, эти подоперации не должны быть в состоянии отменить родительский. Вместо этого функция WithCancel (описанная ниже) предоставляет способ отменить новое значение Context.

Context безопасен для одновременного использования несколькими goroutine. Код может передавать один и тот же Context любому количеству goroutine и отменять этот Context, чтобы сигнализировать им всем.

Метод Deadline позволяет функциям определять, должны ли они вообще начинать работу; если осталось слишком мало времени, может оказаться, что не стоит начинать работу. Код также может использовать крайний срок для установки таймаутов для операций ввода-вывода.

Value позволяет контексту переносить данные в области запроса. Эти данные должны быть безопасными для одновременного использования несколькими goroutine.

Производные контексты

Пакет context предоставляет функции для получения новых значений Context из существующих. Эти значения образуют дерево: при отмене Context все производные от него Context'ы также отменяются.

Background является корнем любого дерева Context; Background никогда не отменяется:

// Background возвращает пустой Context. 
// Он никогда не отменяется, не имеет срока,
// и не имеет значений. 
// Background обычно используется в main, init и тестах,
// и как Context верхнего уровня для входящих запросов.
func Background() Context

WithCancel и WithTimeout возвращают производные значения Context, которые могут быть отменены раньше, чем родительский Context. Context, связанный с входящим запросом, обычно отменяется, когда возвращается обработчик запроса. WithCancel также полезен для отмены избыточных запросов при использовании нескольких реплик. WithTimeout полезен для установки крайнего срока на запросы к внутренним серверам:

// WithCancel возвращает копию родительского элемента, 
// чей канал Done закрыт 
// как только parent.Done закрыт или вызвана отмена.
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)

// CancelFunc отменяет Context.
type CancelFunc func()

// WithTimeout возвращает копию родительского элемента, 
// чей канал Done закрыт
// как только parent.Done закрыт, 
// вызвана отмена (cancel) или истекло время ожидания. 
// Deadline нового Context более ранний 
// чем (текущее времени + тайм-аут) 
// и крайний срок для родителя, если он есть. 
// Если таймер все еще работает, 
// функция отмены освобождает его ресурсы.
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

WithValue предоставляет способ связать значения в области запроса с Context:

// WithValue возвращает копию родительского элемента, 
// метод Value которого возвращает val для key.
func WithValue(parent Context, key interface{}, val interface{}) Context

Лучший способ увидеть, как использовать пакет context - это работающий пример.

Пример: веб-поиск Google

Данный пример это HTTP-сервер, который обрабатывает URL-адреса, такие как /search?q=golang&timeout=1s, перенаправляя запрос "golang" в API веб-поиска Google (Google Web Search API) и представляя результаты. Параметр timeout указывает серверу отменить запрос по истечении этого срока.

Код разбит на три пакета:

  • server предоставляет main функцию и обработчик для /search.
  • userip предоставляет функции для извлечения IP-адреса пользователя из запроса и связывания его с Context.
  • google предоставляет функцию Search для отправки запроса в Google.

Программа server

Программа server обрабатывает запросы типа /search?q=golang, предоставляя первые несколько результатов поиска Google для golang. Она регистрирует handleSearch для обработки конечной точки /search. Обработчик создает начальный Context с именем ctx и организует его отмену при возврате обработчика. Если запрос включает timeout параметр URL-адреса, Context автоматически отменяется по истечении времени ожидания (timeout):

func handleSearch(w http.ResponseWriter, req *http.Request) {
    // ctx - это Context для этого обработчика. 
    // Вызов отмены закрывает
    // канал ctx.Done, 
    // который является сигналом отмены для запросов
    // запущенных этим обработчиком.
    var (
        ctx    context.Context
        cancel context.CancelFunc
    )
    timeout, err := time.ParseDuration(req.FormValue("timeout"))
    if err == nil {
        // У запроса есть timeout, 
        // поэтому создаем контекст, который
        // автоматически отменяется по истечении 
        // времени ожидания.
        ctx, cancel = context.WithTimeout(context.Background(), timeout)
    } else {
        ctx, cancel = context.WithCancel(context.Background())
    }
    // Отмена ctx, как только вернется handleSearch.
    defer cancel() 

Обработчик извлекает запращивамые параметры (query) из запроса (request) и извлекает IP-адрес клиента, вызывая пакет userip. IP-адрес клиента необходим для внутренних запросов, поэтому handleSearch присоединяет его к ctx:

    // Проверяем поисковый запрос.
    query := req.FormValue("q")
    if query == "" {
        http.Error(w, "no query", http.StatusBadRequest)
        return
    }

    // Сохраняем IP-адрес пользователя в ctx 
    // для использования кодом в других пакетах.
    userIP, err := userip.FromRequest(req)
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    ctx = userip.NewContext(ctx, userIP)

Обработчик вызывает google.Search с помощью ctx и запроса:

    // Запустить поиск Google и распечатать результаты.
    start := time.Now()
    results, err := google.Search(ctx, query)
    elapsed := time.Since(start)

Если поиск успешен, обработчик отображает результаты:

    if err := resultsTemplate.Execute(w, struct {
        Results          google.Results
        Timeout, Elapsed time.Duration
    }{
        Results: results,
        Timeout: timeout,
        Elapsed: elapsed,
    }); err != nil {
        log.Print(err)
        return
    }

Пакет userip

Пакет userip предоставляет функции для извлечения IP-адреса пользователя из запроса и связывания его с Context. Context предоставляет создание map ключ-значение, где ключ и значение имеют тип interface{}. Типы ключа должны поддерживать равенство, а значения должны быть безопасными для одновременного использования несколькими goroutine. Пакеты, такие как userip, скрывают детали создания этой map и предоставляют строго типизированный доступ к определенному значению Context.

Чтобы избежать коллизий ключей, userip определяет ключ неэкспортированного типа и использует значение этого типа в качестве ключа контекста:

// Тип ключа не экспортируется 
// для предотвращения конфликтов 
// с ключами контекста, определенными в других пакетах.
type key int

// userIPkey - это контекстный ключ 
// для IP-адреса пользователя. 
// Его нулевое значение произвольно. 
// Если этот пакет определит другие контекстные ключи, 
// они будут иметь разные целочисленные значения.
const userIPKey key = 0

FromRequest извлекает значение userIP из http.Request:

func FromRequest(req *http.Request) (net.IP, error) {
    ip, _, err := net.SplitHostPort(req.RemoteAddr)
    if err != nil {
        return nil, fmt.Errorf("userip: %q is not IP:port", req.RemoteAddr)
    }

NewContext возвращает новый Context, который несет предоставленное значение userIP:

func NewContext(ctx context.Context, userIP net.IP) context.Context {
    return context.WithValue(ctx, userIPKey, userIP)
}

FromContext извлекает userIP из Context:

func FromContext(ctx context.Context) (net.IP, bool) {
    // ctx.Value возвращает nil, 
    // если ctx не имеет значения для ключа;
    // утверждение типа net.IP 
    // возвращает ok=false для nil.
    userIP, ok := ctx.Value(userIPKey).(net.IP)
    return userIP, ok
}

Пакет google

Функция google.Search отправляет HTTP-запрос в API веб-поиска Google и анализирует кодированный в JSON результат. Она принимает параметр Context ctx и немедленно возвращается, если ctx.Done закрывается, пока запрос находится в исполнении.

Запрос API веб-поиска Google включает поисковый запрос и IP-адрес пользователя в качестве параметров запроса:

func Search(ctx context.Context, query string) (Results, error) {
    // Подготовливаем запрос API поиска Google.
    req, err := http.NewRequest("GET", 
        "https://ajax.googleapis.com/ajax/services/search/web?v=1.0", nil)
    if err != nil {
        return nil, err
    }
    q := req.URL.Query()
    q.Set("q", query)

    // Если ctx передает IP-адрес пользователя, 
    // перенаправляем его на сервер.
    // API Google используют 
    // IP-адрес пользователя для различения запросов, 
    // инициированных сервером 
    // от запросов конечного пользователя.
    if userIP, ok := userip.FromContext(ctx); ok {
        q.Set("userip", userIP.String())
    }
    req.URL.RawQuery = q.Encode()

Search использует вспомогательную функцию httpDo для выдачи HTTP-запроса и его отмены, если ctx.Done закрывается во время обработки запроса или ответа. Search передает замыкание чтобы httpDo обработал ответ HTTP:

    var results Results
    err = httpDo(ctx, req, func(resp *http.Response, err error) error {
        if err != nil {
            return err
        }
        defer resp.Body.Close()

        // Обрабатываем JSON результат поиска.
        var data struct {
            ResponseData struct {
                Results []struct {
                    TitleNoFormatting string
                    URL               string
                }
            }
        }
        if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
            return err
        }
        for _, res := range data.ResponseData.Results {
            results = append(results, Result{Title: res.TitleNoFormatting, URL: res.URL})
        }
        return nil
    })
    // httpDo ожидает возврата из предоставленного нами 
    // замыкания, поэтому безопасно читать результаты здесь.
    return results, err

Функция httpDo выполняет HTTP-запрос и обрабатывает его ответ в новой goroutine. Она отменяет запрос, если ctx.Done закрывается до выхода из goroutine:

func httpDo(ctx context.Context, req *http.Request, f func(*http.Response, error) error) error {
    // Запускаем HTTP-запрос в goroutine 
    // и передаем ответ в f.
    c := make(chan error, 1)
    req = req.WithContext(ctx)
    go func() { c <- f(http.DefaultClient.Do(req)) }()
    select {
    case <-ctx.Done():
        <-c // Ожидаем пока f вернется.
        return ctx.Err()
    case err := <-c:
        return err
    }
}

Адаптация кода для Context'ов

Многие серверные фреймворки предоставляют пакеты и типы для переноса значений в области запросов. Можно определить новые реализации интерфейса Context для связи между кодом, используя существующие фреймворки и код, который ожидает параметр Context.

Например, пакет Gorilla github.com/gorilla/context позволяет обработчикам связывать данные с входящими запросами, обеспечивая сопоставление HTTP-запросов с парами ключ-значение. В gorilla.go предоставляется реализация Context, метод Value которой возвращает значения, связанные с конкретным HTTP-запросом в пакете Gorilla.

Другие пакеты предоставили поддержку отмены, аналогичную Context. Например, Tomb предоставляет метод Kill, который сигнализирует об отмене путем закрытия Dying канала. Tomb также предоставляет методы для ожидания выхода из этих goroutine, аналогично sync.WaitGroup. В tomb.go предоставлена реализация Context, которая отменяется, когда отменяется родительский Context или уничтожается предоставленный Tomb.

Заключение

В Google требуется, чтобы программисты Go передавали параметр Context в качестве первого аргумента каждой функции на пути вызова между входящими и исходящими запросами. Это позволяет коду Go, разработанному многими различными командами, хорошо взаимодействовать. Context обеспечивает простой контроль времени ожидания и отмены и гарантирует, что критические значения, такие как учетные данные безопасности, корректно проходят программы Go.

Серверные фреймворки, которые хотят строить свой код на Context, должны предоставлять реализации Context для связи между их пакетами и теми, которые ожидают параметр Context. Их клиентские библиотеки будут принимать Context из вызывающего кода. Устанавливая общий интерфейс для данных области запроса и отмены запросов, Context облегчает разработчикам пакетов совместный доступ к коду для создания масштабируемых сервисов.


Читайте также:


вторник, 22 октября 2019 г.

Паттерны конкурентности в Golang: пайплайны, пример - получение дайджеста файлов

Рассмотрим более реалистичный пример пайплайна, чем в предыдущем посте.

MD5 - это алгоритм дайджеста сообщений, полезный в качестве контрольной суммы файла. Утилита командной строки md5sum выводит дайджест-значения для списка файлов.

% md5sum *.go
d47c2bbc28298ca9befdfbc5d3aa4e65  bounded.go
ee869afd31f83cbb2d10ee81b2b831dc  parallel.go
b88175e65fdcbc01ac08aaf1fd9b5e96  serial.go

Наш пример программы похож на md5sum, но вместо этого принимает в качестве аргумента один каталог и печатает значения дайджеста для каждого обычного файла в этом каталоге, отсортированные по имени пути.

% go run serial.go .
d47c2bbc28298ca9befdfbc5d3aa4e65  bounded.go
ee869afd31f83cbb2d10ee81b2b831dc  parallel.go
b88175e65fdcbc01ac08aaf1fd9b5e96  serial.go

main функция нашей программы вызывает вспомогательную функцию MD5All, которая возвращает карту (map) имени пути к значению дайджеста, затем сортирует и печатает результаты:

func main() {
    // Рассчитать MD5 сумму всех файлов 
    // в указанном каталоге,
    // затем печатаем результаты, 
    // отсортированные по имени пути.
    m, err := MD5All(os.Args[1])
    if err != nil {
        fmt.Println(err)
        return
    }
    var paths []string
    for path := range m {
        paths = append(paths, path)
    }
    sort.Strings(paths)
    for _, path := range paths {
        fmt.Printf("%x  %s\n", m[path], path)
    }
}

Функция MD5All находится в центре нашего обсуждения. В serial.go реализация не использует конкурентность, а просто читает и суммирует каждый файл по мере обхода дерева.

// MD5All читает все файлы в дереве файлов с корнем в root 
// и возвращает карту пути к файлу к MD5 сумме 
// содержимого файла. Если происходит сбой прохода
// по каталогу или сбой любой операции чтения, 
// MD5All возвращает ошибку.
func MD5All(root string) (map[string][md5.Size]byte, error) {
    m := make(map[string][md5.Size]byte)
    err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
        if err != nil {
            return err
        }
        if !info.Mode().IsRegular() {
            return nil
        }
        data, err := ioutil.ReadFile(path)
        if err != nil {
            return err
        }
        m[path] = md5.Sum(data)
        return nil
    })
    if err != nil {
        return nil, err
    }
    return m, nil
}

Параллельное получение digest'а

В parallel.go, мы разделили MD5All на двухступенчатый пайплайн. Первый этап, sumFiles, обходит дерево, получает digest каждого файла в новой go-процедуре и отправляет результаты по каналу с типом значения result:

type result struct {
    path string
    sum  [md5.Size]byte
    err  error
}

sumFiles возвращает два канала: один для результатов и другой для ошибки, возвращаемой filepath.Walk. Функция walk запускает новую go-процедуру для обработки каждого обычного файла, а затем проверяет done. Если done закрыт, walk немедленно останавливается:

func sumFiles(done <-chan struct{}, root string) (<-chan result, <-chan error) {
    // Для каждого обычного файла запускаем goroutine, 
    // которая суммирует файл и отправляет
    // результат в c. Ошибки walk отправляются в errc.
    c := make(chan result)
    errc := make(chan error, 1)
    go func() {
        var wg sync.WaitGroup
        err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
            if err != nil {
                return err
            }
            if !info.Mode().IsRegular() {
                return nil
            }
            wg.Add(1)
            go func() {
                data, err := ioutil.ReadFile(path)
                select {
                case c <- result{path, md5.Sum(data), err}:
                case <-done:
                }
                wg.Done()
            }()
            // Завершаем walk если done закрыт.
            select {
            case <-done:
                return errors.New("walk canceled")
            default:
                return nil
            }
        })
        // Walk вернулся, 
        // поэтому все вызовы wg.Add завершены. 
        // Начинаем goroutine для закрытия c, 
        // как только все посылки сделаны.
        go func() {
            wg.Wait()
            close(c)
        }()
        // select не нужен здесь, поскольку errc буферизован.
        errc <- err
    }()
    return c, errc
}

MD5All получает значения дайджеста от c. MD5All возвращается рано при ошибке, закрытие осуществляется с помощью defer:

func MD5All(root string) (map[string][md5.Size]byte, error) {
    // MD5All закрывает done канал при возврате; 
    // это может быть сделано
    // до получения всех значений от c и errc.
    done := make(chan struct{})
    defer close(done)

    c, errc := sumFiles(done, root)

    m := make(map[string][md5.Size]byte)
    for r := range c {
        if r.err != nil {
            return nil, r.err
        }
        m[r.path] = r.sum
    }
    if err := <-errc; err != nil {
        return nil, err
    }
    return m, nil
}

Ограниченный параллелизм

Реализация MD5All в parallel.go запускает новую goroutine для каждого файла. В каталоге со многими большими файлами это может аллоцировать больше памяти, чем доступно на машине.

Мы можем ограничить эти аллокации, ограничив число файлов, читаемых параллельно. В bounded.go мы делаем это, создавая фиксированное количество go-процедур для чтения файлов. Наш пайплайн теперь имеет три этапа: пройтись по дереву, прочитать файлы и получить дайджесты файлов и собрать дайджесты.

Первый этап, walkFiles, генерирует пути обычных файлов в дереве:

func walkFiles(done <-chan struct{}, root string) (<-chan string, <-chan error) {
    paths := make(chan string)
    errc := make(chan error, 1)
    go func() {
        // Закрываем paths канал после возврата Walk.
        defer close(paths)
        // select не требуется для этой отправки, 
        // поскольку errc буферизован.
        errc <- filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
            if err != nil {
                return err
            }
            if !info.Mode().IsRegular() {
                return nil
            }
            select {
            case paths <- path:
            case <-done:
                return errors.New("walk canceled")
            }
            return nil
        })
    }()
    return paths, errc
}

Средняя стадия запускает фиксированное число digester go-процедур, которые получают имена файлов из путей и отправляют результаты по каналу c:

func digester(done <-chan struct{}, paths <-chan string, c chan<- result) {
    for path := range paths {
        data, err := ioutil.ReadFile(path)
        select {
        case c <- result{path, md5.Sum(data), err}:
        case <-done:
            return
        }
    }
}

В отличие от наших предыдущих примеров, digester не закрывает свой выходной канал, так как несколько go-процедур отправляют по общему каналу. Вместо этого код в MD5All организует закрытие канала после завершения работы всех digester:

// Запускаем фиксированное количество go-процедур 
// для чтения и получения дайджеста файлов.
c := make(chan result)
var wg sync.WaitGroup
const numDigesters = 20
wg.Add(numDigesters)
for i := 0; i < numDigesters; i++ {
    go func() {
        digester(done, paths, c)
        wg.Done()
    }()
}
go func() {
    wg.Wait()
    close(c)
}()

Вместо этого мы могли бы сделать так чтобы каждый digester создавал и возвращал свой собственный выходной канал, но тогда нам потребовались бы дополнительные go-процедуры для сдувания (fan-in) результатов.

Последний этап получает все результаты от c, затем проверяет ошибку от errc. Эта проверка не может произойти раньше, так как до этого момента walkFiles может блокировать отправку значений вниз:

    m := make(map[string][md5.Size]byte)
    for r := range c {
        if r.err != nil {
            return nil, r.err
        }
        m[r.path] = r.sum
    }
    // Проверяем на пройзошел ли сбой Walk.
    if err := <-errc; err != nil {
        return nil, err
    }
    return m, nil
}

Заключение

В этом и предыдущем посте представлены методы построения потоковых пайплайнов данных в Go. Устранение сбоев в таких пайплайнах является сложной задачей, поскольку каждая ступень в пайплайне может блокировать попытки отправки значений в нисходящем направлении, а нисходящие ступени могут больше не заботиться о поступающих данных. Мы показали, как закрытие канала может транслировать сигнал "выполнено" на все go-процедуры, запущенные пайплайном, и определили правила правильного построения пайплайнов.


Читайте также:


воскресенье, 20 октября 2019 г.

Паттерны конкурентности в Golang: пайплайны

Примитивы Go для параллелизма упрощают создание потоковых пайплайнов данных, эффективно использующих ввод/вывод и несколько CPU. В этом посте приводятся примеры таких пайплайнов, освещаются тонкости, возникающие при сбое операций, и вводятся техники, позволяющие бороться с отказами.

Что такое пайплайн?

В Go нет формального определения пайплайна; это всего лишь одна из множества конкурентных программ. Неформально пайплайн представляет собой последовательность этапов, соединенных каналами, где каждый этап представляет собой группу goroutine, выполняющих одну и ту же функцию. На каждом этапе goroutine

  • получают значения из вышестоящего потока через входящие каналы
  • выполняют некоторую функцию над этими данными, обычно создавая новые значения
  • отправляют значения дальше вниз по исходящим каналам

Каждый этап имеет любое количество входящих и исходящих каналов, кроме первого и последнего этапов, которые имеют только исходящие или входящие каналы соответственно. Первый этап иногда называют источником (source) или продюсером (producer); последний этап, раковиной (sink) или потребитель (consumer).

Начнем с простого примера пайплайна, чтобы объяснить идеи и методы. В следующем посте будет представлен более реалистичный пример.

Квадратные числа

Рассмотрим пайплайн с тремя этапами.

Первый этап, gen, - это функция, которая преобразует список целых чисел в канал, который выдает целые числа из списка. Функция gen запускает go-процедуру, которая отправляет целые числа по каналу, и закрывает канал после отправки всех значений:

func gen(nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        for _, n := range nums {
            out <- n
        }
        close(out)
    }()
    return out
}

Второй этап, sq, получает целые числа из канала и возвращает канал, который выдает квадрат каждого полученного целого числа. После того, как входящий канал закрыт и этот этап отправил все значения в нисходящем направлении, он закрывает исходящий канал:

func sq(in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        for n := range in {
            out <- n * n
        }
        close(out)
    }()
    return out
}

Функция main устанавливает пайплайн и запускает последний этап: она получает значения со второго этапа и печатает каждый из них, пока канал не будет закрыт:

func main() {
    // Устанавливаем пайплайн.
    c := gen(2, 3)
    out := sq(c)

    // Потребляем вывод.
    fmt.Println(<-out) // 4
    fmt.Println(<-out) // 9
}

Запустить пример в песочнице play.golang.org

Поскольку sq имеет одинаковый тип для входящих и исходящих каналов, мы можем составить его любое количество раз. Мы также можем переписать main как range цикл, как и другие этапы:

func main() {
    // Устанавливаем пайплайн и потребляем вывод.
    for n := range sq(sq(gen(2, 3))) {
        fmt.Println(n) // 16 затем 81
    }
}

Запустить пример в песочнице play.golang.org

Fan-out, fan-in

Несколько функций могут читать с одного канала, пока этот канал не будет закрыт; это называется fan-out (раздуванием). Это дает возможность распределить работу среди группы работников, чтобы распараллелить использование CPU и I/O (ввод/вывод).

Функция может считывать данные с нескольких входов и работать до тех пор, пока все они не будут закрыты, путем мультиплексирования входных каналов в один канал, который закрыт, когда все входы закрыты. Это называется fan-in (вдуванием).

Мы можем изменить наш пайплайн для запуска двух экземпляров sq, каждый из которых читает из одного и того же входного канала. Мы вводим новую функцию merge, чтобы выполнять fan in для результатов:

func main() {
    in := gen(2, 3)

    // Распределяем работу sq по двум goroutine, 
    // которые обе читают из in.
    c1 := sq(in)
    c2 := sq(in)

    // Потребляем объединенный вывод из c1 и c2.
    for n := range merge(c1, c2) {
        fmt.Println(n) // 4 затем 9, или 9 затем 4
    }
}

Функция merge преобразует список каналов в один канал, запуская goroutine для каждого входящего канала, которая копирует значения в единственный исходящий канал. Как только все выходные goroutine были запущены, merge запускает еще одну goroutine, чтобы закрыть исходящий канал после того, как все отправки по этому каналу сделаны.

Отправка по закрытому каналу вызывает panic, поэтому важно убедиться, что все goroutine выполнены до вызова close. Тип sync.WaitGroup предоставляет простой способ организовать эту синхронизацию:

func merge(cs ...<-chan int) <-chan int {
    var wg sync.WaitGroup
    out := make(chan int)

    // Запускаем output goroutine 
    // для каждого входного канала в cs.
    // output копирует значения из c в out 
    // до тех пор пока c не закрыт, затем вызывает wg.Done.
    output := func(c <-chan int) {
        for n := range c {
            out <- n
        }
        wg.Done()
    }
    wg.Add(len(cs))
    for _, c := range cs {
        go output(c)
    }

    // Запускаем goroutine чтобы закрыть out 
    // когда все output goroutine заверешены.
    // Это должно начнаться после вызова wg.Add.
    go func() {
        wg.Wait()
        close(out)
    }()
    return out
}

Запустить пример в песочнице play.golang.org

Ранняя остановка

В функциях нашего пайплайна есть паттерн:

  • Этапы закрывают свои исходящие каналы, когда все операции отправки завершены.
  • Этапы продолжают получать значения из входящих каналов, пока эти каналы не будут закрыты.

Этот паттерн позволяет каждому этапу приема быть записаным как range цикл и гарантирует, что все goroutine завершают работу после того, как все значения были успешно отправлены в нисходящем направлении.

Но в реальных пайплайнах этапы не всегда получают все входящие значения. Иногда это сделано специально: получателю может потребоваться только подмножество значений для достижения прогресса. Чаще всего этап завершается рано, поскольку входящее значение представляет собой ошибку на более раннем этапе. В любом случае получателю не нужно ждать поступления оставшихся значений, и мы хотим, чтобы более ранние этапы прекратили создавать значения, которые не нужны на более поздних этапах.

В нашем примере пайплайна, если этап не может использовать все входящие значения, go-процедуры, пытающиеся отправить эти значения, будут блокироваться бесконечно:

    // Используем первое значение из вывода.
    out := merge(c1, c2)
    fmt.Println(<-out) // 4 or 9
    return
    // Поскольку мы не получили второе значение из out,
    // одна из output goroutine зависла 
    // при попытке отправить его.
}

Это утечка ресурсов: go-процедуры потребляют ресурсы памяти и среды выполнения, а ссылки на кучу (heap) в стеках go-процедур не позволяют данным быть утилизированными сборщиком мусора. go-процедуры не утилизируются сборщиком мусора; они должны выйти самостоятельно.

Нам необходимо организовать выход вышестоящих этапов нашего пайплайна, даже если последующие этапы не смогут получить все входящие значения. Один из способов сделать это - изменить исходящие каналы, чтобы они имели буферы. Буфер может содержать фиксированное количество значений; операции отправки завершаются немедленно, если в буфере есть место:

c := make(chan int, 2) // размер буфера 2
c <- 1 // проходит успешно сразу
c <- 2 // проходит успешно сразу
c <- 3 // блокируется, пока другая goroutine 
       // не выполнит <-c и не получит 1

Когда количество отправляемых значений известно во время создания канала, буфер может упростить код. Например, мы можем переписать gen, чтобы скопировать список целых чисел в буферизованный канал и избежать создания новой goroutine:

func gen(nums ...int) <-chan int {
    out := make(chan int, len(nums))
    for _, n := range nums {
        out <- n
    }
    close(out)
    return out
}

Возвращаясь к заблокированным go-процедурам в нашем пайплайне, мы могли бы рассмотреть возможность добавления буфера к исходящему каналу, возвращаемому функцией merge:

func merge(cs ...<-chan int) <-chan int {
    var wg sync.WaitGroup
    out := make(chan int, 1) // достаточно места 
                             // для непрочитанных вводов
    // ... остальное без изменений ...

Запустить пример в песочнице play.golang.org

Хотя это исправляет заблокированную goroutine в этой программе, это плохой код. Выбор размера буфера равным 1 здесь зависит от знания количества значений, которые получит merge, и количества значений, которые будут использовать последующие этапы. Это хрупко: если мы передадим дополнительное значение в gen или если нижестоящая стадия считывает какие-либо меньшие значения, у нас снова будут заблокированы go-процедуры.

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

Явная отмена

Когда main решает выйти без получения всех значений из out, она должна сообщить go-процедурам на вышестоящих этапах отказаться от значений, которые они пытаются отправить. Это делается путем отправки значений по каналу названному здесь done. Он отправляет два значения, поскольку потенциально могут быть два заблокированных отправителя:

func main() {
    in := gen(2, 3)

    // Распределяем работу sq по двум go-процедурам, 
    // которые обе читают из in.
    c1 := sq(in)
    c2 := sq(in)

    // Используем первое значение из вывода.
    done := make(chan struct{}, 2)
    out := merge(done, c1, c2)
    fmt.Println(<-out) // 4 или 9

    // Сообщаем оставшимся отправителям, что мы уходим.
    done <- struct{}{}
    done <- struct{}{}
}

go-процедуры-отправители заменяют свою операцию отправки утверждением select, которое выполняется либо при отправке, либо при получении значения от done. Тип значения done - пустая структура, потому что значение не играет в данном случае никакой роли: это событие приема, которое указывает на то, что отсылка должна быть прекращена. output go-процедуры продолжают работать в цикле на своем входящем канале, с, поэтому вышестоящие этапы не блокируются. (Мы обсудим через некоторое время, как позволить этому циклу вернуться рано.)

func merge(done <-chan struct{}, cs ...<-chan int) <-chan int {
    var wg sync.WaitGroup
    out := make(chan int)

    // Запускаем output go-процедуру 
    // для каждого входного канала в cs.
    // output копирует значения из c в out до тех пор, 
    // пока c не закроется или не получит значение из done,
    // затем output вызывает wg.Done.
    output := func(c <-chan int) {
        for n := range c {
            select {
            case out <- n:
            case <-done:
            }
        }
        wg.Done()
    }
    // ... остальное без изменений ...

Запустить пример в песочнице play.golang.org

У этого подхода есть проблема: каждому получателю в нисходящем направлении необходимо знать количество потенциально заблокированных отправителей в восходящем направлении и организовать сигнализацию этим отправителям о досрочном возврате. Отслеживание этих показателей утомительно и подвержено ошибкам.

Нам нужен способ сообщить неизвестному и неограниченному числу goroutine, чтобы они прекратили отправку своих значений вниз. В Go мы можем сделать это, закрыв канал, потому что операция приема на закрытом канале всегда может выполняться немедленно, давая нулевое значение типа элемента.

Это означает, что main может разблокировать всех отправителей, просто закрыв done канал. Это закрытие фактически является широковещательным сигналом для отправителей. Мы расширяем каждую из наших функций пайплайна, чтобы принимать done в качестве параметра, и организуем закрытие через утверждение defer, так что все пути возврата из main будут сигнализировать о завершении этапов пайплайна.

func main() {
    // Установливаем done канал, общий для всего пайплайна,
    // и закрываем этот канал при выходе из этого пайплайна 
    // в качестве сигнала для всех go-процедур, 
    // что мы начали выходить.
    done := make(chan struct{})
    defer close(done)

    in := gen(done, 2, 3)

    // Распределяем работу sq по двум goroutine, 
    // которые обе читают из in.
    c1 := sq(done, in)
    c2 := sq(done, in)

    // Используем первое значение из output.
    out := merge(done, c1, c2)
    fmt.Println(<-out) // 4 or 9

    // done будет закрыт отложенным вызовом.
}

Каждый из наших этапов пайплайна теперь может вернуться, как только done будет закрыт. output процедура в merge может вернуться, не опустошая свой входящий канал, так как она знает, что вышестоящий отправитель sq прекратит попытки отправки, когда done закрыт. output гарантирует, что wg.Done вызывается на всех путях возврата с помощью утверждения defer:

func merge(done <-chan struct{}, cs ...<-chan int) <-chan int {
    var wg sync.WaitGroup
    out := make(chan int)

    // Запускаем output goroutine 
    // для каждого входного канала в cs.
    // output копирует значения из c в out 
    // до закрытия c или done, 
    // затем вызывает wg.Done.
    output := func(c <-chan int) {
        defer wg.Done()
        for n := range c {
            select {
            case out <- n:
            case <-done:
                return
            }
        }
    }
    // ... остальное без изменений ...

Аналогично, sq может вернуться, как только done будет закрыт. sq обеспечивает закрытие своего out канала на всех путях возврата с помощью утверждения defer:

func sq(done <-chan struct{}, in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for n := range in {
            select {
            case out <- n * n:
            case <-done:
                return
            }
        }
    }()
    return out
}

Запустить пример в песочнице play.golang.org

Вот рекомендации по созданию пайплайнов:

  • Этапы закрывают свои исходящие каналы, когда все операции отправки завершены.
  • Этапы продолжают получать значения из входящих каналов до тех пор, пока эти каналы не будут закрыты или отправители не будут разблокированы.

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


Читайте также: