Go представляет собой статически типизированный компилируемый язык со сборщиком мусора. Его создали в Google в 2007 году Роберт Гризмер, Роб Пайк и Кен Томпсон, чтобы писать большие сетевые серверы с быстрой сборкой и простым, читаемым кодом.
История: открытый код с 2009 года, версия 1.0 в 2012-м, новый релиз каждый февраль и август.
Сильные стороны: компиляция за секунды в один статический бинарник; горутины и каналы встроены в язык; маленькая спецификация, которую вся команда читает одинаково; сильная стандартная библиотека (net/http, encoding/json, testing); gofmt, go test и go vet из коробки; обещание совместимости Go 1, благодаря которому старый код продолжает собираться.
Слабые стороны: многословная обработка ошибок (if err != nil); нет типов-сумм и настоящих enum; дженерики появились только в 1.18, а дженерик-методы появились только в 1.27; сборщик мусора даёт меньше контроля над памятью и задержками, чем C++ или Rust; nil-интерфейсы и общие массивы под слайсами удивляют новичков.
Где уместен: бэкенд-сервисы, CLI и инфраструктура: Docker, Kubernetes, Terraform и Prometheus написаны на Go. Плохо подходит для GUI, систем жёсткого реального времени и тяжёлых численных расчётов.
GoJunior
Основы Go
Синтаксис, нулевые значения, константы и iota, control flow, defer и замыкания
Закрыто вопросов
0 / 25
- 0Повторить
- 13Часто спрашивают
Вопросы
Go отказывается от возможностей, которые дают второй способ написать то же самое или прячут поток управления, и у каждой есть одна простая замена. Это же честный ответ на вопрос «объектно-ориентированный ли Go»: в нём есть типы с методами и интерфейсы, но нет классов и наследования.
**Тернарный ?:**: if/else; для самого частого случая есть встроенные min и max (Go 1.21).
**while, do...while**: for cond {} и for {} с break.
Наследование: встраивание структур (embedding) для переиспользования и интерфейсы, которые реализуются неявно, для полиморфизма.
Исключения: ошибки как обычные возвращаемые значения с проверкой if err != nil; panic оставлен для багов и невозможных состояний.
Перегрузка и аргументы по умолчанию: разные имена (strings.Split, strings.SplitN), вариативные параметры или структура с опциями.
Неявные преобразования: явное T(x) для каждого преобразования.
Цена в том, что местами больше строк; выигрыш в том, что любой Go-разработчик читает код одинаково.
Пакет объединяет все .go-файлы одной директории с одинаковым объявлением package. Импортируют его по пути (путь модуля из go.mod плюс директория), и снаружи видны только имена, которые начинаются с заглавной буквы.
Приватность принадлежит пакету, а не файлу: файлы одного пакета видят строчные имена друг друга без всякого импорта.
Правило действует и на поля структур, и на методы, поэтому encoding/json игнорирует поля со строчной буквы.
Пакет в директории internal/ могут импортировать только из дерева, которое начинается у её родителя. Так делят код внутри модуля, не превращая его в публичный API.
package main с функцией func main() собирается в исполняемый файл; любой другой пакет становится библиотекой.
Неиспользуемый импорт вызывает ошибку компиляции, и два пакета не могут импортировать друг друга.
Все три компилируют одинаково и отличаются только тем, куда попадает бинарник: go run собирает его во временной директории, запускает и удаляет; go build кладёт его в текущую директорию (или по пути из -o); go install устанавливает его в $GOBIN, по умолчанию ~/go/bin.
На библиотечном пакете go build не создаёт файла: он только компилирует и проверяет типы, поэтому в CI запускают go build ./....
У всех трёх общий кэш сборки (go env GOCACHE), так что повторная сборка перекомпилирует только изменённое.
Для кросс-компиляции нужны две переменные: GOOS=linux GOARCH=arm64 go build. С CGO_ENABLED=0 получается полностью статический бинарник, который работает в образах scratch и distroless.
go run нужен для разработки. В прод отправляют результат go build: контейнеру не нужен тулчейн, и он не компилирует код при каждом старте.
var объявляет переменную где угодно (с типом, с начальным значением или с тем и другим); короткая форма := объявляет и выводит тип за один шаг, но работает только внутри тела функции. На уровне пакета любое объявление начинается с ключевого слова: var, const, type или func.
var n int даёт нулевое значение, var n = 5 выводит int, var n int64 = 5 задаёт тип явно.
Слева от := должно быть хотя бы одно новое имя: в data, err := f(), когда err уже объявлена в той же области видимости, data объявляется, а err только получает значение.
var нужен, когда значения ещё нет (var rows []Row) или когда литерал дал бы не тот тип (var id uint64 = 7).
Неиспользуемая локальная переменная вызывает ошибку компиляции declared and not used; неиспользуемая переменная уровня пакета не вызывает.
:= во вложенном блоке объявляет новую переменную, которая скрывает внешнюю. Это затенение (shadowing), частый источник багов.
Нулевым значением называют то, что лежит в переменной, объявленной без инициализатора: 0 у чисел, false у bool, "" у строк, nil у указателей, слайсов, map, каналов, функций и интерфейсов. У структур и массивов каждое поле или элемент получает своё нулевое значение.
nil-слайс: len равен 0, append и range работают; делать make заранее не нужно.
nil-map: чтение возвращает нуль типа значения, delete ничего не делает, а запись паникует с assignment to entry in nil map.
nil-канал: отправка и получение блокируются навсегда; этим пользуются в select, чтобы выключить ветку. close паникует.
nil-указатель: разыменование паникует с invalid memory address or nil pointer dereference.
Неинициализированной памяти в Go нет, поэтому стоит проектировать типы так, чтобы их нулевое значение было готово к работе, как у sync.Mutex, bytes.Buffer и strings.Builder, которым не нужен конструктор.
Константой называют значение, известное на этапе компиляции: число, строка, булево значение или руна, объявленное через const. iota служит счётчиком внутри блока const, который начинается с 0 и растёт на единицу с каждой строкой, поэтому enum в Go представляет собой именованный тип плюс блок констант с iota.
Строка без выражения повторяет предыдущее выражение со следующим iota, так что новое значение добавляется просто новым именем. _ пропускает значение, а выражения тоже работают: KB = 1 << (10 * iota).
Нетипизированная константа (const timeout = 30) не имеет типа, пока её не использовали, поэтому timeout * time.Second компилируется как time.Duration. С var timeout = 30 та же строка падает: mismatched types int and time.Duration.
Типизированная константа (const timeout int = 30) проверяется как переменная этого типа.
Enum в Go не закрыт: State(42) компилируется. Проверяйте значения, пришедшие извне, и добавьте метод String(), руками или через генератор stringer.
На iota == 0 ставьте честное значение по умолчанию вроде StateUnknown: это заодно нулевое значение типа.
int и uint занимают 64 бита на 64-битных платформах и 32 бита на 32-битных. Целочисленное переполнение во время выполнения молча «заворачивается», целочисленное деление на ноль паникует, а деление на ноль у чисел с плавающей точкой даёт +Inf, -Inf или NaN.
Типы фиксированного размера: от int8 до int64, от uint8 до uint64, float32 и float64; byte и rune служат алиасами для uint8 и int32.
Переполнение идёт по правилам дополнительного кода и без ошибки: int8 со значением 127 плюс один даёт -128. Константу, которая не помещается в тип, ловит уже компилятор: var b int8 = 128 падает с (overflows).
У uint нет отрицательных значений, поэтому uint(0) - 1 заворачивается в максимальное значение; берите int, если не нужны битовые операции или полный беззнаковый диапазон.
Целочисленное деление отбрасывает дробную часть в сторону нуля: -7/2 равно -3, а -7%2 равно -1.
Деление на переменную со значением 0 паникует с integer divide by zero, деление на константу 0 не компилируется. Результаты с плавающей точкой проверяют через math.IsInf и math.IsNaN.
Где переполнение критично (деньги, счётчики из пользовательского ввода), сравнивайте с math.MaxInt64 до операции или берите math/big.
Любое преобразование пишется явно как T(x): Go никогда не преобразует числовые типы сам, даже int в int64, потому что тихие преобразования служат классическим источником переполнений и потери точности. Смешение типов даёт ошибку компиляции вроде mismatched types int64 and int.
Преобразование между числовыми типами всегда компилируется и может молча терять данные: при v := 300 значение int8(v) равно 44, а при f := 3.9 значение int(f) равно 3; дробная часть отбрасывается, а не округляется (для округления есть math.Round).
Преобразуйте до деления. При done, total := 3, 4 выражение float64(done/total) равно 0, потому что целочисленное деление уже отбросило дробь; float64(done) / float64(total) даёт 0.75.
string(rune(65)) даёт "A", а не "65", и go vet ругается на string(i) от целого числа. Числа и текст преобразуют через strconv.Itoa и strconv.Atoi, которая возвращает ошибку на вводе вроде "42x".
Исключение составляют нетипизированные константы: 2 * time.Second работает, потому что константа принимает тип того места, где её использовали.
for является единственным ключевым словом цикла в Go, и у него три формы: с тремя частями for i := 0; i < n; i++, только с условием for cond {} (это и есть while) и пустой for {}, который крутится до break или return. for ... range перебирает значения.
слайс, массив: индекс и копия элемента; изменение копии не меняет слайс, поэтому пишите s[i].
строка: смещение в байтах и rune: range декодирует UTF-8, поэтому смещения перескакивают через многобайтовые символы.
map: ключ и значение в намеренно случайном порядке.
канал: значения, пока канал не закроют.
int (Go 1.22): for i := range 10 считает от 0 до 9.
функция (Go 1.23): итератор вроде iter.Seq[K], например for k := range maps.Keys(m).
Выражение после range вычисляется один раз, до первой итерации. do...while в языке нет.
Он напечатает run a, stopping, run b. break выходит из ближайшего for, switch или select, поэтому здесь он выходит только из switch, и цикл переходит к "b".
Пометьте цикл меткой и выходите по ней: Jobs: for ... и break Jobs. continue Jobs работает так же; метка остаётся единственным способом указать на внешнюю конструкцию.
Или вынесите цикл в отдельную функцию и делайте return: это часто читается лучше метки.
case в Go никогда не проваливается в следующий сам: каждый case заканчивается неявным break, поэтому явный ничего не изменил. fallthrough должен быть последним оператором case и входит в тело следующего case, не проверяя его условие.
В одном case можно перечислить несколько значений, case "a", "b":, а switch без выражения означает switch true; это идиоматичная замена длинной цепочки if/else if.
Та же ловушка возникает с break внутри select в цикле.
Функция может вернуть несколько значений, и по соглашению последнее из них имеет тип error. Пустой идентификатор _ служит заглушкой только для записи: она принимает значение, которое вы обязаны получить, но которое вам не нужно.
Вызывающий принимает либо все результаты, либо ни одного: n := strconv.Atoi(s) падает с assignment mismatch: 1 variable but strconv.Atoi returns 2 values.
_ отбрасывает результаты и значения цикла: _, err := w.Write(b), for _, v := range s.
import _ "github.com/jackc/pgx/v5/stdlib" импортирует пакет только ради побочного эффекта его init(); здесь это регистрация драйвера database/sql.
var _ io.Writer = (*Buffer)(nil) проверяет на этапе компиляции, что тип реализует интерфейс.
Отбросить error через _ можно, но это запах кода, и линтеры вроде errcheck об этом сообщают.
Вариативная функция принимает любое число аргументов одного типа через последний параметр, как func sum(xs ...int) int; внутри функции xs оказывается обычным []int. Чтобы передать готовый слайс, его «разворачивают»: sum(nums...).
Вариативным может быть только последний параметр. Вызов sum() без аргументов передаёт nil-слайс, с которым len и range работают нормально.
sum(nums...) передаёт сам слайс, а не копию, поэтому если функция пишет в xs[i], вызывающий это увидит. Для перечисленных аргументов, sum(1, 2, 3), создаётся новый слайс.
Смешивать две формы нельзя: sum(1, nums...) не скомпилируется; соберите один слайс через append([]int{1}, nums...).
[]string нельзя развернуть в ...any: fmt.Println(strs...) падает с cannot use strs (variable of type []string) as []any value. Сначала скопируйте значения в []any.
Go всегда передаёт по значению: функция получает копию каждого аргумента. Чтобы функция могла изменить переменную вызывающего или чтобы не копировать большую структуру, передают указатель; копируется сам указатель, но обе копии смотрят на одни и те же данные.
Копия поверхностная. Слайс, map и канал ссылаются на свои данные, поэтому вызываемая функция может менять элементы слайса или записи в map, но append с переаллокацией или присваивание параметру нового значения останутся локальными.
Указатель берут, когда функция должна изменить значение, когда структура большая или когда тип вообще нельзя копировать (всё, что содержит sync.Mutex).
Значение берут для маленьких данных только для чтения вроде time.Time или структуры из двух полей: копировать дёшево, значения могут остаться на стеке, а указатели часто выталкивают данные в кучу.
&x берёт адрес, *p разыменовывает, а p.Field разыменовывает автоматически. Арифметики указателей вне unsafe нет.
Вернуть указатель на локальную переменную безопасно: escape-анализ перенесёт переменную в кучу.
new(T) работает с любым типом: выделяет обнулённое значение T и возвращает *T. make работает только со слайсами, map и каналами: инициализирует их внутреннюю структуру и возвращает само значение, а не указатель.
Слайсам, map и каналам нужен make, потому что их нулевое значение равно nil: в make(map[string]int) уже можно писать, make([]int, 0, 100) задаёт длину и ёмкость, make(chan int, 10) задаёт размер буфера.
new(map[string]int) компилируется, но возвращает указатель на nil-map, и запись через него всё равно паникует.
На практике new встречается редко: &User{Name: "ada"} выделяет память и задаёт поля за один шаг.
С Go 1.26 new принимает и выражение: new(int64(30)) возвращает *int64 со значением 30, что удобно для необязательных полей-указателей.
Ни то ни другое не решает, где окажется память: в куче или на стеке; это решает escape-анализ.
defer откладывает вызов до выхода из окружающей функции (через return или при разворачивании паники). Отложенные вызовы выполняются в порядке LIFO («последним пришёл, первым вышел»), а их аргументы вычисляются сразу, на строке с defer.
Обычно ресурс освобождают сразу после захвата: defer f.Close(), defer mu.Unlock(). Тогда уборка выполнится на любом пути выхода.
Поскольку аргументы вычисляются заранее, defer fmt.Println("took", time.Since(start)) печатает почти ноль, и go vet на эту строку ругается. Откладывайте замыкание, defer func() { ... }(), чтобы прочитать переменные на выходе.
defer принадлежит функции, а не блоку: в цикле, который открывает 10 000 файлов, все они остаются открытыми до выхода из функции (too many open files). Вынесите тело цикла в отдельную функцию.
Отложенное замыкание может менять именованные результаты; так recover() превращает панику в ошибку.
os.Exit и log.Fatal завершают процесс, не выполняя отложенные вызовы.
С Go 1.14 defer вне цикла компилируется инлайном и почти ничего не стоит.
fmt.Println печатает аргументы через пробел и добавляет перевод строки; fmt.Printf форматирует их по шаблону с глаголами; fmt.Sprintf форматирует так же, но возвращает строку, а не печатает её. Fprintf пишет в любой io.Writer, а Errorf создаёт ошибку.
%v задаёт формат по умолчанию, %+v добавляет имена полей структуры, %#v печатает в синтаксисе Go, %T печатает тип.
%q печатает строку в кавычках с экранированием, поэтому пустое значение или пробел в конце видны в логе.
Другие частые глаголы: %d (целое), %s (строка), %x (hex), %.2f (два знака после точки), %5d (ширина 5), %% (сам знак процента).
Тип с методом String() string печатается через него и в %v, и в %s, поэтому time.Duration выводится как 1.5s.
Неверный глагол или число аргументов не вызывает панику: в вывод попадает %!d(string=ada) или %!v(MISSING). go vet ловит оба случая, а go test запускает его автоматически.
:= в заголовке if объявляет новые resp и err, видимые только внутри этого if, поэтому внешней err ничего не присваивается, и функция возвращает её нулевое значение, то есть nil. Компилятор доволен, потому что все переменные используются.
Каждый заголовок if, for и switch и каждый блок {} открывает новую область видимости, а := всегда объявляет в текущей. Правило «хотя бы одна новая переменная» позволяет := переиспользовать только имена, объявленные в той же области.
Затенение остаётся законным Go, и проверки go vet по умолчанию его не включают из-за ложных срабатываний. Запускайте анализатор shadow отдельно (go vet -vettool=$(which shadow)) или включите shadow для govet в golangci-lint.
Тот же баг бывает с ctx, заново объявленным в блоке, и с переменной, скрывающей пакет, как url := ..., скрывающая net/url.
Исправление: обрабатывайте ошибку в той области, где она появилась, а значению, которое должно пережить блок, дайте своё имя, например lastErr.
i имеет тип uint, поэтому при n == len(xs) условие i >= uint(len(xs))-n всегда истинно (справа 0); когда i доходит до 0, i-- переходит в 18446744073709551615, и xs[i] паникует.
Почему: беззнаковые целые никогда не опускаются ниже нуля: 0 - 1 даёт максимальное значение вместо -1, без всякой ошибки. Условие i >= 0 для беззнакового i поэтому всегда истинно.
Тихий вариант: при n > len(xs) выражение uint(len(xs))-n превращается в огромное число, условие сразу ложно, и функция молча возвращает пустой результат.
Исправление: использовать int для индексов и счётчиков, ограничить n через min(n, len(xs)); тогда цикл for i := len(xs) - 1; i >= len(xs)-n; i-- корректно заканчивается на -1 при n == len(xs). Сам Go по этой причине использует int для len и индексов.
Как поймать: staticcheck сообщает SA4003 (сравнение беззнакового значения с >= 0 всегда истинно). Тест с n == len(xs) и тест с n > len(xs) ловят оба случая.
Она вернёт 6: return n + 2 присваивает 3 именованному результату n, затем отложенное замыкание удваивает его, и только потом вызывающий получает n. С безымянным результатом вернулось бы 3: значение копируется наружу до запуска defer, и defer меняет лишь локальную переменную.
Именованные результаты ведут себя как обычные переменные: обнуляются на входе в функцию, получают значение от return и остаются видны отложенным вызовам, которые выполняются после него.
Поэтому это единственный способ изменить возвращаемое значение уже после return: сообщить об ошибке Close (if cerr := f.Close(); err == nil { err = cerr }), превратить панику в ошибку через recover() или обернуть все возвращаемые ошибки в одном месте.
Голый return возвращает то, что лежит в именованных результатах в этот момент. Если блок заново объявляет err через :=, голый return внутри этого блока не скомпилируется (result parameter err not in scope at return), а после блока скомпилируется и вернёт внешние значения, которым ничего не присваивали.
Имена результатам дают, чтобы описать пару одного типа, как (lat, lng float64), или чтобы defer было куда писать, а не ради экономии на return.
defer f.Close() выполняется, когда возвращается hashAll, а не в конце каждой итерации, поэтому все файлы остаются открытыми до конца цикла; при достаточном числе путей процессу не хватает файловых дескрипторов, и os.Open падает с ошибкой.
Почему: defer привязан к объемлющей функции, а не к блоку или телу цикла. Каждая итерация добавляет ещё один вызов в список отложенных вызовов функции, так что N файлов дают N одновременно открытых дескрипторов и N ожидающих вызовов в памяти.
Почему тесты проходят: лимит дескрипторов обычно исчисляется тысячами, а с Go 1.19 процесс на Go при старте поднимает мягкий лимит до жёсткого, так что тест с десятью файлами до него не доходит.
Исправление: вынести работу с одним файлом в отдельную функцию hashFile, чтобы её defer срабатывал сразу после обработки каждого файла и одновременно был открыт максимум один файл. То же даёт замыкание, вызванное прямо в цикле: func() error { ... }().
Как поймать: правило defer в revive (в составе golangci-lint) сообщает prefer not to defer inside loops; тест под пониженным ulimit -Hn с числом файлов больше лимита воспроизводит падение.
Замыканием называют функциональный литерал, который использует переменные окружающей функции; он захватывает сами переменные, а не их значения. До Go 1.22 все итерации цикла for делили одну переменную цикла, и замыкания видели значение, которое было в конце цикла; с 1.22 у каждой итерации своя.
Захваченные переменные живут столько же, сколько замыкание, даже после выхода из внешней функции: компилятор переносит их в кучу. Так генератор-счётчик хранит своё состояние.
До 1.22 в for _, v := range items { go func() { use(v) }() } все горутины часто видели последний элемент, а все &v были одним и тем же указателем. Лечилось строкой v := v в начале тела.
Новое правило действует и на цикл с тремя частями, а включает его строка go в go.mod, а не тулчейн: модуль с go 1.21 сохраняет старое поведение, даже если собран Go 1.25.
На 1.22 и новее копия v := v становится мёртвым кодом; её находит линтер copyloopvar.
Не изменилось: замыкание над переменной, объявленной вне цикла, по-прежнему делит её со всеми итерациями, поэтому конкурентная запись в неё остаётся всё той же гонкой данных (data race).
init() выполняется один раз на пакет: после инициализации импортируемых пакетов и собственных переменных пакета и до main. Настоящей работе в нём не место: он не может вернуть ошибку, запускается при каждом импорте (в тестах тоже) и прячет глобальное состояние.
Полный порядок: сначала импортируемые пакеты, в глубину и каждый ровно один раз; затем переменные уровня пакета в порядке зависимостей, а не в порядке текста; затем все init() в том порядке, в каком файлы попадают в компилятор (утилита go сортирует их по имени), а внутри файла сверху вниз; затем main.main.
В пакете может быть несколько функций init, даже несколько в одном файле.
Пакеты, которые не импортируют друг друга, инициализируются в порядке путей импорта (это специфицировано с Go 1.21), поэтому код, который рассчитывает на побочный эффект чужого init, ломается при переименовании пути или новом импорте.
Почему там не место настоящей работе: init выполняется при каждом импорте, в том числе в каждом тестовом бинарнике; он не может вернуть ошибку (только panic или log.Fatal); он запускается до разбора флагов; и он прячет глобальное состояние.
Нормальные применения: регистрация драйвера или кодека (import _ "image/png"), построение таблицы поиска, проверка констант. Всё, что читает конфиг, открывает соединения или может упасть, должно быть в main, а дальше передаётся через конструкторы.
type UserID string представляет собой определение типа: новый, отдельный тип с базовым типом string и без собственных методов. type UserID = string объявляет алиас: второе имя для string, полностью взаимозаменяемое с ним.
Для определённого типа нужно явное преобразование UserID(s), поэтому компилятор не даст передать email туда, где ждут id пользователя. Нетипизированные константы при этом присваиваются напрямую: var id UserID = "u_1".
У определённого типа могут быть свои методы, а методы исходного типа он не наследует: у type Stamp time.Time нет Format.
Алиас не создаёт нового типа, поэтому к алиасу типа из другого пакета нельзя добавить методы, а %T печатает исходное имя.
Алиасы нужны для рефакторинга: тип переезжает в другой пакет, а старое имя продолжает компилироваться. byte, rune и any служат алиасами для uint8, int32 и interface{}.
Дженерик-алиасы вроде type Set[T comparable] = map[T]struct{} полностью поддерживаются с Go 1.24.
Для повседневного кода главное: своя переменная цикла на каждой итерации и range по целым числам (1.22), итераторы (1.23), дженерик-алиасы и map на Swiss-таблицах (1.24), GOMAXPROCS с учётом лимитов контейнера (1.25), new(expr) и сборщик мусора Green Tea по умолчанию (1.26), а также дженерик-методы (1.27).
Язык
1.22: у каждой итерации цикла своя переменная, включается строкой go в go.mod; for i := range n работает с целыми числами.
1.23: range по функциям вида func(yield func(T) bool), пакет iter и хелперы для итераторов вроде slices.Collect(maps.Keys(m)).
1.24: дженерик-алиасы типов.
1.26: new принимает выражение: new(int64(30)).
1.27: методы могут объявлять собственные параметры типа (дженерик-методы); методы интерфейсов по-прежнему не могут.
Стандартная библиотека и инструменты
1.22: шаблоны http.ServeMux с методами и wildcard-сегментами, GET /items/{id}, значение читается через r.PathValue("id").
1.23: при go 1.23 или новее в go.mod таймеры, на которые никто не ссылается, собираются сборщиком мусора даже без Stop, поэтому time.After в цикле больше не течёт.
1.24: testing.B.Loop для бенчмарков, os.Root для доступа к файлам в пределах одной директории, пакет weak и директивы tool в go.mod.
1.25: sync.WaitGroup.Go, testing/synctest для тестирования кода с таймерами на фейковых часах и encoding/json/v2 как эксперимент.
1.27: encoding/json теперь работает на реализации v2, появился новый пакет encoding/json/jsontext; asynctimerchan удалён, поэтому каналы таймеров всегда небуферизованные.
Рантайм
1.24: map устроены как Swiss-таблицы и быстрее на больших объёмах.
1.25: GOMAXPROCS учитывает CPU-лимит контейнера и обновляется, когда тот меняется.
1.26: сборщик Green Tea, экспериментальный в 1.25, включён по умолчанию и снижает CPU-нагрузку GC у сервисов, которые много аллоцируют.
1.27: профиль goroutineleak в runtime/pprof стал общедоступным.