Что Go грядущий нам готовит? Разбираем долгожданный релиз 1.19
Привет всем гоферам! Я пишу на Go уже четыре года — начиная с версии 1.10. Сейчас я занимаюсь разработкой одних из важнейших сервисов в логистике Ozon.
Не успели мы до конца оправиться от долгожданного релиза Go 1.18, в котором нам предоставили дженерики, как команда Go анонсировала следующий бета-релиз Go 1.19.
Несмотря на то что Go 1.19 ещё не выпущен и окончательный вариант, выход которого ожидается в августе, может претерпеть некоторые изменения, разработчики всё же обещают, что больших изменений в языке не произойдёт.
Навстречу ̶п̶р̶и̶к̶л̶ю̶ч̶е̶н̶и̶я̶м̶ изменениям!
1. Область действия типов в объявлениях методов
Очень маленькое изменение исправление в языке, которое многие из нас не заметили бы (если, конечно, у вас нет проблем с наименованием переменных), связано с областью действия типа в объявлениях методов.
В версии Go 1.18 следующая конструкция приводила к ошибке компиляции из-за совпадения названия типа и наименования параметра при объявлении метода:
type T[T any] struct {}
func (T[T]) m() {} // error: T is not a generic type
Ещё один пример:
func (T1 T[T1]) Bar() {}
// error: T1 redeclared in this block
// error: other declaration of T1
В 1.19 изменились правила определения компилятором области действия параметров типа в объявлении функций и методов. Таким образом, оба примера скомпилируется без ошибок. Данное изменение в языке не ломает существующие программы.
2. Модель памяти
В новой версии Go была пересмотрена модель памяти, чтобы привести её в соответствие с моделью памяти, используемой в C, C++, Java, JavaScript, Rust и Swift.
На первый взгляд всё просто: наверное, увидим какие-то изменения во внутреннем устройстве памяти. Открываем страницу новой документации про модель памяти в Go — и всё оказывается не таким очевидным и простым…
В начале документации читаем:
Модель памяти Go определяет условия, при которых чтение переменной в одной горутине может гарантировать получение значений, записанных в ту же переменную в другой горутине.
Хорошо, возможно, какие-то изменения затронут условия синхронизации горутин и памяти? Читаем дальше:
Совет
Программы, изменяющие данные, к которым одновременно обращаются несколько горутин, должны сериализовать такой доступ.Чтобы сериализовать доступ, защитите данные с помощью каналов или других примитивов синхронизации из пакетов sync и sync/atomic.
На знакомый всем вопрос на собеседовании про гонки данных и синхронизацию доступа к памяти мы знаем ответ: «атомики, мьютексы, каналы — наше всё». Так что же нового?
Если вы вынуждены прочитать остальную часть этого документа, чтобы понять поведение вашей программы [при выполнении в многопоточной среде], вы слишком умны.
Не умничайте.
Ладно-ладно, на этом можно было бы закончить поиски сокровищ изменений и оставить это профессионалам, но, кажется, это не наш случай.
Далее в документации говорится что-то про гонки данных, свойство SC-DRF и про формальное определение модели памяти Go. Этому можно было бы посвятить отдельную статью, в которой расписана подробно вся теория многопроцессорных вычислений и моделей памяти, но, к счастью для нас, такие статьи уже есть, и я наткнулся на одну из них: «Модели памяти C++ и CLR». Эта статья — расшифровка-перевод доклада Саши Гольдштейна на конференции DotNext 2016 Piter. В нём рассматриваются фундаментальные концепции (атомарность, эксклюзивность доступа и изменение порядка выполнения программы) с примерами. Для нас важно следующее:
Операции в наших программах выполняются не обязательно в том порядке, в котором они были определены в коде, в силу разных объективных причин (оптимизации компилятора, особенности архитектуры процессора).
Последовательная согласованность (sequential consistency, SC) — модель, в которой результат выполнения многопоточного кода должен быть таким же, как в случае если бы код выполнялся в порядке, определённом программой.
Последовательная согласованность для программ без состояний гонки (sequential consistency for data-race-free programs, SC-DRF) — модель системы, обеспечивающей последовательную согласованность при условии отсутствия состояний гонок данных
Немного осмыслив эти термины, я понял, что всё это время модель памяти Go жила в этих же парадигмах.
Вернёмся к нашему совету из документации:
Программы, изменяющие данные, к которым одновременно обращаются несколько горутин, должны сериализовать такой доступ.
Чтобы сериализовать доступ, защитите данные с помощью каналов или других примитивов синхронизации из пакетов sync и sync/atomic.
Этот совет согласуется с поддержкой свойства SC-DRF в других языках: «Синхронизируйте доступ к ресурсам, чтобы устранить гонки данных, — и тогда программы будут вести себя так, как если бы они были последовательно согласованными, не оставляя необходимости понимать оставшуюся часть модели памяти».
Но в документе «Модель памяти Go» не говорилось явно, какой подход использован в Go, чем он похож на подходы в других языках, чем от них отличается и какие есть гарантии согласованности.
Все эти недочеты формального определения модели памяти описал Расс Кокс в серии своих статей:
(Советую прочитать на досуге для более полного понимания контекста)
В третьей статье автор предлагает внести правки в формальное определение модели памяти, а также расписать, какие гарантии есть в Go. Все эти замечания были учтены и нашли отражение в версии 1.19. Из важного, что следует знать и помнить разработчикам:
В отсутствие гонок данных программы на Go ведут себя так, как если бы все горутины были мультиплексированы на одном процессоре (свойство SC-DRF).
Используйте примитивы синхронизации в Go во избежание гонок данных (пакеты
sync
иsync/atomic
).В отличие от программ на C и C++ программы с гонками данных на Go могут завершиться с ошибкой, чтобы сообщить о наличии гонки данных. Но в то же время если программы на Go с гонками данных имеют определённую семантику с ограниченным числом результатов (как, например, в Java и JavaScript), то они продолжат работу, что делает ошибочные программы более надёжными и лёгкими для отладки.
Гонки в структурах данных, состоящих из нескольких машинных слов, могут привести к несогласованным значениям, не соответствующим одной записи. Когда значения зависят от согласованности внутренних пар (указатель — длина или указатель — тип), как в случае со значениями интерфейса, картами, срезами и строками в большинстве реализаций Go, такие гонки могут привести к произвольному повреждению памяти.
Для чего вообще нужен был пересмотр формальной модели Go? Всё просто: модель памяти служит договором между программистами и разработчиками компилятора. Первые знают, какие гарантии предоставляются, и разрабатывают программы с учётом модели памяти, а вторые эти гарантии должны обеспечивать. По этой причине в новой версии модели памяти также приведены примеры запрещённых оптимизаций компилятора.
3. Новые типы в пакете sync/atomic
Наряду с обновлением модели памяти в Go 1.19 представлены новые типы в пакете sync/atomic, которых так давно ждали разработчики:
Эти типы скрывают базовые значения, так что все обращения вынуждены использовать атомарные API (Load, Swap, Store).
Новые типы упрощают работу с атомиками в коде. Сейчас для примитивных типов, переменные которых мы хотим использовать явно только атомарно, приходится работать с абстрактным atomic.Value
и осуществлять приведение к нужному типу, что усложняет чтение кода:
type S struct {
counter atomic.Value // int64
}
func (s *S) SetCounter(v int64) {
s.counter.Store(v)
}
func (s *S) GetCounter() int64 {
return s.counter.Load().(int64)
}
В новой версии мы явно можем указать, какого типа atomic мы используем, чтобы избежать путаницы в коде, и нам не приходится делать лишних приведений типов:
type S struct {
counter atomic.Int64
}
func (s *S) SetCounter(v int64) {
s.counter.Store(v)
}
func (s *S) GetCounter() int64 {
return s.counter.Load()
}
4. Soft Memory Limit
В версии Go 1.19 появляется поддержка «мягкого» ограничения памяти программы. Это значит, что планировщик GC будет стараться не выходить за установленное ограничение, но не гарантирует, что в какой-то момент программа по потребляемой памяти не выйдет за этот предел.
Ограничение распространяется на:
размер кучи;
память, управляемую рантаймом.
Ограничение не учитывает:
пространство памяти, занимаемое двоичным файлом Go;
память, внешнюю по отношению к Go:
2.1. управляемую ОС от имени процесса (память ядра ОС, хранимая от имени процесса);
2.2. управляемую кодом, отличным от Go, внутри того же процесса (например, память, выделенную кодом C).
Ограничением можно управлять с помощью функции runtime/debug.SetMemoryLimit или переменной среды GOMEMLIMIT. Оно работает в сочетании с runtime/debug.SetGCPercent / GOGC и будет соблюдаться, даже если GOGC=off
, позволяя программам на Go всегда максимально использовать свой лимит памяти, в некоторых случаях повышая эффективность использования ресурсов.
GOMEMLIMIT
— это числовое значение в байтах с необязательным суффиксом единицы измерения. Поддерживаемые суффиксы включают: B, KiB, MiB, GiB, TiB
. По умолчанию это значение равно math.MaxInt64
(т. е. ограничение выключено). При маленьких значениях GOMEMLIMIT
ограничение лишь приведёт к постоянной работе GC.
5. Оптимизации, оптимизации, и ещё раз оптимизации
Рантайм станет создавать намного меньше рабочих горутин GC в бездействующих потоках операционной системы, когда приложение простаивает достаточно, чтобы вызвать периодический цикл GC.
Теперь размер начального стека горутин будет выделяться на основе исторического среднего использования стека. Это позволяет избежать некоторого раннего роста стека и копирования, необходимого в среднем случае, в обмен на не более чем двукратное неиспользованное пространство на горутинах в среднем.
Теперь компилятор использует таблицу переходов для реализации конструкции
switch
с большими целочисленными и строковыми операторами. Разработчики заявляют, что в некоторых случаях производительности оператораswitch
станет на 20% быстрее. Бенчмарки от авторов изменений можно посмотреть тут и тут.Алгоритм сортировки в пакете sort переписан для использования быстрой сортировки без шаблонов, которая работает быстрее в некоторых распространённых сценариях.
Продолжительность пауз stop-the-world значительно сократилась при сборе профилей горутин, что уменьшает их общее влияние на приложение.
Новые функции Bytes, String в пакете hash/maphash обеспечивают эффективный способ хеширования строки и/или слайса байтов, состоящих из одного элемента. Они эквивалентны использованию более общей функции Hash с одной записью, но позволяют избежать дополнительных затрат на настройку при небольших входных данных.
6. Прочие минорные изменения
Из приятных маленьких изменений можно отметить, что теперь неустранимые фатальные ошибки (например, concurrent map writes) выводят более простую трассировку, исключая некоторые метаданные.
Следующая программа с конкурентной запись в карту:
package main
import "sync"
func foo(wg *sync.WaitGroup, m map[int]any) {
defer wg.Done()
for i := 0; i < 10000; i++ {
m[1] = i
}
}
func main() {
var (
m = make(map[int]any)
wg = new(sync.WaitGroup)
)
wg.Add(2)
go foo(wg, m)
go foo(wg, m)
wg.Wait()
}
в ранних версиях Go выдаст большой стек-трейс паники:
fatal error: concurrent map writes
goroutine 18 [running]:
runtime.throw({0x1063067?, 0x0?})
/Users/lmoguchev/.gvm/gos/go1.18.3/src/runtime/panic.go:992 +0x71 fp=0xc00004a728 sp=0xc00004a6f8 pc=0x102b411
runtime.mapassign_fast64(0x0?, 0x0?, 0x1)
/Users/lmoguchev/.gvm/gos/go1.18.3/src/runtime/map_fast64.go:102 +0x2c5 fp=0xc00004a760 sp=0xc00004a728 pc=0x100da65
main.foo(0x0?, 0x0?)
/Users/lmoguchev/go/src/playground/playground/main.go:8 +0x93 fp=0xc00004a7c0 sp=0xc00004a760 pc=0x1054f73
main.main.func2()
/Users/lmoguchev/go/src/playground/playground/main.go:20 +0x2a fp=0xc00004a7e0 sp=0xc00004a7c0 pc=0x10551aa
runtime.goexit()
/Users/lmoguchev/.gvm/gos/go1.18.3/src/runtime/asm_amd64.s:1571 +0x1 fp=0xc00004a7e8 sp=0xc00004a7e0 pc=0x1051d81
created by main.main
/Users/lmoguchev/go/src/playground/playground/main.go:20 +0xea
. . .
exit status 2
В версии 1.19 трейс будет куда меньше (опущены ряд функций и значения регистров стека fp
, sp
, pc
):
fatal error: concurrent map writes
goroutine 5 [runnable]:
main.foo(0x0?, 0x0?)
/Users/lmoguchev/go/src/playground/playground/main.go:8 +0x92
created by main.main
/Users/lmoguchev/go/src/playground/playground/main.go:20 +0xea
. . .
exit status 2
Хоть и мелочь, но в логах такие стек-трейсы будет гораздо проще читать.
Можно и нужно пробовать!
Те, кому уже не терпится попробовать версию 1.19, могут установить её двумя способами.
Если уже установлен Go:
$ go install golang.org/dl/go1.19beta1@latest
$ go1.19beta1 download
Через менеджер версий Golang (gvm):
$ gvm install go1.19beta1
$ gvm use go1.19beta1
Советую воспользоваться вторым способом. С помощью менеджера версий можно очень легко и быстро переключаться между версиями Golang. Это особенно актуально, если хочется попробовать необкатанные версии Go и при этом иметь возможность переключаться на stable-версию для разработки.
Заключение
В целом версия 1.19 не внесла каких-то больших и значимых изменений в Go по сравнению с 1.18. В статье опущена часть минорных трансформаций. С их полным списком можно ознакомиться на странице релиза 1.19. Так как официальный релиз намечен на август, можно предположить, что ещё какие-то изменения обязательно появятся.
Надеюсь, статья была полезной. Ждём вместе следующих анонсов от команды Go!