Что Go грядущий нам готовит? Разбираем долгожданный релиз 1.19

Kate

Administrator
Команда форума
Привет всем гоферам! Я пишу на Go уже четыре года — начиная с версии 1.10. Сейчас я занимаюсь разработкой одних из важнейших сервисов в логистике Ozon.

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

3b9ecabce59793dab462dd65713ad2ad.jpg

Несмотря на то что 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 — и всё оказывается не таким очевидным и простым…

99ce1e69b8cdcb605d682416379695cd.png

В начале документации читаем:

Модель памяти Go определяет условия, при которых чтение переменной в одной горутине может гарантировать получение значений, записанных в ту же переменную в другой горутине.
Хорошо, возможно, какие-то изменения затронут условия синхронизации горутин и памяти? Читаем дальше:

Совет

Программы, изменяющие данные, к которым одновременно обращаются несколько горутин, должны сериализовать такой доступ.
Чтобы сериализовать доступ, защитите данные с помощью каналов или других примитивов синхронизации из пакетов sync и sync/atomic.
На знакомый всем вопрос на собеседовании про гонки данных и синхронизацию доступа к памяти мы знаем ответ: «атомики, мьютексы, каналы — наше всё». Так что же нового?

Если вы вынуждены прочитать остальную часть этого документа, чтобы понять поведение вашей программы [при выполнении в многопоточной среде], вы слишком умны.
Не умничайте.
1fcf41fb261a09b237fb00a823c242f3.png

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

Далее в документации говорится что-то про гонки данных, свойство SC-DRF и про формальное определение модели памяти Go. Этому можно было бы посвятить отдельную статью, в которой расписана подробно вся теория многопроцессорных вычислений и моделей памяти, но, к счастью для нас, такие статьи уже есть, и я наткнулся на одну из них: «Модели памяти C++ и CLR». Эта статья — расшифровка-перевод доклада Саши Гольдштейна на конференции DotNext 2016 Piter. В нём рассматриваются фундаментальные концепции (атомарность, эксклюзивность доступа и изменение порядка выполнения программы) с примерами. Для нас важно следующее:

  1. Операции в наших программах выполняются не обязательно в том порядке, в котором они были определены в коде, в силу разных объективных причин (оптимизации компилятора, особенности архитектуры процессора).
  2. Последовательная согласованность (sequential consistency, SC) — модель, в которой результат выполнения многопоточного кода должен быть таким же, как в случае если бы код выполнялся в порядке, определённом программой.
  3. Последовательная согласованность для программ без состояний гонки (sequential consistency for data-race-free programs, SC-DRF) — модель системы, обеспечивающей последовательную согласованность при условии отсутствия состояний гонок данных
Немного осмыслив эти термины, я понял, что всё это время модель памяти Go жила в этих же парадигмах.

77fdfbe9b8201c89fe32c61043ddf295.png

Вернёмся к нашему совету из документации:

Программы, изменяющие данные, к которым одновременно обращаются несколько горутин, должны сериализовать такой доступ.
Чтобы сериализовать доступ, защитите данные с помощью каналов или других примитивов синхронизации из пакетов sync и sync/atomic.
Этот совет согласуется с поддержкой свойства SC-DRF в других языках: «Синхронизируйте доступ к ресурсам, чтобы устранить гонки данных, — и тогда программы будут вести себя так, как если бы они были последовательно согласованными, не оставляя необходимости понимать оставшуюся часть модели памяти».

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

Все эти недочеты формального определения модели памяти описал Расс Кокс в серии своих статей:

  1. Модели аппаратной памяти
  2. Модели памяти языка программирования
  3. Обновление модели памяти Go
(Советую прочитать на досуге для более полного понимания контекста)

В третьей статье автор предлагает внести правки в формальное определение модели памяти, а также расписать, какие гарантии есть в Go. Все эти замечания были учтены и нашли отражение в версии 1.19. Из важного, что следует знать и помнить разработчикам:

  1. В отсутствие гонок данных программы на Go ведут себя так, как если бы все горутины были мультиплексированы на одном процессоре (свойство SC-DRF).
  2. Используйте примитивы синхронизации в Go во избежание гонок данных (пакеты sync и sync/atomic).
  3. В отличие от программ на C и C++ программы с гонками данных на Go могут завершиться с ошибкой, чтобы сообщить о наличии гонки данных. Но в то же время если программы на Go с гонками данных имеют определённую семантику с ограниченным числом результатов (как, например, в Java и JavaScript), то они продолжат работу, что делает ошибочные программы более надёжными и лёгкими для отладки.
  4. Гонки в структурах данных, состоящих из нескольких машинных слов, могут привести к несогласованным значениям, не соответствующим одной записи. Когда значения зависят от согласованности внутренних пар (указатель — длина или указатель — тип), как в случае со значениями интерфейса, картами, срезами и строками в большинстве реализаций 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 будет стараться не выходить за установленное ограничение, но не гарантирует, что в какой-то момент программа по потребляемой памяти не выйдет за этот предел.

Ограничение распространяется на:

  1. размер кучи;
  2. память, управляемую рантаймом.
Ограничение не учитывает:

  1. пространство памяти, занимаемое двоичным файлом Go;
  2. память, внешнюю по отношению к 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. Оптимизации, оптимизации, и ещё раз оптимизации​

e7c4c4bf92047ccfb8f99a6dce4503b8.png

  • Рантайм станет создавать намного меньше рабочих горутин 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
Хоть и мелочь, но в логах такие стек-трейсы будет гораздо проще читать.

Можно и нужно пробовать!​

8408fa7cc4b23a9bb6506c269bb7c763.png

Те, кому уже не терпится попробовать версию 1.19, могут установить её двумя способами.

  1. Если уже установлен Go:
$ go install golang.org/dl/go1.19beta1@latest
$ go1.19beta1 download
  1. Через менеджер версий Golang (gvm):
$ gvm install go1.19beta1
$ gvm use go1.19beta1
Советую воспользоваться вторым способом. С помощью менеджера версий можно очень легко и быстро переключаться между версиями Golang. Это особенно актуально, если хочется попробовать необкатанные версии Go и при этом иметь возможность переключаться на stable-версию для разработки.

Заключение​

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

Надеюсь, статья была полезной. Ждём вместе следующих анонсов от команды Go!

 
Сверху