Заметки Go-разработчика
17 subscribers
13 photos
13 links
Download Telegram
#go

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

Передача значения занимает тем больше времени, чем больше размер данных, и составляет примерно 0.7 мс, когда размер значения доходит до 10 Мбайт.

Сравнение возврата указателя вместо значения дает более любопытный результат. Когда размер данных не превышает 1 Мбайт, то возврат указателя вместо значения на самом деле снижает производительность. Например, возврат структуры данных размером 100 байт занимает около 10 нс, а возврат указателя на такую структуру данных - около 30нс. Однако, когда размер структуры данных превышает 1 Мбайт, использование указателей, наоборот, дает положительный эффект. Например, для возврата данных размером 10 Мбайт требуется почти 1.5 мс, а для возврата указателя на эти данные - меньше 0.5 мс.

Однако, эти показатели отличаюстя от процессора к процессору.

🤔 Открытый вопрос: почему передача указателя в функцию занимает константное время, а возвращение указателя из функции зависит от размера входных данных? Ведь само потребление памяти указателями константно (см. предыдущий пост).
#go

Go предоставляет удобную возможность перечисления допустимых типов внутри интерфейса:


type Integer interface {
int | int8 | int16 | int32 | int64 | uint | uint8 | uint16 | uint32 | uint64 | uintptr
}


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


func divAndRemainder[T Integer](num, denom T) (T, T, error) {
if denom == 0 {
return 0, 0, errors.New('can not divide by zero')
}

return num / denom, num % denom, nil
}


Применение такого подхода со списком типов внутри интерфейса допустимо только в качестве ограничителя типа. Обозначение таким интерфейсом типа переменной, поля, возвращаемого значения или параметра вызовет ошибку на этапе компиляции.
Заметки Go-разработчика
#go Go предоставляет удобную возможность перечисления допустимых типов внутри интерфейса: type Integer interface { int | int8 | int16 | int32 | int64 | uint | uint8 | uint16 | uint32 | uint64 | uintptr } Такие интерфейсы используются в качестве ограничителей…
#go

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


type MyInt int
var a MyInt = 10
var b MyInt = 20
fmt.Println(divAndRemainder(a, b))

// Компилятор выдаст ошибку:
// MyInt does not satisfy Integer


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


type Integer interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 | ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr
}


Теперь приведенный выше код скомпилирован без ошибки.
Заметки Go-разработчика
#go Однако, такой подход неудобен тем, что по умолчанию типы из списка должны точно совпадать с конкретным типом значения. Если попытаться divAndRemainder к значению пользовательского типа, основанного на типе, входящем в список в Integer, то компилятор выдаст…
#go

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


type Ordered interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 | ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr | ~float32 | ~float64 | ~string
}


Интерфейс Ordered перечисляет все типы, которые поддерживают операторы сравнения ==, !=, <, >, <=, >=. Возможность указать, что переменная представляет упорядочиваемый тип, настолько полезна, что в Go 1.21 был добавлен пакет cmp, который определяет этот интерфейс Ordered. Пакет также определяет две функции сравнения. Функция Compare возвращает -1, 0 или 1, если первый параметр меньше второго, равен ему или больше него соответственно, а функция Less возвращает true, если первый параметр меньше второго.
Заметки Go-разработчика
#go Однако, такой подход неудобен тем, что по умолчанию типы из списка должны точно совпадать с конкретным типом значения. Если попытаться divAndRemainder к значению пользовательского типа, основанного на типе, входящем в список в Integer, то компилятор выдаст…
#go

Интерфейс, используемый как ограничитель типа, может перечислять не только допустимые типы, но и обязательные методы. Например, можно указать, что тип должен иметь базовый тип int и метод String() string:


type PrintableInt interface {
~int
String() string
}


Следует иметь в виду, что Go позволяет объявлять интерфейсы для применения в качестве ограничителей типов, которые невозможно удовлетворить. Если в PrintableInt использовать int вместо ~int, то ему не будет соответствовать допустимый тип, потому что у типа int нет методов. Компилятор сообщит об этой ошибке при попытке использования (а не объявления) сущности, по отношению к которой действует данный ограничитель типа.
#go

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

for i := 0; i < b.N; i++ { ... }


Вместо этого можно написать:

func BenchmarkMyFunc(b *testing.B) {
b.Loop(func() {
MyFunc()
})
}


Помимо визуального удобства, это гарантирует, что каждая итерация будет строго учтена, а все параметры/результаты функции останутся живыми - это поможет избежать странных оптимизаций компилятора, которые могут испортить бенчмарк.
👍1
#go

Джон Боднер в своей книге пишет: "после записи в открытый небуферизованный канал пишущая горутина приостанавливается, пока другая горутина не прочитает данные из этого канала". Решил проверить это на практике.

Сейчас как раз в рамках изучения Go работаю над Телеграм-ботом, суть которого - получать сообщения от пользователей и отправлять на них какой-то ответ (если не затрагивать бизнес-логику).

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

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

И согласно Боднеру, первая горутина после записи в канал updates должна приостановиться, пока записанные в канал данные не будут прочитаны другой горутиной. То есть, если обложить всё логами посредством fmt.Println, я должен увидеть что-то в духе:


// before write to ch
// read from ch
// after write to ch


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


fmt.Println("read from ch", <-updates)


Но фактически я вижу:


// before write to ch
// after write to ch
// next poll (следующая итерация цикла for внутри функции-поллера!!!)
// read from ch


Здесь видно, что после записи в канал пишущая горутина не блокируется, а продолжает свою работу - она выполняет логирование в стандартный вывод и переходит на следующую итерацию внутреннего цикла for, и тем самым отправляет следующий полл-запрос. И лишь после того, как она заблокируется об ожидание ответа на следующий лонгполл-запрос, мы видим в логах вывод от горутины-читателя: "read from ch". Понятно, что положение лога о чтении здесь может меняться в силу конкурентного выполнения, но по представленным результатам однозначно можно сказать, что блокирования пишущей горутины в ожидании чтения записанных данных не происходит.

Что имел в виду Боднер?
👎1🤔1
Заметки Go-разработчика
#go Джон Боднер в своей книге пишет: "после записи в открытый небуферизованный канал пишущая горутина приостанавливается, пока другая горутина не прочитает данные из этого канала". Решил проверить это на практике. Сейчас как раз в рамках изучения Go работаю…
#go

В официальной спеке языка на go.dev сказано следующее:

"The capacity, in number of elements, sets the size of the buffer in the channel. If the capacity is zero or absent, the channel is unbuffered and communication succeeds only when both a sender and receiver are ready."

что можно перевести как:

"Ёмкость в количестве элементов определяет размер буфера в канале. Если ёмкость равна нулю или отсутствует, канал является небуфферизованным, и обмен данными происходит только тогда, когда готовы и отправитель, и получатель."

Здесь уже другая формулировка. Здесь не говорится, что пишущая горутина приостановится, пока данные не будут прочитаны. Здесь говорится, что обмен данными произойдет только когда готов и отправитель, и получатель. Очевидно, что определение готовности - сложная вещь, реализуемая внутренней логикой Go, но это спокойно укладывается в голове в отличие от утверждения Боднера о том, что пишущая горутина должна заблокироваться, пока другая горутина не прочитает записанные данные.
Заметки Go-разработчика
#go В официальной спеке языка на go.dev сказано следующее: "The capacity, in number of elements, sets the size of the buffer in the channel. If the capacity is zero or absent, the channel is unbuffered and communication succeeds only when both a sender…
#go

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

Но базово, видимо, имеется в виду, что пишущая горутина заблокируется, пока не появится читающая горутина в статусе waiting. Только и всего. То есть ожиданием фактической операции чтения пишущая горутина блокироваться не будет.
Заметки Go-разработчика
#go Про определение готовности - это, к слову, в тему о планировщике. Выше где-то есть посты с ссылками на материалы, где подробно разбирается планировщик Go. Но базово, видимо, имеется в виду, что пишущая горутина заблокируется, пока не появится читающая…
#go

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

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

Джон Боднер все в той же книге про идиомы Go приводит удобную таблицу по поведению каналов. Но в ней снова на мой взгляд критичная неточность (или недосказанность) про блокирование при записи в небуферизованный открытый канал.

Снова сказано, что выполнение приостанавливается, пока не будет произведено чтение. Что на самом деле не подтверждается экспериментальным путём и официальной спецификацией языка.

Истина в том, что пишущая горутина блокируется, пока не найдётся читающая горутина в статусе waiting. Как только она нашлась, планировщик переводит её в статус runnable, а блокировка пишущей горутины снимается. То есть блокировки пишущей горутины до момента фактической операции чтения (когда читающая горутина перейдет в статус running и сделает свою работу) не происходит.
#go

Очень понравилась глава в книге Боднера про контекст, а именно её подраздел про использование контекста для записи и чтения значений. Этот подраздел - лучшее введение в философию использования контекста в качестве передатчика значений в Go, которое я встречал.

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

Кому релевантно, вот ссылка: https://habr.com/ru/articles/932368/
👍1
#go

Способ проверки на nil с использованием рефлексии, который в отличие от стандартного оператора сравнения возвращает true в случае, если переменной типа interface{} или any присвоить значение другой переменной, которая имеет конкретный тип, но также равна nil:


func IsNil(i interface{}) bool {
iv := reflect.ValueOf(i)
if !iv.IsValid() {
return true
}
switch iv.Kind() {
case reflect.Pointer, reflect.Slice, reflect.Map, reflect.Func, reflect.Interface, reflect.Chan:
return iv.IsNil()
default:
return false
}
}


Например:


var c []string
var (
a interface{} = c
b any = c
)
fmt.Println(
IsNil(a),
IsNil(b),
a == nil,
b == nil,
)
// true true false false
#go

Как известно, init() - это функция, используемая дли инициализации состояния приложения.

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

Например, есть запустить следующий код:


package main

import "fmt"

var a = func() int {
fmt.Println("var")
return 0
}()

func init() {
fmt.Println("init")
}

func main() {
fmt.Println("main")
}


Вывод будет:


var
init
main
#go

Пустые и нулевые срезы


// Пустой и нулевой срез
var s []string // len == 0, s == nil

// Пустой и нулевой срез
s = []string(nil) // len == 0, s == nil

// Пустой и НЕнулевой срез
s = []string{} // len == 0, s != nil

// Пустой и НЕнулевой срез
s = make([]string, 0) // len == 0, s != nil


Одно из основных различий между нулевым и пустым срезом касается выделения памяти: инициализация нулевого среза не требует выделения памяти, в то время как инициализация пустого среза - требует.

✍️ Вызов встроенной функции append корректно работает как с пустым, так и с нулевым срезом.

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


func f() []string {
var s []string
if foo() {
s = append(s, "foo")
}
if bar() {
s = append(s, "bar")
}
return s
}


Если и foo, и bar равны false, мы получаем пустой срез. Чтобы предотвратить создание пустого среза без особого на то основания, следует выбрать вариант 1 (`var s []string`). Можно выбрать и вариант 4 (`make([]string, 0)`), но по сравнению с первым вариантом это не принесет никакой пользы, а кроме того, еще и потребует резервирования места в памяти, которого можно было избежать. Однако, стоит делать выбор в пользу make(), когда функция всегда возвращает срез с известной длиной (или с известной минимальной длиной).

✍️ Итого, можно декларировать, что вариант []string{} хуже варианта []string по той причине, что ведет к выделению памяти на пустой срез. Литерал среза []string{} следует использовать только тогда, когда при его создании нужно указать начальные значения, например:


s := []string{"foo", "bar"}


Однако, следует учитывать, что некоторые библиотеки по-разному работают с пустыми и нулевыми срезами. Например, encoding/json при маршалинге преобразует пустой срез в [], а нулевой - в null.
👍1
#go

Карты и утечки памяти

Создадим карту, где каждый элемент m представляет собой массив из 128 байтов. Сделаем следующее:
1. Создадим в памяти пустую карту
2. Добавим в нее 1 миллион элементов
3. Удалим все эти элементы и вызовем выполнение GC
После каждого шага выведем размер кучи.


n := 1_000_000
m := make(map[int][128]byte)

printAlloc()

for i := 0; i < n; i++ {
m[i] = randBytes()
}

printAlloc()

for i := 0; i < n; i++ {
delete(m, i)
}

runtime.GC()
printAlloc()
runtime.KeepAlive(m)


Выводит будет следующим:


// 0 MB <- После инициализации
// 461 MB <- После добавления 1 млн элементов
// 293 MB <- После удаления 1 млн элементов


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

✍️ Дело в том, что карта под капотом Go - это не плоская структура, а составная, состоящая из сегментов, предназначенных для эффективного и раздробленного хранения данных. Каждый сегмент вмещает в себя до восьми элементов и может иметь ссылку на следующий сегмент. После добавления в карту 1 миллиона элементов карта содержит 262 144 сегмента (2 в степени 18). И после удаления всех элементов из карты количество её сегментов не сокращается. В этом смысле карта может только расти, что, безусловно, создаёт риск излишних затрат памяти.

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

✍️ Масштабы проблемы можно уменьшить, используя указатели:


map[int]*[128]byte


Это не отменяет того факта, что у нас будет значительное количество сегментов. Однако, каждый сегмент будет резервировать память только в соответствии с размером указателя, а не 128 байтов (то есть 8 байтов в 64-разрядных и 4 байта в 32-разрядных системах). Сопоставление затрат памяти этих двух вариантов представлено ниже:


map[int][128]byte
// 0 MB <- После инициализации
// 461 MB <- После добавления 1 млн элементов
// 293 MB <- После удаления 1 млн элементов

map[int]*[128]byte
// 0 MB <- После инициализации
// 182 MB <- После добавления 1 млн элементов
// 38 MB <- После удаления 1 млн элементов
#go

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

Обход среза с append'ом

Первый пример:


s := []int{0, 1, 2}
for range s {
s = append(s, 10)
}

// [0 1 2 10 10 10]


1. s на второй строке (в выражении for range s) вычисляется только один раз - до начала цикла, то есть она всегда содержит изначальный срез из трёх элементов, даже если в ходе цикла срез изменяется
2. s на третьей строке (в выражении s = append(s, 10)) ожидаемо ссылается на внешнюю s в первой строке, то есть вполне корректно позволяет её изменять

Второй пример:


s := []int{0, 1, 2}
for i := 0; i < len(s); i++ {
s = append(s, 10)
}


Этот цикл никогда не закончится, поскольку выражение len(s) вычисляется во время каждой итерации
#go

Подмена канала внутри range

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


ch1 := make(chan int, 3)
go func() {
ch1 <- 0
ch1 <- 1
ch1 <- 2
close(ch1)
}()

ch2 := make(chan int, 3)
go func() {
ch2 <- 10
ch2 <- 11
ch2 <- 12
close(ch2)
}()

ch := ch1
for v := range ch {
fmt.Println(v)
ch = ch2
}


Выражение, используемое циклом range для обхода, вычисляется только единожды - перед началом цикла. Поэтому здесь выражение, задаваемое для range, представляет собой канал ch, указывающий на ch1. Следовательно, range вычисляет ch, выполняет его копирование во временную переменную и итерируется по элементам из этого канала. Несмотря на то, что ch = ch2, цикл range будет выполняться по ch1, а не ch2:


// 0
// 1
// 2


Но выражение ch = ch2 все-таки оказывает некоторое влияние. Оно мутирует переменную ch за рамками for-range, поэтому далее она будет ссылаться именно на канал ch2.