Thank Go!
3.3K subscribers
10 photos
134 links
Здравый взгляд на язык программирования Go.

Злой админ @nalgeon. Добрый админ @mikeberezin. Рекламы нет.
Download Telegram
map[T]struct{}: новая надежда

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

Итак, map[T]struct{} стала занимать столько же, сколько map[T]bool. И это не +1 байт, как может показаться.

Данные в новой карте хранятся в группах:

type group struct {
ctrl uint64
slots [8]slot
}

type slot struct {
key K
elem E
}


Если elem занимает один байт, это увеличивает размер слота не на 1 байт, а более значительно — из-за необходимости выравнивать структуру.

Например, для ключа в 8 байт слот займет 16 байт, а для ключа в 16 байт — 24 байта.

Если вы поклонник громадных, необъятных карт со struct{}, это заметная проблема.

Команда Go рассматривает два варианта решения.

➊ Переделать слоты в группе на KVKVKVKV

В слоте ключ и значение меняются местами:

type slot struct {
elem E
key K
}


Поскольку для struct{} пустое поле elem идет не последним, оно занимает 0 байт. Соответственно, слот занимает ровно столько, сколько занимает ключ.

➋ Переделать слоты в группе на KKKKVVVV

Реинкарнация прежнего карточного подхода — ключи отдельно, значения отдельно:

type group struct {
ctrl uint64
keys [8]K
elems [8]V
}


Посколько для struct{} массив elems содержит только нулевые элементы, весь массив занимает 0 байт. Потерь тоже нет.

Оба варианта уже реализованы, но ни один не замержен.

Болеем за карту 🤞
1👍329😁4🤩2
Утечки горутин в Go 1.24+

Вы конечно и так в курсе, но на всякий случай:

Утечка происходит, если одна или несколько горутин навсегда заблокировались на канале (или другом примитиве синхронизации), но другие горутины и программа в целом при этом продолжают работать.

Утрированный пример утечки:

func work() chan int {
ch := make(chan int)
go func() {
ch <- 42
ch <- 13 // (!) утечка
}()
return ch
}

func main() {
<-work()
// ...
}


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

Сейчас это меняется.

Сначала в Go 1.24 добавили пакет synctest, который прекрасно справляется с поиском утечек при тестировании. Об этом почему-то никто не говорит — наверно, потому что не проходили мой курс по многозадачности 😁

func Test(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
<-work()
synctest.Wait()
})
}


panic: main bubble goroutine has exited but blocked goroutines remain

goroutine 37 [chan send (durable), synctest bubble 1]:
main.work.func1()
leak_test.go:12 +0x3c
created by main.work in goroutine 36
leak_test.go:10 +0x6c


Как видите, тест и ругнулся, и точно указал, где возникла утечка.

А теперь в Go 1.26 подвезут новый pprof-профиль под названием goroutineleak — он надежно (без ложных срабатываний) обнаруживает утечки в продакшене (как вы понимаете, synctest-то в продакшене не запустишь).

func main() {
prof := pprof.Lookup("goroutineleak")

defer func() {
time.Sleep(50 * time.Millisecond)
prof.WriteTo(os.Stdout, 2)
}()

<-work()
}


goroutine 3 [chan send (leaked)]:
main.work.func1()
leak.go:12 +0x3c
created by main.work in goroutine 1
leak.go:10 +0x74


Как видите, указал ровно на ту же утечку, что synctest.

В общем, рекомендую оба инструмента. За подробностями велкам в блог:

https://antonz.org/detecting-goroutine-leaks
2🔥5211😁3
Go 1.26: Обновленный go fix

С годами команда "go fix" превратилась в свалку покрытых плесенью рефакторингов, которые никто не использует.

Сейчас это изменится.

В 1.26 go fix переписана с нуля и использует тот же движок, что go vet — но другой набор анализаторов.

В отличие от go vet, исправления go fix безопасны (можно применять автоматически) и нацелены больше на модернизацию кода под новые фичи языка и стдлибы, а не исправление проблем в коде.

Пример: замена циклов на slices.Contains:

// до go fix
func find(s []int, x int) bool {
for _, v := range s {
if x == v {
return true
}
}
return false
}


// после go fix
func find(s []int, x int) bool {
return slices.Contains(s, x)
}


Сейчас в go fix больше 20 исправлений. Подробности в блоге:

https://antonz.ru/accepted/modernized-go-fix
2🔥52👍113
Go 1.26: Быстрое выделение памяти

В Go появились специализированные версии функции выделения памяти (allocation) для маленьких объектов (от 1 до 512 байт).

Вместо одной универсальной функции теперь выбирается конкретная реализация, в зависимости от размера объекта.

В заметках к релизу Go написано: "the compiler will now generate calls to size-specialized memory allocation routines".

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

Для маленьких объектов это изменение уменьшает затраты на выделение памяти до 30%. Команда Go ожидает, что в реальных программах с большим количеством аллокаций общее улучшение будет около 1%.

коммит
🔥261
Go 1.26: Ускоренный cgo

Слоганом Go 1.26 можно делать фразу «ускорилось ВСЕ». Помимо GreenTea GC и скоростных аллокаций (о которых я писал выше) — теперь еще и cgo-вызовы работают на 20–30% быстрее.

Вот немного подробностей.

В рантайме Go используются процессоры (обычно их обозначают буквой P) — это ресурсы, которые нужны для выполнения кода. Чтобы поток мог выполнять горутину, к нему должен быть привязан конкретный процессор.

Процессоры могут находиться в разных состояниях: running (выполняет код), idle (ждет работы) или gcstop (остановлен из-за сборки мусора).

Раньше было еще состояние syscall, когда горутина делала системный или cgo-вызов. Теперь это состояние убрали.

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

Это уменьшает накладные расходы и упрощает работу с cgo и системными вызовами.

Красота!
6👍418🔥2😁1
Интерактивный тур по Go 1.26

Опубликовал традиционный тур по будущему релизу (на англ). Часть фич мы с вами уже разобрали, а часть еще разберем, но если хотите прочитать все вместе уже сейчас — добро пожаловать.

Вот что вошло:

— new(expr)
— Безопасная проверка ошибок
— Новый «чайный» GC
— Ускоренный cgo и выделение памяти
— SIMD для amd64
— Секретный режим
— Криптография без ридеров
— Профиль для ловли утекающих горутин
— Метрики состояния горутин
— Итераторы в reflect
— Подсматривание в байтовый буфер
— Дескриптор процесса ОС
— Сигнал как причина в контексте
— Сравнение IP-подсетей
— Dialer с контекстом
— Фальшивый example.com
— Оптимизированные fmt.Errorf и io.ReadAll
— Множественные хендлеры в логах
— Артефакты тестов
— Обновленный go fix

Это жесть сколько всего они в релиз запихнули 😅

https://antonz.org/go-1-26
5👍3911🔥9😢1
Прожорливый make и скромный append

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

final := append([]byte(nil), make([]byte, finalSize)...)[:0]


Так вот, вопрос — зачем такие сложности? Почему не сделать просто так:

final := make([]byte, 0, finalSize)


UPD. Я не знаю 😅 Изначально я неправильно понял поведение компилятора, и написал, что append-make выделяет ровно finalSize байт, в отличие от make. Но это не так — оба варианта аллоцируют одинаково.

А вот что точно известно, так это что такое выражение:

append([]T(nil), make([]T, n)...)


не приводит к двойной аллокации (сначала для make, потом для append), как можно подумать по его внешнему виду.

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

Поведение это публично не документировано. Но разработчики стдлибы о нем знают и пользуются 😈
2👍37🔥15😱122
Как выделить 33 байта

Вчера уважаемая редакция знатно ошиблась и была вынуждена отозвать свое заявление насчет make и append-make, а также компенсировать дорогим читателям проставленные лайки.

К счастью, замешательство продолжалось недолго. Сейчас разберемся.

В Go аллокатор (штука, которая выделяет память) работает с фиксированными блоками памяти (так называемые size classes) для объектов меньше 32KB. Всего таких size-класса 67 штук: 8B, 16B, 24B, 32B, 48B, ..., 28672B, 32768B.

Если вы создаете срез емкостью (capacity) 33 байта:

s := make([]byte, 0, 33)


То аллокатор проставляет срезу значение cap = 33, но на самом деле выделяет не 33 байта, а подходящий ближайший size-класс — в данном случае 48B.

Если же вы создаете срез через append-make:

s := append([]byte(nil), make([]byte, 33)...)[:0]


То аллокатор точно так же выделяет 48B, а вот значение cap будет не 33, а тоже 48 (равно размеру size-класса).

Так как же заставить аллокатор выделить ровно 33 байта?

А никак 😁
3😁53👍117
Ускоренная fmt.Errorf

В Go 1.26 много улучшений производительности, но мне особенно понравилась оптимизированная fmt.Errorf — небольшое, но приятное дополнение.

Как вы знаете, errors.New("x") и fmt.Errorf("x") создают одинаковую ошибку типа errorString. Поэтому проще и единообразнее всегда использовать fmt.Errorf.

Проблема была в том, что fmt.Errorf работала в 3 раза медленнее, чем errors.New.

Один из разработчиков Go устал слышать, что "fmt.Errorf медленная", и решил это исправить. Он добавил быстрый путь, чтобы для "неформатированных" ошибок не запускалась вся тяжелая логика, а сразу вызывалась errors.New (см. скриншот).

Теперь fmt.Errorf("x") выполняет только одну аллокацию (как и errors.New) и всего на 20% медленнее (25нс против 21нс).

Очень круто!

коммит
🔥31👍123
Go 1.26: Рекурсивные дженерики

В Go объявление дженерика содержит параметр типа и ограничение. Например:

type Foo[T any] interface {}
type Map[K comparable, V any] {}


Здесь Foo и Map — дженерики; T, K, V — параметры типа; comparable и any — ограничения.

Раньше ограничение не могло напрямую или косвенно ссылаться на дженерик. Например, вот такое объявление интерфейса:

// Значение, которое можно сравнивать с другими
// значениями того же типа с помощью операции "меньше".
type Ordered[T Ordered[T]] interface {
Less(T) bool
}


вызывало ошибку компиляции: "invalid recursive type: Ordered refers to itself".

В Go 1.26 все компилируется нормально. Теперь ограничения могут быть рекурсивными.

Рекурсивное ограничение может пригодиться, если дженерик должен работать с аргументами или результатами того же типа, что он сам (как Ordered[T] в примере выше).

Такой дженерик можно использовать как элемент в контейнере:

// Дерево, которое хранит ordered-значения.
type Tree[T Ordered[T]] struct {
nodes []T
}

// У netip.Addr есть метод Less с нужной сигнатурой,
// поэтому он подходит для Ordered[netip.Addr].
t := Tree[netip.Addr]{}


Так что дженерики в Go стали чуть более гибкими.

P.S. Правда, пример с деревом будет прекрасно работать и без всякой рекурсии 🤷‍♀️
👍216😁2😱1
Go 1.26: SIMD для amd64

В Go 1.26 наконец-то появились векторные операции (SIMD) в пакете simd/archsimd.

SIMD (single instruction, multiple data) — это когда одна инструкция процессора обрабатывает не одно число, а сразу несколько (вектор фиксированного размера). SIMD поддерживаются современными CPU разной архитектуры, но реализация у них отличается.

Сходу было непонятно, каким должен быть высокоуровневый кросс-платформенный API. Поэтому команда Go решила начать с низкоуровневого, и пока поддерживать только amd64 (самая популярная серверная платформа).

Думаю, это отличная идея — дать разработчикам попробовать новые возможности и собрать отзывы, прежде чем делать высокоуровневый интерфейс.

На скриншоте пример использования archsimd для сложения двух векторов произвольного размера.
👍165🎉5😁1
Go 1.26: Криптография без ридеров

Пожалуй, самое спорное изменение в Go 1.26.

Сейчас криптографические API (например ecdsa.GenerateKey), обычно принимают io.Reader как источник случайных данных:

ecdsa.GenerateKey(elliptic.P256(), rand.Reader)


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

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

Начиная с версии 1.26, большинство крипто-API просто игнорируют параметр io.Reader и используют внутренний системный источник случайных чисел.

Теперь можно передавать nil или ридер — это больше не имеет значения:

ecdsa.GenerateKey(elliptic.P256(), nil)


Кажется, это первый раз, когда в стандартной библиотеке Go несколько публичных API начали так себя вести. Это оправдано, но все равно как-то грустно.
😢13👍4😁1
Go 1.26: Байтовое подсматривание

Новый метод Buffer.Peek в пакете bytes возвращает следующие N байт буфера, не меняя при этом текущую позицию:

buf := bytes.NewBufferString("I love bytes")

sample, err := buf.Peek(1)
fmt.Println(sample)
// I

buf.Next(2)

sample, err = buf.Peek(4)
fmt.Println(sample)
// love


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

buf := bytes.NewBufferString("car")
sample, err := buf.Peek(3)
fmt.Println(sample)
// car

sample[2] = 't' // меняем в буфере

data, err := buf.ReadBytes(0)
fmt.Println(sample)
// cat


Срез от Peek можно использовать только до следующего вызова Read или Write.

👀
Please open Telegram to view this post
VIEW IN TELEGRAM
👍21🔥74😁1
Go 1.26: Green Tea GC

Новый сборщик мусора (экспериментальный в версии 1.25), в 1.26 будет включен по умолчанию.

Мотивация

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

В итоге процессор тратит слишком много времени, просто ожидая, когда нужные данные поступят из памяти. Больше 35% времени, потраченного на сканирование памяти, уходит впустую на ожидание доступа к данным. С увеличением числа ядер в процессоре ситуация только ухудшается.

Реализация

Green Tea меняет подход: теперь в приоритете эффективная работа с памятью. Вместо того, чтобы проверять каждый объект отдельно, система сканирует память блоками по 8 КБ (спанами). Алгоритм работает с маленькими объектами (до 512 байт), потому что их больше всего и их сложнее всего быстро просканировать.

Каждый спан делится на одинаковые слоты в зависимости от своего size-класса, и внутри него хранятся только объекты этого класса. Например, если спан относится к классу 32 байта, весь блок делится на слоты по 32 байта, и объекты кладутся прямо в эти слоты (каждый объект в начало отдельного слота). Благодаря такому фиксированному расположению сборщик мусора может легко найти метаданные объекта с помощью простой адресной арифметики, не проверяя размер каждого найденного объекта.

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

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

А еще Green Tea использует векторные инструкции процессора (только на архитектуре amd64), чтобы обрабатывать объекты пачками.

Бенчмарки

Результаты тестов разнятся, но команда Go ожидает, что в реальных программах, которые сильно зависят от сборщика мусора, затраты на GC снизится на 10–40%. Благодаря векторной реализации, на процессорах вроде Intel Ice Lake или AMD Zen 4 и новее, затраты на GC могут уменьшиться еще на 10%.

К сожалению, нормальных актуальных бенчмарков я не нашел 🌚
🔥337👍5🤩2😁1
Go 1.26: Множественные хендлеры в логах

Теперь можно легко писать логи сразу в несколько мест — например, в консоль, файл или на удаленный сервер — с помощью стандартной библиотеки.

Все благодаря slog.MultiHandler, который отправляет записи лога всем настроенным обработчикам.

Например, создаем хендлер для записи в stdout:

stdoutHand := slog.NewTextHandler(os.Stdout, nil)


И второй для записи в файл:

const flags = os.O_CREATE | os.O_WRONLY | os.O_APPEND
file, err := os.OpenFile("/tmp/app.log", flags, 0644)
if err != nil { log.Fatal(err) }
defer file.Close()
fileHand := slog.NewJSONHandler(file, nil)


Комбинируем их через MultiHandler:

multiHand := slog.NewMultiHandler(stdoutHand, fileHand)
logger := slog.New(multiHand)


И логируем как обычно:

logger.Info("login",
slog.String("name", "whoami"),
slog.Int("id", 42),
)


Вряд ли это перевернет ваш мир, но штука полезная.
136👍28😁1
Фи и бета

Если долго смотреть в бездну, то бездна начинает смотреть в ответ.

А если долго вглядываться в стандартную библиотеку Go, то можно обнаружить вот такую функцию, которая оперирует переменными с названиями φ и β.

Какой-то математик дорвался до программирования, судя по всему 😁
2😁39🔥86👍4
Shuffle и Фишер-Йетс

Минутка алгоритмов!

В пакете стдлибы rand есть функция Shuffle, которая отвечает за перетасовку коллекций. Реализована она в стиле классического Go до эпохи дженериков — через колбэк-функцию:

func Shuffle(n int, swap func(i, j int))


Пример:

nums := []int{1, 2, 3, 4, 5}
rand.Shuffle(len(nums), func(i, j int) {
nums[i], nums[j] = nums[j], nums[i]
})
fmt.Println(nums)
// [2 3 4 1 5]


Но как именно Shuffle выбирает пары (i,j), чтобы результат получился действительно случайным, а не смещенным в пользу тех или иных комбинаций?

Она использует алгоритм Фишера-Йетса:

1. Начать с последнего индекса (i = n-1)
2. Выбрать случайный индекс 0 ≤ j ≤ i.
3. Поменять местами arr[i] и arr[j] (этот шаг Shuffle делегирует колбэку).
4. Выполнить i-- и продолжить шаги 2-4, пока i не достигнет 0.

Реализуется с нуля в три строчки кода:

func Shuffle[T any](s []T) {
for i := len(s) - 1; i > 0; i-- {
j := rand.IntN(i + 1)
s[i], s[j] = s[j], s[i]
}
}


Пример:

nums := []int{1, 2, 3, 4, 5}
Shuffle(nums)
fmt.Println(nums)
// [1 3 4 2 5]


Алгоритм Фишера-Йетса используют и другие языки — например, Python.

P.S. Вот бы все алгоритмы настолько сложны в реализации были 😁
2👍258😁2
netip.Addr и уникальная ручка

netip.Addr в Go — любопытная штука. В основе типа лежит пара uint64-чисел (старшая и младшая части IP-адреса) — это компактное и эффективное представление.

Но есть нюанс: IPv6-адреса могут иметь "зоны". Зона определяет конкретный сетевой интерфейс для локального IP-адреса.

На уровне передачи данных зона — это uint32 (например, 12), но команда Go решила хранить зоны как строки (например, "eth0"). Как сохранить строку в структуре Addr, не ухудшая размер и производительность?

И здесь появляется unique.Handle. Пакет unique хранит глобальный кэш всех зон (addrDetails), которые используются в программе в данный момент. Структура Addr хранит ссылку на конкретную запись в этом кэше.

Некрасиво, но эффективно.
🔥113👍2
UUID в Go 1.27

Много лет команду Go просили добавить генерацию uuid в стдлибу, и много лет они отказывались (что совершенно нелогично — ведь уиды есть в каждом втором проекте, это лучший кандидат на добавление).

Но здравый смысл наконец возобладал, и в Go 1.27 добавят пакет uuid для генерации UUIDv4 (полностью случайные айдишники) и UUIDv7 (частично упорядочены по времени с точностью до 1 мс).

Так вот.

Если вы посмотрите исходники, то можете заметить кое-что странное — мьютекс v7mu, объявленный на уровне пакета. Вообще-то генерация уидов не требует глобального состояния, так зачем нужен мьютекс?

Оказывается, команда Go хотела гарантировать, что функция NewV7 (которая создает новый UUIDv7) всегда возвращает строго возрастающие UUID (если только системные часы не пошли назад).

Чтобы это реализовать, им пришлось добавить общее состояние на уровне пакета (переменные v7lastSecs и v7lastTimestamp), и защитить его мьютексом v7mu. NewV7 использует их, чтобы убедиться, что каждый новый UUID идет после предыдущего.

Получается, нарушили «архитектурную красоту» ради удобства использования.
🔥31👍103