Вопросы и ответы для собеседования Go-разработчика

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

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


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


Да, это очень объемный пост, и врятли его можно вдумчиво осилить за один подход, но поместив его в закладки он, возможно, когда-то сослужит вам добрую службу (читать его можно по частям, находясь в метро или между вечными совещаниями; да и Ctrl + F никто не отменял). Ещё ему очень не хватает оглавления для удобной навигации между вопросами, но у хабраредактора нет возможности генерировать TOC (если будут запросы об этом в комментариях — сделаю его руками). Об очепятках, пожалуйста, пишите в личку.


Расскажи о себе?


Чаще всего этот вопрос идёт первым и даёт возможность интервьюверу задать вопросы связанные с твоим резюме, познакомиться с тобой, попытаться понять твой характер для построения последующих вопросов. Следует иметь в виду, что интервьюверу не всегда удается подготовиться к интервью, или он банально не имеет перед глазами твоего резюме. Тут есть смысл ещё раз представиться (часто в мессенджерах используются никнеймы, а твоё реальное имя он мог забыть), назвать свой возраст, образование, рассказать о предыдущих местах работы и должностях, сколько лет в индустрии, какие ЯП и технологии использовал — только "по верхам", для того чтоб твой собеседник просто понял с кем он "имеет дело".


Расскажи о своем самом интересном проекте?


К этому вопросу есть смысл подготовиться заранее и не спустя рукава. Дело в том, что это тот момент, когда тебе надо подобно павлину распустить хвост и создать правильное первое впечатление о себе, так как этот вопрос тоже очень часто идёт впереди всех остальных. Возьми и выпиши для себя где-нибудь на листочке основные тезисы о том, что это был за проект/сервис/задача, уделяя основное внимание тому какой профит это принесло для компании/команды в целом. Например:


  • Я со своей командой гоферов из N человек в течении трех месяцев создали аналог сервиса у которого компания покупала данные за $4000 в месяц, а после перехода на наш сервис — расходы сократились до $1500 в месяц и значительно повысилось их качество и uptime;
  • Внедренные мной практики в CI/CD пайплайны позволили сократить время на ревью изменений в проектах на 25..40%, а зная сколько стоит время работы разработчиков — вы сами всё понимаете;
  • Разработанный мной сервис состоял из такого-то набора микросервисов, такие-то службы и протоколы использовал, были такие-то ключевые проблемы которые мы так-то зарешали; основной ценностью было то-то.

Кем был создан язык, какие его особенности?


Go (часто также golang) — компилируемый многопоточный язык программирования, разработанный внутри компании Google. Разработка началась в 2007 году, его непосредственным проектированием занимались Роберт Гризмер, Роб Пайк и Кен Томпсон. Официально язык был представлен в ноябре 2009 года.


В качестве ключевых особенностей можно выделить:


  • Простая грамматика (минимум ключевых слов — язык создавался по принципу "что ещё можно выкинуть" вместо "что бы ещё в него добавить")
  • Строгая типизация и отказ от иерархии типов (но с сохранением объектно-ориентированных возможностей)
  • Сборка мусора (GC)
  • Простые и эффективные средства для распараллеливания вычислений
  • Чёткое разделение интерфейса и реализации
  • Наличие системы пакетов и возможность импортирования внешних зависимостей (пакетов)
  • Богатый тулинг "из корочки" (бенчмарки, тесты, генерация кода и документации), быстрая компиляция

Для того, чтоб вспомнить историю создания Go и о его особенностях можно посмотреть:



Go — императивный или декларативный? А в чем разница?


Go является императивным языком.


Императивное программирование — это описание того, как ты делаешь что-то (т.е. конкретно описываем необходимые действия для достижения определенного результата), а декларативное — того, что ты делаешь (например, декларативным ЯП является SQL — мы описываем что мы хотим получить от СУБД, но не описываем как именно она должна это сделать).


Что такое ООП? Как это сделано в Golang?


ООП это методология (подход) программирования, основанная на том, что программа представляет собой некоторую совокупность объектов-классов, которые образую иерархию наследования. Ключевые фишки — минимализация повторяемости кода (принцип DRY) и удобство понимания/управления. Фундаментом ООП можно считать идею описания объектов в программировании подобно объектам из реального мира — у них есть свойства, поведение, они могут взаимодействовать. Мы (люди) так понимаем мир, и нам (людям) так проще описывать всякие штуки в коде. Основные принципы в ООП:


  • Абстракция вообще присуща для любого программирования, а не только для ООП. По большому счету (топорный, но понятный пример) это про выделение общего и объединение этого в какие-то сущности но без реализации, про контракты. Например — экземпляры абстрактных классов не могут быть созданы (new AbstractClass), но могут содержать абстрактные методы, чтоб разработчик решив наследоваться от этого абстрактного класса их реализовал так, как ему нужно для своих целей (например — ходить в SQL СУБД или файл). Другой пример — это интерфейсы, они же контракты чистой воды — содержат только сигнатуры методов и ни капельки реализации. Но абстракция не ограничивается ими и должна быть умеренной, так как усложняет архитектуру приложения в общем и целом. Опираться следует на интуицию и опыт. Слишком много слоев абстракции (ещё раз — тут дело не ограничивается интерфейсами и абстрактными классами) приводит к переусложнению и головной боли последующего сопровождения продукта. Недостаточная — к сложности внесения изменений и расширению функционала
  • Инкапсуляция про контроль доступа к свойствам объекта и их динамическая валидация/преобразования. Если метод/свойство должно быть доступно "из вне" объекта — объявляем публичным, иначе — приватным. Если есть необходимость переопределять его из потомков класса — то защищенным (protected). Python, например, реализуют инкапсуляцию, но не предусматривает возможности сокрытия в принципе; в то время как С++ и Java она просто всюду
  • Наследование это возможность (барабанная дробь!) наследоваться одним объектам от других, "перенимая" все методы родительских объектов. Своеобразный вариант Матрешки. Т.е. выделяя в родительских объектах "всё общее" мы можем не повторяться в реализации частных, а просто "наследоваться"
  • Полиморфизм — "поли" — много, "морф" — вид. Везде, где есть интерфейсы — подразумевается полиморфизм. Суть — это контракты (интерфейсы), мы можем объявить "что-то умеет закрывать себя методом Close()", и нам не важно что именно это будет. Реализаций может быть много, и если это что-то умеет делать то, что нам надо — нам удобнее с этим работать

Тут же можно упомянуть про знание SOLID, а именно:


  • S (single responsibility principle, принцип единственной ответственности) — определенный класс/модуль должен решать только определенную задачу, максимально узко но максимально хорошо (своеобразные UNIX-way). Если для выполнения своей задачи ему требуются какие-то другие ресурсы — они в него должны быть инкапсулированы (это отсылка к принципу инверсии зависимостей)
  • O (open-closed principle, принцип открытости/закрытости) — классы/модули должны быть открыты для расширения, но закрыты для модификации. Должна быть возможность расширить поведение, наделить новым функционалом, но при этом исходный код/логика модуля должна быть неизменной
  • L (Liskov substitution principle, принцип подстановки Лисков) — поведение наследующих классов не должно противоречить поведению, заданному базовым классом, то есть поведение наследующих классов должно быть ожидаемым для кода
  • I (interface segregation principle, принцип разделения интерфейса) — много тонких интерфейсов лучше, чем один толстый
  • D (dependency inversion principle, принцип инверсии зависимостей) — "завязываться" на абстракциях (интерфейсах), а не конкретных реализациях. Так же (это уже про IoC, но всё же) можно рассказать что если какому-то классу для своей работы требуется функциональность другого — то есть смысл "запрашивать" её в конструкторе нашего класса используя интерфейс, под который подходит наша зависимость. Таким образом целевая реализация опирается только на интерфейсы (не зависит от реализаций) и соответствует принципу под буквой S

А теперь о том, как это реализовано в Go (наконец-то!).


В Go нет классов, объектов, исключений и шаблонов. Нет иерархии типов, но есть сами типы (т.е. возможность описывать свои типы/структуры). Структурные типы (с методами) служат тем же целям, что и классы в других языках. Так же следует упомянуть что структура определяет состояние.


В Go нет наследования. Совсем. Но есть встраивание (называемое "анонимным", так как Foo в Bar встраивается не под каким-то именем, а без него) при этом встраиваются и свойства, и функции:


import "fmt"

type Foo struct {
    name    string
    Surname string
}

func (f Foo) SayName() string { return f.name }

type Bar struct {
    Foo
}

func main() {
    bar := Bar{Foo{name: "one", Surname: "baz"}}

    fmt.Println(bar.SayName()) // one
    fmt.Println(bar.Surname)   // baz

    bar.name = "two"

    fmt.Println(bar.SayName()) // two
}

Есть интерфейсы (это типы, которые объявляют наборы методов). Подобно интерфейсам в других языках, они не имеют реализации. Объекты, которые реализуют все методы интерфейса, автоматически реализуют интерфейс (так называемый Duck-typing). Не существует наследования или подклассов или ключевого слова Implements:


import "fmt"

type Speaker interface {
    Speak() string
}

type Foo struct{}

func (Foo) Speak() string { return "foo" }

type Bar struct{}

func (Bar) Speak() string { return "bar" }

func main() {
    var foo, bar Speaker = new(Foo), &Bar{}

    fmt.Println(foo.Speak()) // foo
    fmt.Println(bar.Speak()) // bar
}

В примере выше мы объявили переменные foo и bar с явным указанием интерфейсного типа, а так интерфейс это "ссылочный" тип (на самом деле в Go нет ссылок, но есть указатели) — то и структуры мы инициализировали указателями на них с использованием new() (что аллоцирует структуру и возвращает указатель на неё) и (или) &.


Инкапсуляция реализована на уровне пакетов. Имена, начинающиеся со строчной буквы, видны только внутри этого пакета (не являются экспортируемыми). И наоборот — всё, что начинается с заглавной буквы — доступно из-вне пакета. Дешево и сердито.


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


Что можно почитать: ООП в картинках, Golang и ООП

Как устроено инвертирование зависимостей?


Инвертирование зависимостей позволяет в нашем коде не "завязываться" на конкретную реализацию (используя, например, интерфейсы), тем самым понижая связанность кода и повышая его тестируемость. Так же сужается зона ответственности конечной структуры/пакета, что повышает его переиспользуемость.


Принцип инверсии зависимостей (dependency inversion principle) в Go который можно реализовывать следующим образом:


import (
    "errors"
    "fmt"
)

type speaker interface {
    Speak() string
}

type Foo struct {
    s speaker // s *Foo - было бы плохо
}

func NewFoo(s speaker) (*Foo, error) {
    if s == nil {
        return nil, errors.New("speaker is nil")
    }

    return &Foo{s: s}, nil
}

func (f Foo) SaySomething() string { return f.s.Speak() }

func main() {
    var foo, err = NewFoo(someSpeaker)

    if err != nil {
        panic(err)
    }

    fmt.Println(foo.SaySomething()) // depends on the speaker implementation
}

Мы объявляем интерфейс speaker не экспортируемым на нашей, принимающей стороне, и используя псевдо-конструктор NewFoo гарантируем что свойство s будет проинициализировано верным типом (дополнительно проверяя его на nil).


Как сделать свои методы для стороннего пакета?


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


Типы данных и синтаксис


К фундаментальным типам данных можно отнести:


  • Целочисленные — int{8,16,32,64}, int, uint{8,16,32,64}, uint, byte как синоним uint8 и rune как синоним int32. Типы int и uint имеют наиболее эффективный размер для определенной платформы (32 или 64 бита), причем различные компиляторы могут предоставлять различный размер для этих типов даже для одной и той же платформы
  • Числа с плавающей запятой — float32 (занимает 4 байта/32 бита) и float64 (занимает 8 байт/64 бита)
  • Комплексные числа — complex64 (вещественная и мнимая части представляют числа float32) и complex128 (вещественная и мнимая части представляют числа float64)
  • Логические aka bool
  • Строки string

Как устроены строки в Go?


В Go строка в действительности является слайсом (срезом) байт, доступным только для чтения. Строка содержит произвольные байты, и у неё нет ёмкости (cap). При преобразовании слайса байт в строку (str := string(slice)) или обратно (slice := []byte(str)) — происходит копирование массива (со всеми следствиями).


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


var (
    str = "hello world"
    sub = str[0:5]
    usr = "/usr/kot"[5:]
)

print(sub, " ", usr) // hello kot

Go использует тип rune (алиас int32) для представления Unicode. Конструкция for ... range итерирует строку посимвольно (а не побайтово, как можно было бы предположить):


var str = "привет"

println(str, len(str)) // привет 12

for i, c := range str {
    println(i, c, string(c))
}

// 0 1087 п
// 2 1088 р
// 4 1080 и
// 6 1074 в
// 8 1077 е
// 10 1090 т

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


Эффективным способом работы со строками (когда есть необходимость часто выполнять конкатенацию, например) является использование слайса байт или strings.Builder:


import "strings"

func main() { // происходит только 1 аллокация при вызове `Grow()`
    var str strings.Builder

    str.Grow(12) // сразу выделяем память

    str.WriteString("hello")
    str.WriteRune(' ')
    str.WriteString("мир")

    println(str.String()) // hello мир
}

И ещё одну важную особенность стоит иметь в виду — это подсчет длины строки (например — для какой-нибудь валидации). Если считать по количеству байт, и строка содержит не только ASCII символы — то количество байт и фактическое количество символов будут расходиться:


const str = "hello мир!"

println(len(str), utf8.RuneCountInString(str)) // 13 10

Тут дело в том, что для кодирования символов м, и и р используются 2 байта вместо одного. Поэтому len == 13, а фактически в строке лишь 10 символов (пакет utf8, к примеру, нам в помощь).


Что можно почитать: Строка, байт, руна, символ в Golang

В чём ключевое отличие слайса (среза) от массива?


  • Срез — всегда указатель на массив, массив — значение
  • Срез может менять свой размер и динамически аллоцировать память

В Go не бывает ссылок — но есть указатели. Где говорится про "по ссылке" имеется в виду "по указателю"

Слайсы и массивы в Go это проиндексированные упорядоченные структуры данных последовательностей элементов. Ёмкость массива объявляется в момент его создания, и после изменить её уже нельзя (его длина это часть его типа). Память, необходимая для хранения элементов массива выделяется соответственно сразу при его объявлении, и по умолчанию инициализируется в соответствии с нулевыми значением для типа (fasle для bool, 0 для int, nil для интерфейсов и т.д.). На стеке можно разместить массив объемом 10 MB. В качестве размера можно использовать константы (компилятор должен знать это значение на этапе компиляции, т.е. что-то вида var a [getSize()]int или i := 3; var a [i]int недопустимо):


const mySize uint8 = 8

type myArray [mySize]byte

var constSized = [...]int{1, 2, 3} // размер сам посчитается исходя из кол-ва эл-ов

Кстати, массивы с элементами одного типа но с разными размерами являются разными типами. Массивы не нужно инициализировать явно; нулевой массив — это готовый к использованию массив, элементы которого являются нулями:


var a [4]int // [0 0 0 0]

a[0] = 1  // [1 0 0 0]
i := a[0] // i == 1

Представление [4]int в памяти — это просто четыре целых значения, расположенных последовательно. Так же следует помнить что в Go массивы передаются по значению, т.е. передавая массив в какую-либо функцию она получает копию массива (для передачи его указателя нужно явно это указывать, т.е. foo(&a)).


А слайс же это своего рода версия массива но с вариативным размером (структура данных, которая строится поверх массива и предоставляет доступ к элементами базового массива). Слайсы до 64 KB могут быть размещены на стеке. Если посмотреть исходники Go (src/runtime/slice.go), то увидим:


type slice struct {
    array unsafe.Pointer // указатель на массив
    len   int            // длина (length)
    cap   int            // вместимость (capacity)
}

Для аллокации слайса можно воспользоваться одной из команд ниже:


var (
    a = []int{}            // []              len=0 cap=0
    b = []int{1, 2}        // [1 2]           len=2 cap=2
    c = []int{5: 123}      // [0 0 0 0 0 123] len=6 cap=6
    d = make([]int, 5, 10) // [0 0 0 0 0]     len=5 cap=10
)

В последнем случае рантайм Go создаст массив из 10 элементов (выделит память и заполнит их нулями) но доступны прямо сейчас нам будут только 5, и установит значения len в 5, а cap в 10. Cap означает ёмкость и помогает зарезервировать место в памяти на будущее, чтобы избежать лишних операций выделения памяти при росте слайса (это ключевой параметр для аллокации памяти, влияет на производительность вставки в срез). При добавлении новых элементов в слайс новый массив для него не будет создаваться до тех пор, пока cap меньше len.


Слайсы передаются "по ссылке" (фактически будет передана копия структуры slice со своими len и cap, но указатель на массив array будет тот-же самый). Для защиты слайса от изменений следует передавать его копию:


var (
    a = []int{1, 2, 0, 0, 1}
    b = make([]int, len(a))
)

copy(b, a)

fmt.Println(a, b) // [1 2 0 0 1] [1 2 0 0 1]

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


var (
    one = []int{1, 2} // [1 2]
    two = one         // [1 2]
)

two[0] = 123

fmt.Println(one, two) // [123 2] [123 2]

one = append(one, 666)

fmt.Println(one, two) // [123 2 666] [123 2]

Что можно почитать: Как не наступать на грабли в Go, Слайсы в Go: использование и особенности, Принцип работы типа slice в GO

Как вы отсортируете массив структур по алфавиту по полю Name?


Например, преобразую массив в слайс и воспользуюсь функцией sort.SliceStable:


package main

import (
  "fmt"
  "sort"
)

func main() {
  var arr = [...]struct{ Name string }{{Name: "b"}, {Name: "c"}, {Name: "a"}}
  //             ^^^^^^^^^^^^^^^^^^^^^ анонимная структура с нужным нам полем

  fmt.Println(arr) // [{b} {c} {a}]

  sort.SliceStable(arr[:], func(i, j int) bool { return arr[i].Name < arr[j].Name })
  //                  ^^^ вот тут вся "магия" - из массива сделали слайс

  fmt.Println(arr) // [{a} {b} {c}]
}

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


Как работает append в слайсе?


append() делает простую операцию — добавляет элементы в слайс и возвращает новый. Но под капотом там делаются довольно сложные манипуляции, чтобы выделять память только при необходимости и делать это эффективно.


Сперва append сравнивает значения len и cap у слайса. Если len меньше чем cap, то значение len увеличивается, а само добавляемое значение помещается в конец слайса. В противном случае происходит выделение памяти под новый массив для элементов слайса, в него копируются значения из старого, и значение помещается уже в новый массив.


Увеличении размера слайса (метод growslice) происходит по следующему алгоритму — если его размер менее 1024 элементов, то его размер будет увеличиваться вдвое; иначе же слайс увеличивается на ~12.5% от своего текущего размера.


Что важно помнить — если на основании слайса one выделить подслайс two, а затем увеличим слайс one (и его вместимость будет превышена) — то one и two будут уже ссылаться на разные участки памяти!


var (
    one = make([]int, 4) // [0 0 0 0]
    two = one[1:3]       // [0 0]
)

one[2] = 11

fmt.Println(one, two)           // [0 0 11 0] [0 11]
fmt.Printf("%p %p\n", one, two) // 0xc0000161c0 0xc0000161c8

one = append(one, 1)

fmt.Printf("%p %p\n", one, two) // 0xc00001c1c0 0xc0000161c8

one[2] = 22

fmt.Println(one, two)           // [0 0 22 0 1] [0 11]
fmt.Printf("%p %p\n", one, two) // 0xc00001c1c0 0xc0000161c8

Есть еще много примеров добавления, копирования и других способов использования слайсов тут — Slice Tricks.


Что можно почитать: Как не наступать на грабли в Go

Задача про слайсы #1


Вопрос: У нас есть 2 функции — одна делает append() чего-то в слайс, а другая просто сортирует слайс, используя пакет sort. Модифицируют ли слайс первая и (или) вторая функции?


Ответ: append() не модифицирует а возвращает новый слайс, а sort модифицирует порядок элементов, если он изначально был не отсортирован.


Задача про слайсы #2


Вопрос: Что выведет следующая программа?


package main

import "fmt"

func main() {
  a := [5]int{1, 2, 3, 4, 5}
  t := a[3:4:4]

  fmt.Println(t[0])
}

Ответ

Выведет 4


Объяснение: Такой синтаксис позволяет задать capacity (вместимость) для полученного под-слайса, который будет равен "последний элемент минус первый элемент из выражения в квадратных скобках", т.е. из примера выше он будет равен 1 (т.к. от четырёх, т.е. третьего сегмента вычитаем первый, т.е. тройку). Если бы выражение имело вид a[3:4:5], то cap была бы равна 2 (5 — 3 = 2). Но при этом на сами данные он не влияет.


Появилась эта штука в Go 1.2.


Что можно почитать: Slicing a slice with slice [a : b : c], Full slice expressions

Какое у слайса zero value? Какие операции над ним возможны?


Zero value у слайса всегда nil, а len и cap равны нулю, так как "под ним" нет инициализированного массива:


var a []int

println(a == nil, len(a), cap(a)) // true 0 0
a = append(a, 1)
println(a == nil, len(a), cap(a)) // false 1 1

Как видно из примера выше — несмотря на то, что a == nil (слайс "не инициализирован"), с этим слайсом возможна операция append — в этом случае Go самостоятельно создаёт нижележащий массив и всё работает так, как и ожидается. Более того — для полной очистки слайса рекомендуется его присваивать к nil.


Так же важно помнить, что не делая make для слайса — не получится сделать пре-аллокацию, что часто очень болезненно для производительности.


Что можешь рассказать про map?


Карта (map или hashmap) — это неупорядоченная коллекция пар вида ключ-значение. Пример:


type myMap map[string]int

Подобно массивам и слайсам, к элементам мапы можно обратиться с помощью скобок:


var m = make(map[string]int) // инициализация

m["one"] = 1 // запись в мапу

fmt.Println(m["one"], m["two"]) // 1 0

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

В случае с m["two"] вернулся 0 так как это является нулевым значением для типа int. Для проверки существования ключа используем конструкцию вида (доступ к элементу карты может вернуть два значения вместо одного) называемую "multiple assignment":


var m = map[string]int{"one": 1}

v1, ok1 := m["one"] // чтение
v2, ok2 := m["two"]

fmt.Println(v1, ok1) // 1 true
fmt.Println(v2, ok2) // 0 false

for k, v := range m { // итерация всех эл-ов мапы
    fmt.Println(k, v)
}

delete(m, "one") // удаление

v1, ok1 = m["one"]

fmt.Println(v1, ok1) // 0 false

Мапы всегда передаются по ссылке (вообще-то Go не бывает ссылок, невозможно создать 2 переменные с 1 адресом, как в С++ например; но зато можно создать 2 переменные, указывающие на один адрес — но это уже указатели). Если же быть точнее, то мапа в Go — это просто указатель на структуру hmap:


type hmap struct {
    // Note: the format of the hmap is also encoded in cmd/compile/internal/reflectdata/reflect.go.
    // Make sure this stays in sync with the compiler's definition.
    count     int // # live cells == size of map.  Must be first (used by len() builtin)
    flags     uint8
    B         uint8  // log_2 of # of buckets (can hold up to loadFactor * 2^B items)
    noverflow uint16 // approximate number of overflow buckets; see incrnoverflow for details
    hash0     uint32 // hash seed

    buckets    unsafe.Pointer // array of 2^B Buckets. may be nil if count==0.
    oldbuckets unsafe.Pointer // previous bucket array of half the size, non-nil only when growing
    nevacuate  uintptr        // progress counter for evacuation (buckets less than this have been evacuated)

    extra *mapextra // optional fields
}

Так же структура hmap содержит в себе следующее:


  • Количество элементов
  • Количество "ведер" (представлено в виде логарифма для ускорения вычислений)
  • Seed для рандомизации хэшей (чтобы было сложнее заddosить — попытаться подобрать ключи так, что будут сплошные коллизии)
  • Всякие служебные поля и главное указатель на buckets, где хранятся значения


На картинке схематичное изображение структуры в памяти — есть хэдер hmap, указатель на который и есть map в Go (именно он создается при объявлении с помощью var, но не инициализируется, из-за чего падает программа при попытке вставки). Поле buckets — хранилище пар ключ-значение, таких "ведер" несколько, в каждом лежит 8 пар. Сначала в "ведре" лежат слоты для дополнительных битов хэшей (e0..e7 названо e — потому что extra hash bits). Далее лежат ключи и значения как сначала список всех ключей, потом список всех значений.


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


Как растет map?


В исходном коде можно найти строчку Maximum average load of a bucket that triggers growth is 6.5. То есть, если в каждом "ведре" в среднем более 6,5 элементов, происходит увеличение массива buckets. При этом выделяется массив в 2 раза больше, а старые данные копируются в него маленькими порциями каждые вставку или удаление, чтобы не создавать очень крупные задержки. Поэтому все операции будут чуть медленнее в процессе эвакуации данных (при поиске тоже, нам же приходится искать в двух местах). После успешной эвакуации начинают использоваться новые данные.


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


Что там про поиск?


Поиск, если разобраться, устроен не так уж и сложно: проходимся по цепочкам "ведер", переходя в следующее, если в этом не нашли. Поиск в "ведре" начинается с быстрого сравнения дополнительного хэша, для которого используется всего 8 бит (вот для чего эти e0...e7 в начале каждого — это "мини" хэш пары для быстрого сравнения). Если не совпало, идем дальше, если совпало, то проверяем тщательнее — определяем где лежит в памяти ключ, подозреваемый как искомый, сравниваем равен ли он тому, что запросили. Если равен, определяем положение значения в памяти и возвращаем.


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


Что можно почитать: Хэш таблицы в Go. Детали реализации, Кажется, поиск в map дороже чем O(1)

Есть ли у map такие же методы как у слайса: len, cap?


У мапы есть len но нет cap. У нас есть только overflow который указывает "куда-то" когда мапа переполняется, и поэтому у нас не может быть capacity.


Какие типы ключей разрешены для ключа в map?


Любым сравнимым (comparable) типом, т.е. булевы, числовые, строковые, указатели, канальные и интерфейсные типы, а также структуры или массивы, содержащие только эти типы. Слайсы, мапы и функции использовать нельзя, так как эти типы не сравнить с помощью оператора == или !=.


Может ли ключом быть структура? Если может, то всегда ли?


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


Что будет в map, если не делать make или short assign?


Будет паника (например — при попытке что-нибудь в неё поместить), так как любые "структурные" типы (а мапа как мы знаем таковой является) должны быть инициализированы для работы с ними.


Race condition. Потокобезопасна ли мапа?


Нет, потокобезопасной является sync.Map. Для обеспечения безопасности вокруг мапы обычно строится структура вида:


type ProtectedIntMap struct {
    mx sync.RWMutex
    m  map[string]int
}

func (m *ProtectedIntMap) Load(key string) (val int, ok bool) {
    m.mx.RLock()
    val, ok = m.m[key]
    m.mx.RUnlock()

    return
}

func (m *ProtectedIntMap) Store(key string, value int) {
    m.mx.Lock()
    m.m[key] = value
    m.mx.Unlock()
}

Что такое интерфейс?


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


Как устроен Duck-typing в Go?


Если это выглядит как утка, плавает как утка и крякает как утка, то это, вероятно, утка и есть.

Если структура содержит в себе все методы, что объявлены в интерфейсе, и их сигнатуры совпадают — она автоматически удовлетворяет интерфейс.


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


Интерфейсный тип


В Go интерфейсный тип выглядит вот так:


type iface struct {
    tab  *itab
    data unsafe.Pointer
}

Где tab — это указатель на Interface Table или itable — структуру, которая хранит некоторые метаданные о типе и список методов, используемых для удовлетворения интерфейса, а data указывает на реальную область памяти, в которой лежат данные изначального объекта (статическим типом).


Компилятор генерирует метаданные для каждого статического типа, в которых, помимо прочего, хранится список методов, реализованных для данного типа. Аналогично генерируются метаданные со списком методов для каждого интерфейса. Теперь, во время исполнения программы, runtime Go может вычислить itable на лету (late binding) для каждой конкретной пары. Этот itable кешируется, поэтому просчёт происходит только один раз.


Зная это, становится очевидно, почему Go ловит несоответствия типов на этапе компиляции, но кастинг к интерфейсу — во время исполнения.


Что важно помнить — переменная интерфейсного типа может принимать nil. Но так как объект интерфейса в Go содержит два поля: tab и data — по правилам Go, интерфейс может быть равен nil только если оба этих поля не определены (faq):


var (
    builder  *strings.Builder
    stringer fmt.Stringer
)

fmt.Println(builder, stringer) // nil nil
fmt.Println(stringer == nil)   // true
fmt.Println(builder == nil)    // true

stringer = builder

fmt.Println(builder, stringer) // nil nil
fmt.Println(stringer == nil)   // false (!!!)
fmt.Println(builder == nil)    // true

Пустой interface{}


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


Что можно почитать: Краш-курс по интерфейсам в Go, Реализация интерфейсов в Golang, Интерфейсы в Go — как красиво выстрелить себе в ногу

На какой стороне описывать интерфейс — на передающей или принимающей?


Многое зависит от конкретного случая, но по умолчанию описывать интерфейсы следует на принимающей стороне — таким образом, ваш код будет меньше зависеть от какого-то другого кода/пакета/реализации.


Другими словами, если нам в каком-то месте требуется "что-то что умеет себя закрывать", или — умеет метод Close() error, или (другими словами) удовлетворят интерфейсу:


type something interface {
    Close() error
}

То он (интерфейс) должен быть описан на принимающей стороне. Так принимающая сторона не будет ничего знать о том, что именно в неё может "прилететь", но точно знает поведение этого "чего-то". Таким образом реализуется инверсия зависимости, и код становится проще переиспользовать/тестировать.


Что такое замыкание?


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


Функции, у которых есть имя — это именованные функции. Функции, которые могут быть созданы без указания имени — это анонимные функции.


func main() {
    var text = "some string"

    var ourFunc = func() { // именованное замыкание
        println(text)
    }

    ourFunc() // some string
    getFunc()() // another string
}

func getFunc() func() {
    return func() { // анонимное
        println("another string")
    }
}

Замыкания сохраняют состояние. Это означает, что состояние переменных содержится в замыкании в момент декларации. Одна из самых очевидных ловушек — это создание замыканий в цикле:


var funcs = make([]func(), 0, 5)

for i := 0; i < 5; i++ {
    funcs = append(funcs, func() { println("counter =", i) })

    // исправляется так:
    //var value = i
    //funcs = append(funcs, func() { println("counter =", value) })
}

for _, f := range funcs {
    f()
}

// counter = 5 (так все 5 раз)

Что можно почитать: Замыкания

Что такое сериализация? Зачем она нужна?


Сериализация — это процесс преобразования объекта в поток байтов для сохранения или передачи. Обратной операцией является десериализация (т.е. восстановление объекта/структуры из последовательности байтов). Синонимом можно считать термин "маршалинг" (от англ. marshal — упорядочивать).


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


Типичными примерами сериализации в Go являются преобразование структур в json-объекты. Кроме json существуют различные кодеки типа MessagePack, CBOR и т.д.


Что такое type switch?


Проверка типа переменной, а не её значения. Может быть в виде одного switch и множеством case:


package main

func checkType(i interface{}) {
  switch i.(type) {
  case int:
    println("is integer")

  case string:
    println("is string")

  default:
    println("has unknown type")
  }
}

А может в виде if-конструкции:


package main

func main() {
    var any interface{}

    any = "foobar"

    if s, ok := any.(string); ok {
        println("this is a string:", s)
    }

    // а так можно проверить наличие функций у структуры
  if closable, ok := any.(interface{ Close() }); ok {
    closable.Close()
  }
}

Какие битовые операции знаешь?


Побитовые операторы проводят операции непосредственно на битах числа.


// Побитовое И/AND (разряд результата равен 1 только тогда, когда оба соответствующих бита операндов равны 1)
println(0b111_000 /* 56 */ & 0b011_110 /* 30 */ == 0b011_000 /* 24 */)

// Побитовое ИЛИ/OR (разряд результата равен 0 только тогда, когда оба соответствующих бита в равны 0)
println(0b111_000 /* 56 */ | 0b011_110 /* 30 */ == 0b111_110 /* 62 */)

// Исключающее ИЛИ/XOR (разряд результата равен 1 только тогда, когда только один бит равен 1)
println(0b111_000 /* 56 */ ^ 0b011_110 /* 30 */ == 0b100_110 /* 38 */)

// Сброс бита AND NOT
println(0b111_001 /* 57 */ &^ 0b011_110 /* 30 */ == 0b100_001 /* 33 */)

// Сдвиг бита влево
println(0b000_001 /* 1 */ << 3 == 0b001_000 /* 8 */)

// Сдвиг бита вправо
println(0b000_111 /* 7 */ >> 1 == 0b000_011 /* 3 */)

Пример использования простой битовой маски:


type Bits uint8

const (
    F0 Bits = 1 << iota // 0b00_000_001 == 1
    F1                  // 0b00_000_010 == 2
    F2                  // 0b00_000_100 == 4
)

func Set(b, flag Bits) Bits    { return b | flag }
func Clear(b, flag Bits) Bits  { return b &^ flag }
func Toggle(b, flag Bits) Bits { return b ^ flag }
func Has(b, flag Bits) bool    { return b&flag != 0 }

func main() {
    var b Bits

    b = Set(b, F0)
    b = Toggle(b, F2)

    for i, flag := range [...]Bits{F0, F1, F2} {
        println(i, Has(b, flag))
    }
    // 0 true
    // 1 false
    // 2 true
}

Что можно почитать: О битовых операциях, Поразрядные операции

Дополнительный блок фигурных скобок в функции


Его можно использовать, и он означает отдельный скоуп для всех переменных, объявленных в нём (возможен и "захват переменных" объявленных вне скоупа ранее, естественно). Иногда используется для декомпозиции какого-то отдельного куска функции, к примеру.


var i, s1 = 1, "foo"

{
    var j, s2 = 2, "bar"

    println(i, s1) // 1 foo
    println(j, s2) // 2 bar

    s1 = "baz"
}

println(i, s1) // 1 baz
//println(j, s2) // ERROR: undefined: j and s2

Так же это может быть связано с AST (Abstract Syntax Tree) — когда оно строится и происходят SSA (Static Single Assignment) оптимизации, к сожалению SSA не работает на всю длину дерева. Как следствие, если у нас слишком длинная функция (примерно дохулион строк) и мы по каким-то причинам не можем её декомпозировать, но можем изолировать какие-то скоупы то, таким образом, мы помогаем SSA произвести оптимизации (если они возможно).


Что такое захват переменной?


Во вложенном скоупе есть возможность обращаться к переменным, объявленных в скоупе выше (но не наоборот). Обращение к переменным из вышестоящего скоупа и есть их захват. Типичной ошибкой является использование значение итератора в цикле:


var out []*int

for i := 0; i < 3; i++ {
    out = append(out, &i)
}

println(*out[0], *out[1], *out[2]) // 3 3 3

Испраляется путём создания локальной (для скоупа цикла) переменной с копией знаяения итератора:


var out []*int

for i := 0; i < 3; i++ {
    i := i // Copy i into a new variable.
    out = append(out, &i)
}

println(*out[0], *out[1], *out[2]) // 0 1 2

Что можно почитать: Using reference to loop iterator variable

Как работает defer?


Defer является функцией отложенного вызова. Выполняется всегда (даже в случае паники внутри функции вызываемой) после того, как функция завершила своё выполнение но до того, как управление вернётся вызывающей стороне (более того — внутри defer возможен захват переменных, и даже возвращаемого результата). Часто используется для освобождения ресурсов/снятия блокировок. Пример использования:


func main() {
    println("result =", f())
    // f started
    // defer
    // defer in defer
    // result = 25
}

func f() (i int) {
    println("f started")

    defer func() {
        recover()

        defer func() { println("defer in defer"); i += 5 }()

        println("defer")

        i = i * 2
    }()

    i = 10

    panic("panic is here")
}

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


Как работает init?


В Go есть предопределенная функция init(). Она выделяет фрагмент кода, который должен выполняться перед всеми другими частями пакета. Этот код будет выполняться сразу после импорта пакета.


Также функция init() используется для автоматической регистрации одного пакета в другом (например, так работает подавляющее большинство "драйверов" для различных СУБД, например — go-sql-driver/mysql/driver.go).


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


Хотя использование init() и является довольно полезным, но часто оно затрудняет чтение/понимание кода, и (почти) всегда можно обойтись без неё, поэтому необходимость её использования — всегда очень большой вопрос.


Прерывание for/switch или for/select


Что произойдёт в следующем примере, если f() вернёт true?


for {
  switch f() {
  case true:
    break
  case false:
    // Do something
  }
}

Очевидно, будет вызван break. Вот только прерван будет switch, а не цикл for. Простое решение проблемы – использовать именованный (labeled) цикл и вызывать break c этой меткой, как в примере ниже:


loop:
  for {
    switch f() {
    case true:
      break loop
    case false:
      // Do something
    }
  }

Сколько можно возвращать значений из функции?


Теоретически, неограниченное количество значений. Так же хочется отметить, что есть правила "де-факто", которых следует придерживаться:


  • Последним значением возвращать ошибку, если её возврат подразумевается
  • Первым значением возвращать контекст, если он подразумевается
  • Хорошим тоном является не возвращать более четырёх значений
  • Если функция что-то проверяет и возвращает значение + булевый результат проверки — то результат проверки возвращать последним (пример — os.LookupEnv(key string) (string, bool))
  • Если возвращается ошибка, то остальные значения возвращать нулевыми или nil

Дженерики — это про что?


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


В версии 1.18 появились дженерики (вообще-то они были и ранее, но мы не могли их использовать в своём коде — вспомни функцию make(T type)), и они позволяют объявлять (описывать) универсальные методы, т.е. в качестве параметров и возвращаемых значений указывать не один тип, а их наборы.


Появились новые ключевые слова:


  • any — аналог interface{}, можно использовать в любом месте (func do(v any) any, var v any, type foo interface { Do() any })
  • comparable — интерфейс, который определяет типы, которые могут быть сравнены с помощью == и != (переменные такого типа создать нельзя — var j comparable будет вызывать ошибку)

И появилась возможность определять интерфейсы, которые можно будет использовать в параметризованных функциях и типах (переменные такого типа создать нельзя — var j Int будет вызывать ошибку):


type Int interface {
    int | int32 | int64
}

Если добавить знак ~ перед типами то интерфейсу будут соответствовать и производные типы, например myInt из примера ниже:


type Int interface {
    ~int | ~int32 | ~int64
}

type myInt int

Разработчики golang создали для нас уже готовый набор интерфейсов (пакет constraints), который очень удобно использовать.


Параметризованные функции


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


import "constraints"

func Max[T constraints.Ordered](a T, b T) T {
  if a > b {
    return a
  }

  return b
}

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


Для слайсов и мап был создан набор готовых полезных функций.


Параметризованные типы


import "reflect"

type myMap[K comparable, V any] map[K]V

func main() {
  m := myMap[int, string]{5: "foo"}

    println(m[5])              // foo
    println(reflect.TypeOf(m)) // main.myMap[int,string]
}

Что можно почитать: Зачем нужны дженерики в Go?, Golang пощупаем дженерики

Память и управление ей


Что такое heap и stack?


Стек (stack) — это область оперативной памяти, которая создаётся для каждого потока. Он работает в порядке LIFO (Last In, First Out), то есть последний добавленный в стек кусок памяти будет первым в очереди на вывод из стека. Каждый раз, когда функция объявляет новую переменную, она добавляется в стек, а когда эта переменная пропадает из области видимости (например, когда функция заканчивается), она автоматически удаляется из стека. Когда стековая переменная освобождается, эта область памяти становится доступной для других стековых переменных.


Стек быстрый, так как часто привязан к кэшу процессора. Размер стека ограничен, и задаётся при создании потока.


Куча (heap) — это хранилище памяти, также расположенное в ОЗУ, которое допускает динамическое выделение памяти и не работает по принципу стека: это просто склад для ваших переменных. Когда вы выделяете в куче участок памяти для хранения переменной, к ней можно обратиться не только в потоке, но и во всем приложении. Именно так определяются глобальные переменные. По завершении приложения все выделенные участки памяти освобождаются. Размер кучи задаётся при запуске приложения, но, в отличие от стека, он ограничен лишь физически, и это позволяет создавать динамические переменные.


В сравнении со стеком, куча работает медленнее, поскольку переменные разбросаны по памяти, а не сидят на верхушке стека. То что попадает в кучу, живёт там пока не придёт GC.


Но почему стек так быстр? Основных причин две:


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

Где выделяется память под переменную? Можно ли этим управлять?


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


Память под переменную может быть выделена в куче (heap) или стеке (stack). Очень приблизительно:


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

Давайте рассмотрим простой пример, в котором вы возвращаем значение:


func getFooValue() foo {
    var result foo
    // Do something
    return result
}

Здесь переменная result создаётся в текущей горутине. И эта переменная помещается в стек. Как только функция завершает работу, клиент получает копию этой переменной. Исходная переменная вытесняется из стека. Эта переменная всё ещё существует в памяти, до тех пор, пока не будет затёрта другой переменной, но к этой переменной уже нельзя получить доступ.


Теперь тот же пример, но с указателем:


func getFooPointer() *foo {
    var result foo
    // Do something
    return &result
}

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


В подобном сценарии компилятор Go вынужден переместить переменную result туда, где она может быть доступна (shared) – в кучу (heap).


Хотя есть и исключение. Для примера:


func main()  {
    p := &foo{}
    f(p)
}

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


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


Как работает Garbage Collection (GC) в Go?


Garbage Collection — это процесс освобождения места в памяти, которая больше не используется. Стек освобождается быстро и просто (условно-самостоятельно), а вот с кучей имеются некоторые сложности.


В основе работы GC в Go лежит:


  • "Трехцветный алгоритм пометки и очистки" (выполняется параллельно с основной программой) — все данные в куче представляются в виде связанного графа, каждая вершина которого (каждый объект, данные) может быть помечена как "белая", "серая", или "чёрная"; данный граф обходится в несколько проходов, все вершины размечаются своими цветами, и "белые" (мусорные) объекты могут быть удалены ("чёрные" — точно нельзя удалять; "серые" — под вопросом, пока не трогать)
  • Write Barrier, следящий за тем, чтоб черные объекты не указывали на белые; и "останавливать мир" (Stop The World, STW) для включения или отключения Write Barrier

GC можно вызвать ручками — runtime.GC(), но пользоваться этим нужно с осторожностью (есть риск блокировки вызывающей стороны или всего приложения целиком).


По умолчанию, GC запускается самостоятельно когда размер кучи становится в 2 раза больше (за это отвечает Pacer; данный коэффициент можно регулировать при сборке с помощью env GOGC).


Полный цикл работы GC:


  1. Sweep termination — фаза завершения очистки:
    • Stop the World
    • Ожидаем пока все горутины достигнут safe-point
    • Завершаем очистку ресурсов
  2. Mark phase — фаза разметки (выполняется конкурентно с основной программой, выделяется на неё ~25% CPU):
    • Включаем Write Barrier
    • Start the World
    • Запускаем сканирование глобальных переменных и стеков
    • При сканировании работа горутины приостанавливается (но не происходит полная остановка всей программы)
    • Выполняем 3-х цветный алгоритм поиска мусора
  3. Mark termination — фаза завершения разметки
    • Stop the World (не является обязательной, но с ней проце было реализовать)
    • Дожидаемся завершения обработки последних задач из очереди
    • Очистка кэшей
    • Завершаем разметку
  4. Sweep phase — фаза очистки
    • Отключаем Write Barrier
    • Start The World
    • Очистка ресурсов происходит в фоне

Источник: https://habr.com/ru/post/658623/


Интересные статьи

Интересные статьи

Все больше компаний на фоне «Великого увольнения» используют тет-а-теты со своими ключевым специалистами, чтобы получить обратную связь и удержать их в штате. Как это происходит.
Продолжая тему устройства в FAANG, которую уже мы поднимали в нашем блоге, и специально к старту нового потока нашего курса по алгоритмам сегодня делюсь описанием пути Эско Обонга, старше...
Собеседование инженера программиста сегодня часто включает в себя некий тест или упражнение на программирование, и я думаю, что это очень плохая вещь. Вот почему. Приятн...
Привет, друзья! Меня зовут Петр, я представитель малого белорусского бизнеса со штатом чуть более 20 сотрудников. В данной статье хочу поделиться негативным опытом покупки 1С-Битрикс. ...
Недавно общался со своим знакомым. Парнишка учится Android разработке и обладает довольно крепким багажом знаний. Я ему задал вопрос: «Почему ты до сих пор не ходишь на собеседования? Ты бы у...