Антон ЖияновGo, SQL и разработка софтаhttps://antonz.ru/https://antonz.ru/assets/favicon/favicon.pngАнтон Жияновhttps://antonz.ru/Hugo -- gohugo.ioru-ruTue, 03 Dec 2024 02:00:00 +0000Say It in Russianhttps://antonz.ru/say-it-emoji-pack/Tue, 03 Dec 2024 02:00:00 +0000https://antonz.ru/say-it-emoji-pack/Эмодзи-пак с текстовыми реакциями для телеграма.Сделал эмодзи-пак с текстовыми реакциями для телеграма (нужен премиум).

Вероятно, еще будет пополняться, но начало положено:

Эмодзи-пак Say It in Russian
Реакции на любой вкус от классических ДА/НЕТ до радикального БУЭ

Добавить в телеграм

]]>
Курс «Многозадачность в Go»https://antonz.ru/go-concurrency-course/Mon, 15 Jul 2024 13:00:00 +0000https://antonz.ru/go-concurrency-course/Осваиваем многозадачное программирование на практике.Закончил курс по многозадачности! Вот какие темы в нем разобраны:

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

Если вы совсем не знакомы с многозадачностью, курс поможет освоить ее с нуля. А если уже прошли модуль «Многозадачность» на курсе «Go на практике» — детально разберетесь в гонках, синхронизации и пакетах sync и atomic.

Как обычно, все концепции разобраны на практических примерах и закреплены задачками с автоматической проверкой.

Курс непростой. Подойдет практикующим разработчикам с уверенным знанием основ Go.

Всем go 💪

]]>
Таймеры в Go 1.23https://antonz.ru/timers-1-23/Sun, 07 Jul 2024 13:00:00 +0000https://antonz.ru/timers-1-23/Детективная история.Тут прям детективная история приключилась. В Go есть таймер (тип Timer), а в нем — поле с каналом (Timer.C), в который таймер тикает спустя указанное время.

В коде стдлибы таймер создается так:

func NewTimer(d Duration) *Timer {
	c := make(chan Time, 1)
	t := &Timer{
		C: c,
		// ...
	}
	startTimer(&t.r)
	return t
}

Такая реализация привела к проблемам с time.After и Reset, от которых многие страдали.

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

// As of Go 1.23, the channel is synchronous (unbuffered, capacity 0),
// eliminating the possibility of those stale values.
func NewTimer(d Duration) *Timer {
	c := make(chan Time, 1)
	t := (*Timer)(newTimer(when(d), 0, sendTime, c, syncTimer(c)))
	t.C = c
	return t
}

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

c := make(chan Time, 1)

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

Specifically, the timer channel has a 1-element buffer like it always has, but len(t.C) and cap(t.C) are special-cased to return 0 anyway, so user code cannot see what's in the buffer except with a receive.

Эту логику вкорячили прямо в реализацию канала (тип chan).

Что тут скажешь. Ну и дичь.

]]>
Полносрезное выражение в Gohttps://antonz.ru/full-slice-expression/Thu, 27 Jun 2024 13:00:00 +0000https://antonz.ru/full-slice-expression/Меняем емкость при нарезке.Полносрезное выражение [::] (full slice expression, далее по тексту «баян») имеет такой синтаксис:

s[low : high : max]

Баян создает срез длиной high-low и емкостью max-low. Используется крайне редко.

Чтобы понять разницу между обычным срезом и баяном, рассмотрим пример.

Как вы знаете, под каждым срезом лежит массив с данными (сам срез данных не содержит). Обычно этот массив создается неявно, но мы для наглядности сделаем так:

arr := [5]int{1, 2, 3}
// [1 2 3 0 0]

s := arr[0:3]
// [1 2 3]

len(s) // 3
cap(s) // 5

Срез s указывает на массив arr. Его длина (length) равна 3, а емкость (capacity, размер массива под срезом) равна 5.

Добавление элемента в срез добавляет его в массив, поскольку емкость это позволяет:

s = append(s, 4)

fmt.Println(arr)
// [1 2 3 4 0]

fmt.Println(s)
// [1 2 3 4]

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

arr := [5]int{1, 2, 3}
// [1 2 3 0 0]

s := arr[0:3:3]
// [1 2 3]

len(s) // 3
cap(s) // 3

Все как раньше, только емкость среза равна 3. Поэтому добавление элемента в срез приведет к созданию нового массива под срезом. Исходный массив arr не изменится:

s = append(s, 4)

fmt.Println(arr)
// [1 2 3 0 0]

fmt.Println(s)
// [1 2 3 4]

Такие дела.

]]>
Пустой срез vs. nil-срез в Gohttps://antonz.ru/empty-vs-nil-slice/Tue, 25 Jun 2024 13:00:00 +0000https://antonz.ru/empty-vs-nil-slice/Чем отличаются и как с ними работать.Как вы знаете, объявленная без инициализации переменная в Go автоматически получает нулевое значение соответствующего типа:

var num int    // 0
var str string // ""
var flag bool  // false

Для среза нулевое значение — nil:

var snil []int
// []int(nil)

С другой стороны, бывает инициализированный, но пустой срез:

sempty := []int{}
// or
// sempty = make([]int, 0)

Это разные значения, которые не равны между собой:

reflect.DeepEqual(snil, sempty)
// false

И в то же время, пустой срез и nil-срез почти всегда взаимозаменямы:

len(snil) // 0
cap(snil) // 0
snil = append(snil, 1) // []int{1}

len(sempty) // 0
cap(sempty) // 0
sempty = append(sempty, 1) // []int{1}

reflect.DeepEqual(snil, sempty)
// true

Почти всегда ваш код не должен делать разницы между пустым и nil-срезом.

Одно из заметных исключений — работа с JSON, при которой мы часто хотим отличать null (nil-срез) и [] (пустой срез).

]]>
Запускаем 100К горутин в Gohttps://antonz.ru/100k-goroutines/Thu, 20 Jun 2024 13:00:00 +0000https://antonz.ru/100k-goroutines/Сколько можно запустить горутин и от чего это зависит.Допустим, у вас есть сервер с 1Гб памяти. Вы выполняете на нем какие-то задачки, и для каждой задачки создаете горутину. Внутри горутина очень простая, без рекурсий и жирных объектов в памяти:

func work() int {
    // какая-то несложная работа
}

Сколько одновременно живущих горутин можно создать без проблем для работы сервера?

Результаты опроса на канале:

Сколько можно создать одновременно живущих горутин?

 2% 10–100
    ■■

 6% 100–1000
    ■■■■

11% 1000–10000
    ■■■■■■

14% 10000–100000
    ■■■■■■■

67% 100000+
    ■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■

Так ли это? Давайте разбираться.

Запускаем 100К горутин

Горутина — это структура минимального размера около 2Кб, так что формально в 1Гб можно уместить 1024*1024/2 ≈ 500K горутин. Понятно, что часть памяти уже занята, но 100 тысяч уж всяко уместятся, так что это ответ 100000+

Но есть нюанс (и даже не один).

Допустим, мы взяли и запустили все горутины, вот так:

func work() {
    // пока забудем, что именно
    // тут происходит
}

const n = 100_000
for range n {
    go work()
}

В память-то они поместятся, а вот борьба за CPU будет нешуточная. Планировщику горутин придется постоянно переключаться между горутинами, чтобы выполнить их на (небольшом) количестве ядер сервера, так что потери на переключение контекста могут быть заметными.

Создаем 100К горутин, выполняем N

Итак, мы создаем 100 тысяч горутин.

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

nConc = runtime.NumCPU()*2
sema := make(chan struct{}, nConc)

const n = 100_000
for range n {
    go func() {
        sema <- struct{}{}
        work()
        <-sema
    }()
}

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

Мы по-прежнему создаем 100К горутин, но теперь каждая из них пытается занять семафор перед выполнением работы. Количество мест в семафоре ограничено (16 мест на 8-ядерном сервере), так что в каждый момент времени только 16 горутин будут выполняться, а остальные будут ждать на семафоре.

Общее время выполнения не увеличится (скорее даже уменьшится), а ресурсы сервера будут использоваться более разумно. Если же мы не хотим занимать CPU на 100% и готовы пожертвовать общим временем, то можно уменьшить nConc.

А если вынести sema <- struct{}{} из горутины в общий цикл, то и создаваться горутины будут не все разом, а постепенно.

Всегда ли оптимален такой подход? Нет.

Количество одновременных горутин

Вот конструкция, к которой мы пришли:

func work() {
    // работает работу на работе
}

nConc = runtime.NumCPU()*2
sema := make(chan struct{}, nConc)

const n = 100_000
for range n {
    sema <- struct{}{}
    go func() {
        work()
        <-sema
    }()
}

Количество одновременных горутин равно количеству ядер × 2. Это хороший подход, если работа внутри work в основном использует CPU (CPU-bound).

Но что делать, если work делает HTTP-запросы? Тогда, вероятно, большую часть времени она ожидает ответа от удаленного сервера, и CPU при этом не используется.

Если большую часть времени горутины чего-то ждут (диска, сети) — их можно одновременно выполнять в больших количествах (десятки, сотни, иногда тысячи).

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

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

Итого

  1. Ограничивайте количество одновременных горутин (семафором или другими средствами).
  2. Для CPU-bound задач степень параллелизма определяется количеством ядер.
  3. Для I/O-bound задач степень параллелизма определяется спецификой работы и доступными ресурсами.

Ну и если вся эта многозадачная хурма вам интересна — проходите мой курс (осторожно, он сложный).

]]>
Приемчики форматирования в Gohttps://antonz.ru/formatting-tricks/Tue, 18 Jun 2024 13:00:00 +0000https://antonz.ru/formatting-tricks/Поля структуры, тип значения, индекс аргумента.Несколько приемов форматирования, о которых вы, возможно, не слышали.

Закавыченная строка

Используйте %q, чтобы вывести строковое значение в кавычках.

s := "Hello, World!"
fmt.Printf("%q\n", s)
// "Hello, World!"

Названия полей структуры

Используйте %+v, чтобы вывести названия полей структуры, а не только значения.

alice := person{"Alice", 25}
fmt.Printf("%+v\n", alice)
// {name:Alice age:25}

Тип значения

Используйте %T, чтобы вывести тип значения.

var val any
val = 42
fmt.Printf("%T: %v\n", val, val)
// int: 42

val = "Hello, World!"
fmt.Printf("%T: %v\n", val, val)
// string: Hello, World!

val = person{"Alice", 25}
fmt.Printf("%T: %v\n", val, val)
// main.person: {Alice 25}

Индекс аргумента

Можно явно указать, какой по порядку аргумент выводить. Полезно, если одно и то же значение выводится несколько раз (как в примере с val выше).

num := 42
fmt.Printf("%[1]T: %[1]v\n", num)
// int: 42

Нумерация с 1.

песочница

]]>
range по функциям в Gohttps://antonz.ru/range-over-function/Thu, 13 Jun 2024 13:00:00 +0000https://antonz.ru/range-over-function/Что это и зачем.В Go 1.23 (август 2024) появится цикл range по функциям. Проще всего показать на примере.

Предположим, вы написали собственный тип OrderedMap, который (в отличие от обычной карты) сохраняет порядок элементов.

Сделали ему конструктор и метод Set:

m := NewOrderedMap[string, int]()
m.Set("one", 1)
m.Set("two", 2)
m.Set("thr", 3)

Хорошо, а как теперь итерироваться по карте? Традиционно это делали примерно так:

m.Range(func(k string, v int) {
  fmt.Println(k, v)
})
// one 1
// two 2
// thr 3

песочница

А с новой фичей range-over-func можно сделать так:

for k, v := range m.Range {
  fmt.Println(k, v)
}

песочница

То есть это такой синтаксический сахарок.

Стоило ли оно того

Стоило ли добавлять в язык range-over-func? На мой взгляд — нет.

Сила Go — в дубовости. У нас уже есть много фичастых языков с большой внутренней сложностью (Java, C#, C++), и Go был единственной простой мейнстрим-альтернативой для них.

Да, Go не модный, и код на нем простынявый. Но он простой до примитивности, и это хорошо.

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

Почему так происходит?

Есть подозрение, что в команде разработки Go заработал стандартный для гугла процесс — promotion-driven development. Развитием языка управляют люди, которым надо демонстрировать выполнение KPI — а значит, будут поливать Go фичами из шланга, превращая в еще одну джаву.

Ждут нас и корутины, и стримы, и паттерн-матчинг.

Штош.

]]>
Go Genericshttps://antonz.ru/generics/Tue, 11 Jun 2024 13:00:00 +0000https://antonz.ru/generics/Урок по дженерикам в Go.Рано или поздно это должно было случиться: в моем курсе по Go появился урок по дженерикам.

Совсем уж в дебри не погружает, но рассматривает базовые возможности, которых хватит в большинстве случаев:

  • Обобщенные функции и типы.
  • Параметры и ограничения типа.
  • Пакеты slices и maps.
  • Задачки! (как без них).

Урок доступен для всех и бесплатно

]]>
nil-получатель метода в Gohttps://antonz.ru/nil-method-receiver/Sat, 08 Jun 2024 13:00:00 +0000https://antonz.ru/nil-method-receiver/Метод можно вызвать на пустом указателе.Раз уж мы заговорили о пустых значениях — вы же в курсе, что метод можно вызвать даже на пустом указателе?

type english struct {
    name string
}

// e может быть nil!
func (e *english) greet() {
    if e == nil {
        fmt.Println("I'm nil")
        return
    }
    fmt.Println("Hello", e.name)
}

Прям вот так взять и вызвать:

var e *english
e.greet()
// I'm nil

И если убрать проверку на nil из метода — будет паника на обращении к e.name 🤷‍♀️

]]>