Это статья об основах программирования на Go. На канале я рассказываю об опыте перехода в IT с нуля, структурирую информацию и делюсь мнением.
Хой, джедаи и амазонки!
Цель поста - собрать воедино знания из разных областей компьютерных наук, программирования и работы с базами данных для подготовки к собеседованию на junior-go разработчика: понимание классического объектно-ориентированного программирования, основных элементов Golang и PostrgeSQL, конкурентности и т.д.
В ходе поиска информации я посчитал, что недостаточно знать, что такое ООП, нужно хотя бы знать, какие ещё подходы к программированию бывают. Недостаточно знать про конкурентность, нужно понимать, какие ещё бывают способы вычислений и чем асинхронность отличается от многопоточности. Недостаточно знать, что горутина - это легковесный поток, нужно понимать, что такое поток и как происходит его смена, хотя бы примерно.
В публикации будет не то чтобы глубокий фундаментальный разбор, но хороший средний уровень понимания всех концепций. Кроме того в большинстве случаев не будут разъясняться элементарные вещи, типа как создать слайс или объявить переменную. Тут будут детали, которые в ходе первичного обучения мы могли не понять полностью или эти детали целенаправленно не доводились до нас людьми, которые подготавливали обучающие материалы, чтобы облегчить процесс усвоения знаний. Но теперь, когда первичное обучение позади, можно немного углубиться.
Кстати, в ходе сбора материалов, большинство публикаций были либо слишком поверхностными, либо слишком углублёнными для сеньоров-архитекторов, которых будто готовят для создания своих языков программирования. Я постарался сделать материал, в котором отражена суть всех процессов, но при этом в доступной форме.
Перечень тем, разобранных в публикации:
Golang:
- ООП;
- ООП в Golang;
- Передача параметров;
- Типы данных;
- Конкурентность;
- Каналы;
- Контексты.
PostgreSQL:
- Первичный ключ;
- Внешний ключ;
- Связи;
- Join.
Задачи:
- defer. Переменные;
- defer. Порядок вызова;
- goroutine, waitgroup.
Поехали!
1. Golang
1.1. Объектно-ориентированное программирование или ООП
Идеологически, ООП - подход к программированию в виде моделирования реальных объектов в информационные объекты со структурированием информации.
Возьмём объект из реального мира - камень. Камень имеет геометрические размеры - пусть это будет камень в форме кирпича с размерами длина-ширина-высота, а также материал - глина, а также масса - 1 кг, а также плотность, шероховатость и разные другие могут быть свойства.
У объекта есть поведение - то, что в реальном мире можно с этим объектом сделать. Можно поднять камень, пронести его, бросить, вложить в стену, разбить, окунуть в воду и т.д. - это поведение объекта. Вот объектно-ориентированный стиль (парадигма) построен на систематизации свойств и поведении объектов. Естественно, в программировании описываются не все свойства и поведение объектов, а те, что нужны для решения задачи.
Объектно-ориентированное программирование систематизирует свойства и поведения реальных объектов и регулирует взаимодействие между объектами для удобства поддержания кода.
Есть множество парадигм программирования, а языки программирования поддерживают одну или несколько парадигм, или их части. Ниже фрагмент таблицы из английской Википедии, где языкам сопоставляются некоторые парадигмы. По недосмотру, в Go ООП отсутствует:
Пару слов для общего кругозора о других парадигмах.
Процедурная парадигма была первой и самой простой. Идея следующая: нет модулей (пакетов), программа может быть написана в нескольких файлах, но модуль будет один. Код - это последовательность функций. По сути наши первые учебные проекты в Go, когда мы всё писали в пакете main - это процедурный подход.
Императивная парадигма - использование операторов для изменения состояния программы с явным обозначением деталей реализации.
Декларативная парадигма - это способ, когда программист описывает, что нужно сделать без детальных этапов реализации. Например, SQL-запрос SELECT * FROM table; можно рассматривать как декларативный, потому что мы запрашиваем данные, но СУБД сама решает, как оптимально извлечь данные из БД. Это позволяет проще работать с информацией.
Возвращаемся к ООП.
ООП позволяет проще управлять информацией и реализовывать поддерживаемые крупные проекты.
Управляемость обеспечивается минимизацией избыточности данных и их целостностью. А польза этой управляемости обычно выявляется не в процессе разработки, а в процессе поддержки кода: тестирования, отладки, расширения.
ООП имеет почти 60-и-летнюю историю: первый язык с ключевыми элементами и принципами ООП - Simula 67 появился в 1967 г. Но, несмотря на это, до сих пор не существует чёткого общепринятого определения данной технологии. Основные принципы, заложенные в первые объектные языки и системы, подверглись существенному изменению (или искажению) и дополнению при многочисленных реализациях последующего времени. Более того, началась какая-то свистопляска с ООП:
Роджер Кинг аргументированно настаивал, что его кот является объектно ориентированным. Кроме прочих своих достоинств, кот демонстрирует характерное поведение, реагирует на сообщения, наделён унаследованными реакциями и управляет своим, вполне независимым, внутренним состоянием.
По мнению Алана Кея, которого считают одним из «отцов-основателей» ООП, объектно-ориентированный подход заключается в следующем наборе основных принципов (цитируется по книге Т. Бадда "Объектно-ориентированное программирование в действии = An Introduction to Object-Oriented Programming. — СПб.: «Питер», 1997"):
- Всё является объектом.
- Вычисления осуществляются путём обмена данными между объектами, при котором один объект требует, чтобы другой объект выполнил некоторое действие. Объекты взаимодействуют, посылая и получая сообщения.
- Сообщение — это запрос на выполнение действия, дополненный набором аргументов, которые могут понадобиться при выполнении действия.
- Каждый объект имеет независимую память, которая состоит из других объектов.
- Каждый объект является представителем класса, который выражает общие свойства объектов (таких, как целые числа или списки).
- В классе задаётся поведение (функциональность) объекта. Тем самым все объекты, которые являются экземплярами одного класса, могут выполнять одни и те же действия.
- Классы организованы в единую древовидную структуру с общим корнем, называемую иерархией наследования. Память и поведение, связанное с экземплярами определённого класса, автоматически доступны любому классу, расположенному ниже в иерархическом дереве.
В коде, написанном по парадигме ООП, выделяют 4 элемента и 3 принципа.
Элементы ООП:
- Класс - схема с описанием свойств и поведения объекта. В коде может быть много разных классов;
- Объект - экземпляр, созданный по схеме, описанной в конкретном классе. У одного класса может не быть объектов, может быть 1 или сколько угодно больше объектов;
- Атрибут или поле - первый тип элементов в схеме класса, определяющий свойства класса - различные типы данных (числа, строки, массивы и т.д.) и другие классы. Атрибут - это про данные;
- Метод - второй тип элементов в схеме класса, определяющий поведение класса - функции. Метод про то, что можно делать с данными. Например, важный метод - конструктор объекта по данному классу.
Принципы ООП:
- Инкапсуляция - механизм контроля (можно/нельзя) доступа к атрибутам и методам объекта;
- Наследование - механизм использования одним классом свойств и/или поведения другого класса;
- Полиморфизм - механизм, позволяющий объектам разных классов использовать один "полиморфный" метод. Целесообразность понятна только на практике, но вкратце - это удобно для больших проектов.
На этом можно заканчивать разбор об ООП.
1.2. ООП в Go
1.2.1. Элемент ООП - Класс
В Go нет понятия класса. Ближе всего к понятию класса в Go - это структура. И хотя в структуру можно добавить даже функцию, т.к. она тоже тип данных, она не будет методом в смысле ООП, а будет просто функцией:
Структура - это класс ООП, в который можно поместить только поля (атрибуты) с типами данных. Но нельзя поместить методы, определяющие поведение объекта.
1.2.2. Элемент ООП - Объект
Под объектом в ООП понимается всё, как сказал один из основоположников парадигмы. Что такое всё? Буква в имени переменной - это объект? А процессор? А вышеупомянутый кот?
Если абстрагироваться от теорий, и использовать больше конкретики, то экземпляр структуры - это объект в Go, т.к. у него есть как свойства (поля структуры), так и поведение ему можно создать (методами).
Структуры - агрегатный тип данных, который позволяет группировать другие типы данных в поля, в т.ч. другие структуры.
Технически, объектом может быть любой тип данных: от числа до функции.
1.2.3. Элемент ООП - Атрибут
В Go атрибут - это поле структуры.
1.2.4. Элемент ООП - Метод
В Go есть методы, но они формируются вне структуры, описывающей объект. В классическом ООП методы формируются внутри класса.
1.2.5. Принцип ООП - Инкапсуляция
В Go инкапсуляция реализуется на уровне пакета в виде доступности идентификаторов, созданных в одном пакете из другого пакета.
Идентификатор - это наименование для обозначения созданных типов данных - переменных и констант, структур и функций или методов и т.д.
Если идентификатор написан с большой буквы - идентификатор экспортируемый (публичный), т.е. доступный из другого пакета. Если с маленькой буквы - тип данных будет не экспортируемый (приватный).
Отдельно скажу о структурах. Допустим, структура экспортируемая - объявлена с большой буквы. А поля в ней не экспортируемые. Работать с полями из другого пакета не выйдет.
Или другой случай - структура неэкспортируемая, а поля в ней экспортируемые. Из другого пакета со структурой не выйдет работать, т.к. "первые двери закрыты" в виде наименования структуры.
Кстати, пакет - это основная единица организации кода из одного или нескольких файлов для группировки типов данных.
1.2.6. Принцип ООП - Наследование
Классического наследования из ООП в Go нет. Почему нет - это сложная тема, которую на мой взгляд можно прочувствовать, только если программировать на языке, в котором есть классическое наследование из ООП. Я же изучил теорию при сравнении классического наследования ООП и наследования в Go.
Итак, между объектами могут быть два типа отношений:
- Is-a (X is a Z, X является Z): это и есть наследование в классическом ООП - когда один класс наследует как методы, так и атрибуты другого класса. В Go такого нет: есть возможность наследовать отдельно атрибуты и отдельно методы, но не сразу и то, и то. Возможность наследовать отдельно методы - это полиморфизм, и он рассматривается в следующем параграфе. А возможность наследовать отдельно атрибуты - это следующий тип отношений.
- Has-a (X has a Z, X имеет Z): отношение между объектами, когда один класс включает в себя атрибуты другого класса. Отношение has-a делится на три вида - ассоциацию, агрегацию и композицию.
Агрегация, ассоциация и композиция различаются силой связи между объектами.
Ассоциация обеспечивает отсутствие прямой связи между объектами, агрегация обеспечивает слабую связь, композиция - сильную связь.
В Go реализуются все три типа has-a отношений, и это называется эмбендингом, т.е., встраиванием (структур).
Эмбединг (встраивание) - механизм внедрения одной структуры в другую структуру. Можно сказать, что есть три вида эмбендинга по разновидностям has-a: эмбендинг ассоциации, эмбендинг агрегации и эмбендинг композиции.
Ассоциация has-a подразумевает такое отношение между объектами, когда один объекты существуют независимо и между ними нет прямой связи. Классический пример - односвязный список:
В структуре Node хранится указатель на другой экземпляр структуры Node - это и есть ассоциация has-a. Может пример немного запутанный, из-за того, что в структуре мы храним ту же структуру, но здесь могла бы быть и другая структура:
Здесь поле Author структуры Book - ассоциация has-a.
Агрегация has-a подразумевает, что один объект содержит другой, но этот другой объект существует сам-по себе.
В строке 19 мы из экземпляра структуры Video обращаемся (присваиваем значение) через поле структуры Video "С" к полю структуры Content.
Структура Video включает структуру Content, но структура Content остаётся обособленным объектом, и доступ к нему возможен только через соответствующее поле структуры Video.
Композиция has-a подразумевает зависимость между объектами: оба объекта созависимы и не могут существовать один без другого. Достигается это через анонимное поле родительской структуры:
Обратите внимание, в строке 19 я обращаюсь к полю Link, как если бы оно было в структуре Video. Но поле Link находится в структуре Content, которая встроена в структуру Video.
Таким образом, мы можем обращаться к полям структуры Content, как если бы они были бы прописаны в структуре Video.
1.2.7. Принцип ООП - Полиморфизм
Напомню, согласно ООП, полиморфизм - механизм, позволяющий объектам разных классов использовать одни и те же методы.
В Go полиморфизм реализуется интерфейсами, хотя это не является первоочередной задачей интерфейсов.
Интерфейс — это контракт между типом данных и описанным в интерфейсе набором методов, который определяет ожидаемое поведение типа и позволяет разным типам работать взаимозаменяемо в контексте функций или структур, использующих этот интерфейс.
Определение мудрённое и применение интерфейсов непростое. Я хочу разобраться в теме как следует и подготовить отдельную публикацию на эту тему. Пока ограничимся определением и использованием интерфейсов для полиморфизма.
Go - статически типизированный язык: ничто не сможет замаскировать один тип данных под другой. Но несколько типов данных могут соответствовать одному интерфейсу. Интерфейсы можно вписывать в сигнатуру функции/метода в качестве параметров, и мы получим "полиморфный" метод.
На мой взгляд, пример полиморфизма в Go - встроенная функция len (встроенная, значит для её использования не требуется импортировать пакеты).
Функция len возвращает длину указанного в ней объекта:
Компьютеру не очевидно, что такое длина строки, длина массива, длина карты или длина для других типов данных. Однако функция len обработает разные типы благодаря тому, что разработчики языка позаботились о полиморфизме.
И хотя детали реализации функции len недоступны из самого языка, как и другие встроенные функции (вот это поворот!), можно быть уверенным, что она принимает в качестве аргумента пустой интерфейс, т.е. интерфейс без методов, который позволяет обрабатывать любой тип данных.
Мы в коде напрямую не вызываем разные методы для разных типов данных, а используем один метод, т.е. не пишем что-то вроде:
lenStr(str)
или
lenArr(arr)
или
lenPtr(ptr) и т.д.
Мы вызываем один полиморфный метод, который работает с разными типами данных и сам уже разбирается, какой дальше метод ему вызвать:
len(str) или len(arr) или len(ptr) и т.д.
Кстати, в коде я использовал термин "литерал".
Литерал - это фиксированное значение, указанное непосредственно в коде и не требующее вычислений.
Термин литерала похож на константу. Вспомним, что такое константа:
Константа - это именованное фиксированное значение.
Переменные и константы могут инициализироваться литералами, либо выражениями.
Возвращаемся к интерфейсам. Добавлю, что факт соответствия типа данных интерфейсу проверяется автоматически путём сравнения перечня методов, определённых в интерфейсе и методов имеющегося у типа данных(в т.ч. структуры, инта или другого типа данных).
На этом разбор ООП в Golang считаю завершённым.
1.3. Передача параметров
1.3.1. Теория
Передача параметров относится к работе с функциями или методами. Корректнее на мой взгляд, назвать передача аргументов или приём аргументов, и вот почему:
Параметры — это переменные, которые определяются в сигнатуре функции или метода и используются для получения значений. Например:
Вся строка 11 - это сигнатура функции. Параметры здесь link, name и dur. Когда мы объявляем функцию, мы определяем, какие параметры она принимает.
Аргументы - это значения, которые мы передаём в функцию при её вызове. На иллюстрации выше, name, "MyFile.txt", 15+25 - это аргументы функции. Аргументами могут быть переменные, константы, литералы, выражения, результаты других функций.
Здесь name - это переменная, "MyFile.txt" - строковый литерал, 15+25 - выражение.
Переменная — это именованная область памяти, которая может хранить значение определенного типа (int, string, массив, карта и т.д.).
Чтобы запомнить, где параметры, а где аргументы, можно использовать мнемотехнику:
- Аргументы - А - Астрал. При вызове функции, мы передаём значения в Астрал.
- Параметры - П - Приём. При описании функции, мы пишем переменные, которые Принимают значения.
1.3.2. Передача аргументов по-значению
Передача параметров в функцию происходит по-значению. Это означает, что вызывая функцию, мы копируем данные, которые хотим передать в неё. При этом изменения, выполненные с данными внутри функции, не отражаются на оригинальных данных.
Тут можно вспомнить, какой объём информации занимают разные типы данных, чтобы понимать, сколько байт информации мы передаём в функцию:
В нашу функцию ModifyValue мы передали int, на моей 64-битной ОС это 8 байт.
Для информации, 1 МБ - это не так много. Всего лишь массив из 125 тысяч элементов типа int64.
А теперь представим, что мы передаём в функцию не переменную типа инт, а структуру в несколько десятков полей, каждое по 8 байт. Это сколько нам памяти нужно?
Считаем, пусть структура из 20 элементов, где каждый элемент весит по 8 байт: 20 х 8 = 160 байт.
А если подобных запросов сотни тысяч в секунду? Речь идёт уже о десятках гигабайт данных, которые нужно постоянно копировать и удалять из памяти. Никакой ОЗУ не хватит.
Для этого можно передавать данные не копированием, а по-указателю.
1.3.3. Передача аргументов по-указателю
Указатель — это переменная, которая хранит адрес другой переменной. Эта переменная занимает столько же места, как int.
На самом деле, когда мы говорим "передача по-указателю", мы подразумеваем, что мы копируем не данные, а копируем указатель, т.е. адрес ячейки памяти, где хранятся другие данные. Это всё равно копия.
Рассмотрим код:
Здесь у нас структура с 15 полями, чтобы не копировать значение каждого поля, можно создать переменную с указателем на данную структуру, и отправлять её.
- Оператор & - произносится "амперсанд" - берёт адрес ячейки памяти, в которой хранится указанный после него объект; в данном случае, объект - это экземпляр структуры Movie.
- Оператор * - произносится "астериск" или просто "звёздочка" - разыменовывает указатель, т.е. позволяет получить доступ к данным, на которые ссылается указатель.
Такой способ передачи аргументов называется "передача по-указателю". Передача по-указателю подразумевает работу в функции с оригинальными значениями, а не копиями.
По указателю можно передавать не только структуры, но и обычные переменные. Но это не идиоматично языку Go.
Есть неявный момент при передаче аргументов для ссылочных типов данных: срезов, карт, каналов и собственно указателей.
1.3.4. Передача ссылочных типов данных
Мы помним, что по-умолчанию данные передаются по-значению. Т.е. происходит копирование данных.
Ссылочные типы данных позволяют функциям работать с оригиналами данных без их копирования, т.к. фактически передаются указатели без явного обозначения этого в коде:
Изменились оригинальные данные.
Тут главное что понимать, что ссылочные типы данных - это структуры, по крайней мере одним полем которых является указатель на данные. Вот при передаче в качестве аргумента за счёт поля с указателем на данные, мы хотя и копируем данные, но копируем указатель. Это равносильно, как если бы мы передавали по-указателю. Передача по-указателю вообще и подразумевает передачу копии указателя.
Давайте порассуждаем логически и решим, когда стоит использовать указатели, а когда не стоит:
- Не стоит использовать указатели для переменной, когда её объём в байтах передаваемых данных равен или меньше объёма указателя. Т.е. не нужно передавать указатель на тип int, bool или float лучше передать саму переменную;
- Не стоит использовать указатели для передачи срезов, карт и каналов, т.к. это не идиоматично в Go, хотя разработчики и дают такую возможность. Идиоматично возвращать новый слайс/карту из функции, а не менять оригиналы слайса/карты внутри функции.
- Обязательно стоит использовать указатели при передаче данных, которые гарантированно должны быть изменены. Например это мьютексы (sync.Mutex) или вэйтгруппы (sync.WaitGroup).
1.3.6. Передача переменного количества аргументов
Одна и та же функция может принимать различное количество параметров за счёт оператора ... (многоточие). В этом случае она будет работать с параметром, как со срезом:
Это работает не только с типом данных int, но и другими типами данных.
Подытоживая тему передачи аргументов, хочу сказать, куда копируются данные. Т.е. мы выяснили, что при передаче аргументов, данные копируются. А копируются они в стек горутины.
Про горутины и стек будет рассмотрено далее, сейчас достаточно понимать, что у каждой горутины свой стек, который создаётся вместе с горутиной, и живёт стек столько же, сколько живёт горутина.
1.4. Типы данных
Есть четыре группы типов данных:
- Базовые типы данных: числа, строки, булевы и др.;
- Агрегатные типы данных: массивы и структуры;
- Ссылочные типы данных: срезы, карты, каналы, указатели;
- Прочие типы данных: интерфейсы, функции и методы.
Особенность ссылочных типов данных, что при нулевом значении они равняются nil.
Помимо указанных типов данных ещё есть алиасы. Начнём разбор с них, т.к. остальные более менее понятны.
1.4.1. Алиасы
Алиасы - это альтернативные имена для типов данных и пакетов.
Рассмотрим пример:
Использование собственных алиасов переменных в чужом коде я пока не встречал, но такой функционал есть.
В Go есть три встроенных алиаса:
- rune - алиас на int32;
- byte - алиас на uint8;
- any - алиас на interface{}.
Пакеты не относятся к типам данных, но раз мы о них сказали, разберём алиас и пакетов. Алиас для пакетов используется в двух случаях:
- Если имя пакета длинное, для удобства его сокращают алиасом;
- Если имя пакета не совпадает с конечным элементом пути при импорте пакета. Другими словами, если мы назвали каталог одним именем, а пакет в файле этого каталога называется по-другому. Нужен алиас.
Смотрим пример:
Здесь мы видим код функции main, структуру каталога и содержимое трёх разных пакетов. Кстати, имена, используемые для хранения кода пакетов роли не играют, их можно назвать как удобно, и даже одинаковыми (для разных каталогов, конечно).
Идея в чём:
- Когда наименование пакета совпадает с наименованием каталога, где хранится пакет - то алиас пакета не требуется.
- Когда наименование пакета не совпадает с наименованием каталога, где хранится пакет - то алиас пакета обязателен.
- Когда наименование пакета совпадает с наименованием каталога, где хранится пакет, но при этом наименование пакета длинное - можно применить алиас, хотя и не обязательно.
С алиасами всё.
1.4.2. Базовые типы данных
Здесь может быть много нудной теории. Я постараюсь выбрать главное, т.к. базовые типы данных - это то, с чего начинается изучение языка и мы всё это знаем, но можем забыть некоторые специфичные вещи.
Итак, вот базовые типы данных:
- int - целочисленный тип данных, размеры зависят от платформы: 32 или 64 бита. Для 64-битной архитектуры компьютера и ОС тип int будет аналогичен типу int64, а для 32-битной архитектуры компьютера и ОС, тип будет аналогичен типу int32.
- float32 / float64 - численные типы с плавающей запятой: пример, 3.14.
- bool - логический тип, может быть true или false.
- string - строковый тип, представляет из себя срез байтов.
- byte - используется для представления одного байта в диапазон 0-255.
- rune - используется для представления одного символа в виде кодовой точки по стандарту Unicode.
Самое неоднозначное здесь - это три последних типа: string, byte и rune.
1.4.3. Строки, руны и байты
Строка - неизменяемый тип данных для хранения текста в виде последовательности байтов, где каждый символ закодирован по стандарту UTF-8.
Итерируясь в цикле по строке, мы получим итерацию по байтам.
Итерируясь в цикле по строке, используя ключевое слово range, мы итерируемся по символам строки, т.е., итерируемся по рунам.
Кодировка UTF-8 подразумевает, что каждый символ Unicode может быть закодирован 1, 2, 3 или 4 байтами, в зависимости от того, насколько, скажем так, символ сложный.
Деление следующее:
- 1 байт - ключевые знаки (точка, запятая, равно и т.д.) и английские буквы;
- 2 байта - буквы других алфавитов, например - русского алфавита.
- 3 байта - более сложные знаки, например - иероглифы;
- 4 байта - новомодные символы, например - смайлики.
Строку можно легко преобразовать в срез байт или срез рун:
Ну здесь уже пошли в выводе представления строк в байтах и рунах.
- Кодовые точки Unicode: в первой строке вывода в терминал, где цифры 82 1051 19990 128512, - это кодовые точки символов стандарта Unicode. Каждый символ - это своя кодовая точка.
- Кодировка UTF-8: во второй строке вывода в терминал мы имеем закодированные символы. Первый простой символ кодируется 1 байтом (значение - 82), другой символ кодируется 2 байтами (208 и 155), третий символ ещё сложнее - кодируется 3 байтами (228, 184 и 150) и четвёртый символ кодируется 4 байтами (240 159 152 128). Тут важно понимать, что 4й символ кодируется 4-мя байтами, не потому что он по порядку 4-й, а потому что он принадлежит к классу сложных символов, которые стандарт UTF-8 представляет в 4-х байтах.
По объёму использования памяти, представление текста в виде среза байтов выгоднее, чем в виде среза рун:
- Представление символа в виде руны занимает 4 байта;
- Представление символа в виде байтов занимает от 1 до 4 байт.
Можно задать вопрос - как компьютер понимает, где в срезе байт заканчивается один символ и начинается другой? Дело в том, что байт/байты символов в UTF-8 имеют определённый шаблон в бинарном виде:
- 0xxxxxxx - шаблон для 1-байтовых символов, где x принимает значения 0 или 1;
- 110xxxxx - шаблон для первого байта 2-байтового символа;
- 1110xxxx - шаблон первого байта 3-х байтового символа;
- 11110xxx - шаблон первого байта 4-х байтового символа;
- 10xxxxxx - шаблон для продолжающихся байтов символов, состоящих из 2, 3 или 4 байтов.
Байты символов по стандарту UTF-8 имеют шаблон, по которому Go обрабатывает срез встроенными библиотеками и понимает, где один символ, а где - другой.
Срез байтов и срез рун легко конвертируется в строку через встроенную функцию string:
Есть эффективные и неэффективные операции со строками. Эффективность оценивается по расходу памяти и производительности.
Эффективные операции используют, когда ожидается обработка большого количества строк. Эффективные операции требуют больше кода, либо это специфичные задачи. Примеры:
- bufio.NewScanner()
- str[7:12]
- strings.Builder
- bytes.Buffer
- strings.Join (менее эффективен чем Builder и Buffer).
Неэффективные операции используют, когда нужно обработать малое количество строк. Их используют для ускорения разработки, т.к. нецелесообразно писать много кода для эффективной обработки большого количества строк - кода будет много, разобраться сложнее, а пользы по эффективности сервис не ощутит. Примеры:
- fmt.Scan()
- str1 + str2
- fmt.Sprintf()
На этом обсуждение строк можно завершить.
1.4.4. Структуры
Структуры - агрегатный тип данных, который позволяет группировать другие типы данных под одним наименованием.
Для чего могут использоваться пустые структуры?
Пустая структура - структура без полей. Она занимает 0 байт в памяти. Если создать срез из 100 тысяч пустых структур, то его размер будет очень маленьким, т.к. в памяти будет храниться только размер среза - 100 тыс.
Используется в качестве:
- Значений в карте, когда мы используем карту для подсчёта уникальных элементов;
- Если нам нужно создать метод, то нам обязательно нужна структура: хотя бы пустая. Такая потребность в методе может возникнуть, если мы хотим использовать интерфейсы (см. идеальное тестовое задание junior go-разработчика);
- Сигнал в каналах для уведомления о наличии какого-либо события. Пустая структура будет только увеличивать счётчик в канале, но не выделять память, копировать элементы и так далее. Иногда для этой цели используют булевы, но это менее рациональный способ.
Ещё могут быть анонимные структуры - когда мы создаём экземпляр структуры без объявления типа структуры через ключевое слово type:
Для чего могут быть полезны анонимные структуры? Например, для тест-кейсов в тестировании, хотя вполне можно использовать структуры, объявленные через ключевое слово type.
1.4.5. Массивы
Массив - агрегатный тип данных в виде непрерывной последовательности данных одного типа фиксированного размера.
Не буду разжёвывать про длину, индексы, способы инициализации, как итерироваться по массиву и т.д.
Скажу вот что:
- Для указания длины массива можно использовать целочисленный тип данных в виде константы или литерала. Также можно использовать оператор многоточие ... - но многоточие не идиоматичный для Go способ указать длину массива.
- При обращении к элементу массива по индексу используется целочисленный тип - всё равно какой, константа, переменная, литерал или выражение.
Алгоритмическая сложность операций на массиве:
- Получить значение или изменить элемент массива (по индексу): константное время - O(1).
Ну и пример работы с массивом:
Как мы помним, при передаче аргумента, происходит копирование значений. В функции changeArr создана копия исходного массива, и доступна она только внутри этой функции. Когда управление возвращается функции main, у массива исходные значения.
1.4.6. Срезы
Срез (или англицизмом - слайс) - это структура данных для хранения элементов одного типа в последовательности изменяемого размера.
Срез так назван, потому что позволяет получить доступ к базовому массива, путём "вырезания" из него фрагмента непрерывной последовательности элементов.
Компилятор Go не показывает устройство среза, но реализация его выглядит примерно так:
uintptr - это не что иное, как обычное число.
В общем, структура слайса - это три поля:
- указатель на первый элемент базового массива;
- длина доступной последовательности базового массива;
- длина базового массива, т.е. количество элементов, которые можно хранить в базовом массиве без нового выделения памяти.
Алгоритмическая сложность работы со срезами следующая:
- Добавление элемента - амортизированное О(1), амортизация за счёт периодического перевыделения памяти под новый базовый массив;
- Обращение к элементу по индексу - O(1);
- Удаление элемента - O(n).
Что ещё можно сказать о слайсах. Есть много видов алгоритмических задач, решаемых на слайсах. Главное здесь освоить три подхода, но они непросты. Возможно позднее, создам публикацию о них (и заодно освою подходы):
- Префиксные и суффиксные суммы;
- Метод двух указателей;
- Скользящее окно.
А теперь к более простым задачам на слайсы:
Задача со слайсами №1. Копирование слайса
Создадим два слайса на основе оригинального слайса slice1. Кстати, обратите внимание, что оригинальный слайс я создаю без функции make, а используя композитный литерал:
Что здесь интересного. Слайс slice2 и slice1 имеют один и тот же базовый массив, и при изменении данных в любом из них, меняются оба слайса.
Когда мы хотим, чтобы данные имели разные исходные данные и менялись независимо, нужно использовать встроенную функцию copy.
Теперь посмотрим, как удалить элемент из слайса.
Задача со слайсом №2. Добавление элемента
Рассмотрим код:
При создании переменной slice1 по сути мы создали слайс с длиной 5 и ёмкостью 5.
Когда добавили новый элемент, то произошло перевыделение памяти, т.к. был создан новый базовый массив и его длина (ёмкость слайса) увеличилась вдвое.
В действительности существует сложное условие, какой объём выделить слайсу, и это условие может меняться от версии к версии. Суть том, что для малых ёмкостей размер слайса увеличивается в два раза, а далее будет учитываться коэффициент для плавного увеличения ёмкости. Табличка ниже соответствует Go версии 1.20 согласно ответу на stackoverflow:
Взгляните на этот код для версии языка 1.23.1:
Этот пример показывает, что увеличение ёмкости слайса в два раза при перевыделении памяти останавливается на числе 48. А дальше идёт более чем в 2 раза.
А вот для больших значений, увеличение ёмкости слайса имеет коэффициент меньше 2х:
Итак, в исходном коде Go есть перечень условий, определяющих, как увеличивать ёмкость слайса. Причём, если копнуть дальше, то выяснится, что зависимость от следующих условий:
- Текущей длины и ёмкости среза;
- Сколько элементов нужно добавить;
- Тип элементов;
- Архитектуры компьютера/ОС, т.к. она влияет на размер указателя;
- Версии Go, т.к. механизм реализации меняется.
Но базово, при малых количествах элементов, происходит удвоение ёмкости при перевыделении памяти под новый базовый массив.
Задача со слайсом №3. Изменение данных
Рассмотрим код, в котором мы используем оператор среза [:] для копирования данных. В этом случае у них будет общий базовый массив:
В операторе среза [:] мы задаём индексы для создания нового среза - но возникают вопросы, включительно или исключительно работаем с индексами?
Проще запомнить, к каким индексам обращается оператор среза [:] можно так:
- Первое число в операторе среза [X:], Х - с этого индекса включительно будет создан новый срез.
- Второе число в операторе среза [:Y], Y - до этого индекса исключительно будет создан новый срез.
Но этого может быть недостаточно. Ещё такие разъяснения:
Что такое "до" - включительно или нет? Пойдём от противного. В русском языке принято использовать предлог "по", если имеем ввиду включительно, например "приём работ с понедельника по пятницу", значит в пятницу ещё можно сдать работу. А если "приём работ с понедельника до пятницы", значит, скорее всего в пятницу уже нельзя сдать работу. Ну или если явно скажут "приём работы с Пн до Пт включительно" - вот тогда понятно, что Пт включается.
Так и с оператором среза ["с этого индекса включительно" : "до этого индекса исключительно"].
Теперь доработаем код.
Через оператор среза создадим слайс и изменим в нём значение. Что будет со значением в исходном слайсе?
В исходном слайсе значение изменится в след за изменением во 2-м слайсе, т.к. они оба ссылаются на один базовый массив.
Раскрутим пример:
Здесь уже исходный слайс не изменился, т.к. при аппенде произошло перевыделение памяти и для переменной slice2 был создан новый массив.
Доработаем пример ещё:
Создали слайс нулевой ёмкости, добавили в него несколько элементов и взяли оператором среза два новых слайса:
Здесь можно запутаться с перевыделением памяти под новый массив.
Задача со слайсом №4. Изменение в функции
Создадим слайс, передадим его в функцию и изменим в ней элемент. Изменится ли оригинальный слайс?
Изменения сохранились, т.к. слайс - ссылочный тип данных. И при копировании данных во время передачи аргументов в функцию changeSlice, мы скопировали не значения элементов слайса, а скопировали указатель, в котором хранится адрес ячейки памяти на первый элемент базового массива.
Изменим код:
Теперь мы эппендим слайс, у которого ёмкость равна длине.
Изменения в исходном слайсе не произошли, т.к. в функции changeSlice произошло перевыделение памяти и был создан новый базовый массив. Но он существовал только внутри функции changeSlice, и при передаче управления в функцию main, этот новый базовый массив был удалён.
Ещё доработаем код:
Мы создали слайс с длиной меньше, чем ёмкость. Затем изменили его в функции. Что имеем?
В функции changeSlice длина слайса увеличилась, но поскольку ёмкость слайса позволяла, перевыделения памяти не происходила и базовый массив остался прежним.
Когда мы вернули управление функции main, у нашего исходного слайса длина осталась прежней - 3. Но базовый массив изменился, это продемонстрировано когда мы взяли оператором среза новый слайс и посмотрели, что в нём хранится.
Задача со слайсом №5. Изменение в функции с возвращением
Создали слайс с равной длиной и ёмкостью и передаём его в функцию с возвращением результата.
Идея в чём - из функции мы вернули новый слайс, и у него свой базовый массив. Поэтому все изменения сохранились.
Этот пример идиоматичен для Go и он аналогичен следующему примеру. Но следующий пример не идиоматичен для Go, и его применять на практики не следует:
Здесь мы передавали слайс по-указателю, и хотя не возвращали данные из функции и при этом произошло перевыделение памяти в функции changeSlice, оригинальный слайс также изменился.
Задача со слайсом №6. Удаление элемента
В Go нет функции для удаления элемента из слайса. Вместо этого есть два подхода для реализации задачи:
- Сдвинуть все элементы после удаляемого элемента влево;
- Создать новый срез.
Код сдвига элементов с использованием копирования слайсов:
Для удаления элемента путём создания нового среза, нужно использовать комбинацию append и оператор среза [:], см. код ниже:
Вот два подхода к удалению элемента.
В общем, со слайсами думаю всё. Повторю - даже если смотрите код, и всё понятно, нужно натренировать навык, т.к. на собеседовании и так волнительно, там не то что работу со слайсами, там руну с байтом можно перепутать.
1.4.7. Карты
Карта (или мапа) - это структура данных на основе хеш-таблиц для хранения пар "ключ-значение", где каждый ключ уникален.
Можно сделать три (четыре) операции с картами: 1 - вставить элемент по ключу, 2 - удалить элемент по ключу, 3(4) - получить значение по ключу (получить значение по ключу и плюс некий флаг - есть такой ключ в карте, или нет).
Алгоритмическое время работы с картой на любые операции - амортизированное O(1). Амортизация из-за коллизий и эвакуцации.
Хеш-таблица - это структура данных, которая обеспечивает ассоциативный доступ к данным на основе бакетов и хеш-функции.
Ассоциативный доступ - это доступ к данным, где значения хранятся по уникальному ключу.
Альтернативами ассоциативного доступа, например, являются доступ по индексу (как в массивах/срезах), доступ по условию (например, при sql-запросах), линейный доступ - просто, последовательным перебором (например, в связанных списках), доступ по-итерации (похож н линейный доступ, но условия итерации могут быть сложнее, чем просто i++).
Хеш - это псевдоуникальное значение, полученное в результате хеш-функции. Это значение фиксировано для входящих данных, и обычно представлено в виде целого числа.
Хеш-функция - функция, которая преобразует входящие данные (ключ) в псевдоуникальное значение - хеш. Чем лучше хеш-функция, тем выше уникальность хешей. Но уникальность не гарантируется.
Хеширование, в отличие от кодирования, необратимый процесс: невозможно по хешу получить исходное значение. Хеш функций довольно много, например есть в строенные в Go: sha1, sha256 и т.д. - из пакета crypto.
Требования к хеш-функции:
- Равномерность - хеши должны группироваться по разным бакетам (определение см. ниже);
- Быстрота - хеши должны вычисляться быстро, т.к. если иначе - то нет смысла хранить данные в карте, проще хранить в срезе;
- Детерминированность - для одного и того же ключа должен получаться один и тот же хеш (и один и тот же номер бакета);
- Криптоустойчивость - хеш-функция не должна позволять возможности подбора ключей таким образом, чтобы все данные попадали в один бакет.
После вычислении хеша, определяется номер бакета, куда эти данные поместить, а именно - определяются младшие биты хеша - LOB - Lower Order Bits.
В структуре "карта" много полей, в одном из полей находится указатель на первый элемент массива с бакетами (поле buckets):
type hmap struct {
count int
flags uint8
B uint8
noverflow uint16
hash0 uint32
buckets unsafe.Pointer // array Buckets
oldbuckets unsafe.Pointer
nevacuate uintptr
extra *mapextra
}
Бакет - структура данных для хранения пар ключ-значение в виде указателя на область памяти, в которой хранятся три элемента:
- Массив из 8 элементов, где каждый элемент - старший бит хеша ключа, помещённого в бакет;
- В следующей ячейке памяти после массива лежит связанный список с информацией о ключах и значениях;
- Указатель на следующий бакет после связанного списка, если текущий бакет переполнен.
Причём в структуре бакета явным образом объявлен только массив старших битов хешей ключей:
type bmap struct {
tophash [abi.MapBucketCount]uint8
}
Номер бакета по сути - это индекс в массиве бакетов - он же младший бит хеша, т.е., LOB. Вычисляется младший бит хеша следующим образом:
- Вычисляется хеш ключа;
- Заранее известно количество бакетов в карте;
- Выполняется выражение: остаток от деления хеша на количество бакетов в карте, выглядит это примерно так:
idxMap = hash(key) % lengthBucketsInMap, например 325 % 4=1,
где hash(key) - это хеш ключа (в примере - 325);
lengthBucketsInMap - общее количество бакетов в карте (в примере - 4).
Индекс бакета в примере равен 1.
Единственно, для быстродействия, вычисления производятся не над десятичными числами, а над двоичными. А полученный результат - младшие биты хеша.
Количество необходимых младших бит ключа зависит от количества бакетов. Для 4х бакетов нужно 2 младших бита, для 8 бакетов 3 младших битов и т.д. по мере удвоения количества бакетов +1 бит хеша нужен.
Зачем в бакете массив из старших битов хешей ключей? Пройтись по элементам односвязного списка, чтобы найти ключ - считается дорогой операцией, т.к. элементы односвязного списка хранятся разрозненно в памяти, а не последовательно.
Чтобы сократить время для поиска ключа, используются старшие биты хешей - HOB (Higher Order Bits), которые попали в этот бакет. Пройтись по этому массиву можно очень быстро, чтобы понять - стоит вообще искать в этом бакете нужный ключ, или смысла нет.
Таким образом, поиск ключа в карте проходит по следующим процедурам:
- Получаем хеш ключа;
- Вычисляется младшие и старшие биты ключа;
- Заходим в бакет с номером по младшему биту ключа;
- В бакете проходимся по старшим битам, сохранённых в бакете хешей. Если находим соответстви, ищем в односвязном списке бакета. Если не нашли - возвращаем базовое значение типа данных значения карты.
- Если нашли совпадение по старшим битам, возможно в бакете есть ключ. Далее проходимся по односвязному списку и сопоставляем значение искомого ключа со значениями сохранённых ключей. И тут либо находим, либо нет.
Итак, бакет - это структура со старшими битами сохранённых хешей и со связанным список данных. Бакет рассчитан на хранение данных для 8 ключей. Причём, в связанном списке 16 узлов: первые восемь узлов связанного списка зарезервированы под ключи карты, следующие восемь узлов зарезервированы под значения ключей. Подход с хранением сперва 8 ключей и затем 8 значений, а не на хранения подряд по 1 ключу и 1 значению связан с выравниванием типов.
Может возникнуть вопрос - а зачем нам в бакете хранить данные больше чем для одного ключа?
Когда хеш-функция выдаёт одинаковый хеш для разных данных, это называется коллизией. Но конкретно применительно к картам, коллизией у нас будет не просто одинаковые хеши по ключу, вероятность которых крайне мала. Коллизия применительно к картам в Go - это присвоение разным ключам одинаковых младших битов хеша (по сути, одного номера бакета). И это ситуация регулярная.
Для разрешения коллизий, используется два метода:
- Метод цепочек - когда мы помещаем данные с одним хешем в связанный список.
- Метод открытой адресации - когда мы кладём данные с одинаковым хешем в следующий свободный бакет; за счёт, например, повторного хеширования ключа или других способов.
В Go можно сказать, используется оба метода. Основной - метод цепочек - тот самый связанный список. При поиске значения по ключу в карте, в бакете сравниваются именно значения ключей (существующего и искомого), а не хеши ключей. А вот чтобы найти бакет - нужен хеш, точнее остаток от деления хеша на количество бакетов.
Если исчерпан метод цепочек для разрешения коллизий - используется метод открытой адресации, когда в конец односвязного списка кладётся адрес на следующий бакет с таким же номером (с такими же младшими битами хеша).
Напоминаю, размер бакета фиксирован - 8 пар ключ-значение, за счёт этого мы достигаем ~константной алгоритмической скорости выполнения операций. Если в бакет заполнен, но в него должен попасть ещё один элемент, то в бакет сохраняется ссылка на следующий бакет. Если так пойдёт и дальше, то мы получим линейную сложность: т.е. простой обход. Нужно перераспределение данных.
За счёт увеличения количества бакетов в карте, у нас среднее количество заполненности бакетов уменьшится (вспомним пример формулы 325 % 4=1).
При этом одни бакеты в карте могут быть ещё свободными, другие переполненными (будут ссылаться на другие бакеты). Как разобраться, когда нужно увеличение количества бакетов в карте?
Разработчики определили эмпирический коэффициент и решили, что когда в среднем в карте ёмкость бакета достигнет 6,5/8, будет удвоено количество бакетов в карте. Этот коэффициент назвали load factor. Когда load factor достигает 6,5 (т.е. коэффициент заполненности бакетов равен 6,5/8=81,25%), то начинается эвакуация данных.
Эвакуация данных - процесс создания нового списка бакетов, который будет в 2 раза больше предыдущего списка, и данные из старых бакетов будут перераспределены в новые бакеты.
Это не быстрый процесс. При эвакуации, операции переноса из одного бакета в другой происходят при обращению к элементу карты по ключу - получению значения или изменению; ну и сохранение значения нового ключа, естественно.
В процессе эвакуации, при запросе к данным, происходит поиск и в старых бакетах, и в новых. Это увеличивает время запроса.
Есть способ выделять память под нужное количество элементов в карте заранее. Если мы знаем, что у нас в карте будет не больше 100 тыс записей, то мы говорим компилятору, чтобы он зарезервировал нам соответствующее место:
Синтаксис как у слайсов. В таком случае эвакуцации данных происходить не будет, и наш код будет быстрее.
Теперь немного практики
Карту можно объявить (создать переменную) и проинициализировать (присвоить переменной значение), используя композитный литерал:
Хотя привычнее, через функцию make:
Нет встроенной функции копирования карты, как со слайсами. Если нам это понадобится - нужно итерироваться через ключевое слово range по карте и каждый раз добавлять элемент в новую карту.
Типы данных для ключей карты:
В Go для ключей могут использоваться не все типы данных. Проще перечислить те, что нельзя использовать как ключи:
- Массивы;
- Срезы;
- Каналы;
- Функции/методы;
- Карты;
- Структуры, если там есть поля с указанными выше типами.
Эти типы конструктивно не подходят для создания и работы с хешами в карте, т.к. в процессе работы ссылки на эти данные могут давать разные хеши.
Ключами полезнее выбирать тип int или string. Ключом не следует выбирать тип float, т.к.:
- Тип float может иметь проблемы с округлением;
- Более долгая хеш-функция, чем с int.
Некоторые возможные вопросы по картам
- Зачем аллоцировать память под карту? - чтобы увеличить скорость работы с картой в процессе её заполнения за счёт избегания эвакуации.
- Почему порядок обхода элементов карты случайный? - порядок следования по списку бакетов зависит от многих факторов, плюс возможна эвакуация, когда итератор ходит и по-старым и по-новым бакетам. Создатели языка сами конструктивно вписали в код, что обход по списку бакетов происходит со случайного значения.
- Почему нельзя взять указатель на элемент карты? - если мы берём ссылку на какой-то бакет, то в какой-то момент может произойти эвакуация данных и ссылка на бакет не будет актуальной, т.к. данные будут лежать в новом бакете по новому адресу в памяти.
На этом разбор темы по картам считаю законченным. Хотя карта - это на мой взгляд очень сложный тип данных, и там можно разбираться намного лучше, но это - хорошая база чтобы понять как что устроено.
1.4.9. Интерфейсы
Интерфейс — это контракт между экземпляром типа данных и описанным в интерфейсе набором методов.
Интерфейс реализуется в виде структуры, посмотреть её элементы можно только в исходном коде:
type iface struct {
tab *itab
data unsafe.Pointer
}
Поле tab - указатель на структуру с множественным встраиванием структур. Ключевое здесь, что одним из полей входящих в tab структур является структура Methods со срезом методов, которые определены в интерфейсе:
type InterfaceType struct {
Type
PkgPath Name // import path
Methods []Imethod // sorted by hash
}
C применением интерфейсов у меня сложности. Могу выделить несколько моментов:
- Хранение значений различных типов, т.к. любой экземпляр типа данных подходит под контракт пустого интерфейса. Т.е. для interface{} подходит любая переменная: i:=55, slice := []int{1,2,3} и т.д.
- Снижение зависимостей при импорте пакетов - мы можем обеспечить общение между пакетами через интерфейсы и структуры, без прямого импорта пакетов. В т.ч. для исполнения разных задач разными разработчиками при командной работе это должно быть удобно.
- При тестировании замена сложных процедур (например, поход в облако или в облако) на заглушку в виде интерфейса.
Более подробно интерфейсы можно понять только на практике.
1.4.10. Функции
Функция - это тип данных для создания данных и выполнения действий над данными.
Когда мы вызываем функцию, мы помещаем в стек горутины новый фрейм (о горутинах и стеке позднее).
Фрейм - это структура данных для хранения информации о текущем вызове функции в стеке. Фрейм содержит:
- Адрес возврата управления - т.е. ячейки памяти, в которой находится инструкция на исполнение следующей части кода в вызывающей функции;
- Параметры, переданные в функцию (если что-то передаётся);
- Другие вещи, например информация о структуре (для метода).
Когда вызванная функция завершает код, управление возвращается в вызывающую функцию, а фрейм вызванной функции удаляется из стека.
Нет связи между данными в разных фреймах одного стека, кроме адреса возврата. Поэтому нельзя обратиться к данным из одной функции, которые созданы в другой функции, хотя стек для них один.
Функции могут быть именованными и анонимными. Анонимные функции часто используются для маленьких горутин, при сортировке или при обработке ошибки в связке с defer.
Посмотрим на тип данных анонимной функции, которую присвоили переменной:
А теперь не анонимной функции:
Как видим, тип функции - это её сигнатура без наименования аргументов/параметров.
Главная функция Go, она же "точка входа" - функция main. Main - это главная горутина, т.е. выполнение программы начинается с неё. О горутинах в следующих параграфах.
Функции могут принимать сколько угодно параметров и возвращать сколько угодно результатов. На практике не принято возвращать больше 4х результатов, и если это требуется - нужно пересмотреть подход: разделить функции на более мелкие или возвращать структуру.
Возвращаемые значения могут быть именованными и неименованными. Именованными обычно делают ошибки для обработки в defer. Подробнее о defer в конце публикации. Далее примеры использования анонимных функций:
Пример анонимной функции №1: Сортировка
Базовая сортировка слайса с простым синтаксисом происходит по-возрастанию. А если по-убыванию, нужно использовать доп функционал, который удобно реализовать анонимной функцией:
При этом таким же подходом можно пользоваться, когда у нас есть срез структур, и одно из полей структуры - число. Мы аналогичным синтаксисом можем отсортировать срез структур по этому полю хоть по-возрастанию, хоть по-убыванию. Как пример, что этот синтаксис используется не только для сортировки срезов.
Пример анонимной функции №2: Обработка ошибок в defer
Часто мы закрываем через defer что-то через метод Close() типа файлов, тело запроса, соединения с БД и т.д.:
defer file.Close()
или что-то подобное. В действительности, метод Close возвращает ошибку. И будет полезно хотя бы неявно обозначить в коде, что здесь возможна ошибка - а для этого подходит анонимная функция:
Думаю, по функциям вопросов не должно быть много, поэтому переходим к следующей теме.
1.5. Конкурентность
Прежде, чем перейти к разбору конкурентности, нужно разобраться с выполнением кода на компьютере. Есть два уровня выполнения: уровень модели (код) и уровень архитектуры (компьютер).
1.5.1. Архитектура и модель выполнения кода
Архитектура выполнения - это способ, которым задачи исполняются в центральном процессоре (ЦП). Архитектура выполнения определяется возможностями ЦП и обеспечивается операционной системой (ОС).
Архитектура выполнения бывает однопоточной и многопоточной:
- Однопоточная - когда все инструкции выполняются на одном ядре ЦП.
- Многопоточная - когда инструкции могут одновременно выполняться на разных ядрах ЦП.
Однопоточная и многопоточная архитектура имеет одинаковые подвиды:
- Кооперативная архитектура - задачи сами решают, когда освободить ресурс (ЦП) для другой задачи. Пример - старые ОС, например Windows 3.1: если одно приложение зависало, вся ОС зависала.
- Вытесняющая архитектура - ОС сама решает, когда убрать задачу из ресурса (ЦП) и дать ресурс другой задаче. Удобно, например, когда одна задача зависает, ОС постарается снять эту задачу с выполнения. Это более продвинутая архитектура.
- Реактивная архитектура - выполнение задач инициируется триггерами, например - кнопкой на пульте или фактом получения сетевых данных.
На самом деле, архитектура выполнения закладывается не только в ОС, как мы увидим далее, но и в Go есть своя под-архитектура выполнения.
Однопоточность/многопоточность имеет отношение к среде исполнения, например, к ОС
Модель выполнения - это концепция, определяющая, как должны выполняться инструкции, описывается в коде и реализуется средствами языка программирования.
Модели выполнения делятся на:
- Синхронное выполнение - когда мы пишем процедурный код, в котором инструкции выполняется одна за другой. Такой подход хорош для ПО, где есть сложные математические расчёты без пауз в ходе исполнения программы - например, научные вычисления, моделирование физических процессов, прогнозов и т.д.
- Асинхронное выполнение (многозадачность) - когда мы пишем код, в котором инструкции должны выполняться одновременно или почти одновременно. Такой подход хорош, когда предполагаются паузы в ходе исполнения кода, а также есть потребность одновременного исполнения более одной инструкции. Паузы постоянно возникают в in-out-bound задачах, например получении данных из сети. Асинхронное выполнение бывает трёх типов: 1 - конкурентное, 2 - параллельное и 3 - параллельно-конкурентное.
Конкурентная многозадачность - две и более инструкции могут начаться, выполняться и завершиться в перекрывающиеся периоды времени на одном ядре ЦП. Используется на одноядерных ЦП, либо если программе доступно одно ядро ЦП.
Параллельная многозадачность - две и более инструкции могут быть запущены и завершены одновременно (на разных ядрах ЦП).
Параллельно-конкурентная многозадачность подразумевает комбинацию конкурентной и параллельной многозадачности, когда есть несколько доступных ядер ЦП и на этих ядрах код выполняется конкурентно.
Примерно с 2000-го года, когда стали появляться массовые двухъядерные ЦП, параллельно-конкурентная модель вычисления постепенно начинала преобладать в сфере веб-технологий, где имеются задержки при обработке сетевых запросов и может быть одновременно множество запросов. А также в ПО для мониторинга и оповещения, компьютерных и мобильных приложениях, играх и т.д.
Синхронная модель эффективна для научного и производственного ПО, аналитических систем, обработки аудио/видео - в общем, в задачах с длительным алгоритмом работы без пауз.
Теперь, когда мы познакомились с моделями и архитектурой выполнения кода, перейдём к конкурентности.
1.5.2. Конкурентность в ОС
Независимо от того, какая модель вычисления использовалась внутри кода, в ОС программы будет выполняться конкурентно.
Конкурентность в ОС - это механизм эффективного использования процессорного времени за счёт смены активных потоков.
Процессорное время - это время, в течение которого ядро центрального процессора выполняет вычисления.
Процесс - это созданный ОС экземпляр программы, в котором хранится код запущенной программы, имеется выделенная ОС память, необходимая во время реализации программы и другие ресурсы.
В процессе имеется один или несколько потоков.
Поток - последовательное исполнение кода, хранимого в процессе. На одном ядре ЦП одновременно может исполняться один поток.
На мобильных телефонах или массовых персональных компьютерах ~до 2008 г. часто был один процессор. Но мы могли одновременно слушать музыку, играть в игру, а фоном что-то скачивалось из интернета. Т.е. было много запущенных процессов (и потоков), которые выполнялись не одновременно, т.к. ядро одно. ОС очень быстро переключала разные потоки на ЦП, поэтому создавалась иллюзия одновременной (параллельной) работы нескольких программ.
Конкурентность на уровне ОС (а не на уровне программы) при вытесняющей архитектуре выполнения реализуется планировщиком ОС. При кооперативной архитектуре выполнения конкурентность на уровне ОС реализуется потоками (инструкциями программы, исполняемыми в ЦП).
Планировщик ОС - компонент ОС, который определяет, какому потоку предоставить ЦП для реализации его кода, а также отвечает за расстановку потоков в очередь к ЦП. Планировщик вместе с ЦП реализует архитектуру выполнения задач: однопоточную или многопоточную.
На современных ОС архитектура выполнения планировщика - вытесняющая.
Планировщик реализует архитектуру выполнения. На большинстве современных ОС архитектура планировщика вытесняющая.
За одну секунду, ядро ЦП исполняет код множества потоков, например, 100 потоков.
У планировщика ОС есть очередь таких потоков он предоставляет им ресурсы ЦП в порядке очереди по-таймеру (например, через каждые 10 миллисекунд менять поток) или вне очереди при определённых обстоятельствах; и прекращает работу потоков в ЦП, если там временно не требуется исполнение кода (например, при ожидании ответа из сети, ожидании ввода команды пользователя и т.д.).
На этом этапе становления go-разработчиком, мне не важно, как работает планировщик ОС. Важно то, что планировщик с вытесняющей архитектурой выполнения использует конкурентное предоставление ресурсов ЦП исходя из своих принципов, одним из которых является - выполняет ли поток ещё работу, или в нём возникла пауза. Это нужно, чтобы повысить КПД (коэффициента полезного действия) использования ЦП ближе к 100%.
Сейчас на компьютерах с многоядерными процессорами планировщик ОС выстраивает очереди из потоков для каждого ядра ЦП, а не для одного, как ранее в одноядерных однопроцессорных компьютерах.
При этом появляется параллельное выполнение: когда потоки запускаются, выполняются и завершаются на разных ядрах ЦП в одно и то же время.
1.5.3. Так а что с конкурентностью в Go?
А с конкурентностью в Go вот что. Если планировщик ОС снимает поток процесса, когда в нём не выполняется какого-то кода, то нужно каким-то образом обеспечить постоянное наличие активного к исполнению кода в потоке. Плюс нагрузить не один поток, а по-максимуму - желательно все ядра, если требуется для исполнения задачи.
Это достигается двумя основными инструментами в Go:
- Горутины;
- Каналы.
При запуске программы (написанной на Go или другом языке), операционная система создаёт процесс, в котором может быть один или больше потоков.
Чтобы максимально выгодно использовать эти имеющиеся ресурсы (потоки), нужно каким-то образом переключать контекст между горутинами, чтобы внутри каждого доступного потока выполнялись разные горутины попеременно.
Горутина - это аналог потока операционной системы с асинхронной конкурентно-параллельной моделью выполнения, управление которой осуществляется внутри Go, а не ОС.
Условно, можно выделить три ключевых отличия горутины от потока:
- Горутина управляется планировщиком Go (потоки - планировщиком ОС);
- Для горутины нужно в среднем в 4000 раз меньше памяти, чем для потока (изначально стек горутины 2 КБ, размер потока в среднем 8 МБ);
- Горутина для исполнения помещается в поток (поток для исполнения помещается в регистры ЦП).
При объявлении горутины ключевым словом go, создаётся стек горутины. В стек помещаются параметры, передаваемые в горутину при её вызове и адрес возврата управления после отработки горутины. А также локальные переменные по ходу исполнения кода горутины.
Итак - в стек горутины помещается всё то же самое, что помещается во фрейм новой функции, см. раздел про функции.
ОС ничего не знает о горутинах, как ЦП ничего не знает о потоках. ЦП только исполняет код, который даёт ему планировщик ОС. Для ЦП всё выглядит, как будто с момента подачи на него электропитания, он выполняет код одной очень большой программы. Ну, может, нескольких программ - по числу ядер ЦП.
Когда мы создаём горутину (в коде), мы не управляем тем, когда она запустится. За это отвечает планировщик Go. Мы только знаем, что горутина запустится асинхронно, а точнее - конкурентно-параллельно.
Вот за что конкретно что отвечает планировщик Go:
- Выстраивает в очередь все горутины, какие есть в программе, к каждому доступному потоку, выделенному программе операционной системой.
- Далее все горутины выполняются последовательно таким образом, чтобы ни один из имеющихся потоков по-возможности не простаивал, а выполнял полезную работу. Если горутина простаивает - планировщик меняет в потоке контекст одной горутины на контекст другой горутины.
Планировщик Go помещает в выделенный ОС поток множество подпотоков (горутин), чтобы программа выполнилась как можно быстрее, в т.ч. за счёт того, что ОС не будет преждевременно прерывать потоки, выделенные программе, написанной на Go, т.к. в потоке не возникнет пауза исполнения кода.
Таким образом, в Go есть свой инструмент реализации архитектуры выполнения кода. Его архитектура выполнения - многопоточная вытесняющая, а фрагменты кода в виде горутин запускаются асинхронно.
Go имеет асинхронную конкурентно-параллельную модель выполнения с вытесняющим планировщиком.
Таким образом, в ОС может быть в режиме ожидания 100 потоков и ЦП на 16 ядер. Т.е. активных потоков 16. 5 активных потоков ОС выделила для запущенной программы, написанной на Go. А Go в эти пять потоков за время их жизни в ЦП, поместил, допустим 25 горутин, чтобы максимально эффективно использовать выданные ресурсы и максимально быстро выполнить свои задачи.
Также у планировщика Go есть два типа очередей горутин: локальные - по числу доступных потоков, и глобальная - куда попадают горутины, если локальные переполнены. В каждой локальной очереди максимум по 256 горутин. Т.е. перед каждым потоком планировщик помещает какое-то количество горутин, которые появились в процессе исполнения программы.
Кстати, за максимальное количество используемых ядер ЦП программой на Go, отвечает переменная GOMAXPROCS пакета runtime. По-умолчанию её значение равно количеству ядер компьютера. При необходимости её можно понизить до желаемого:
runtime.GOMAXPROCS(8)
Если горутины в одном потоке вдруг закончились, то поток берёт половину горутин из очереди соседнего потока.
Итак, итоги:
- Можно сказать, что есть два типа конкурентности. Основная реализуется в коде, а другая реализуется планировщиком (для вытесняющей архитектуры) или самими потоками (для кооперативной архитектуры).
- Конкурентность в коде - это один из видов асинхронного (многозадачного) кода.
- Синхронный и асинхронный код - это не плохой и хороший код. Это код, который используется для разных целей.
- Есть два вида архитектуры выполнения кода - многопоточная и однопоточная, но в обоих случаях будет конкурентность.
- Конкурентность в целом - это механизм повышения коэффициента полезного действия ЦП за счёт смены инструкций, которые на нём исполняются;
- Чтобы увеличить КПД использования ЦП, планировщик современной ОС решает, какой поток пустить в ЦП, а какой вывести из ЦП. Один из факторов вывода потока из ЦП - пауза в потоке по причине ожидания;
- В Go есть свой планировщик, который определяет, как эффективнее заполнить поток от ОС своими горутинами (подпотоками);
- Go - язык с асинхронной конкурентно-параллельной многозадачностью и вытесняющей архитектурой выполнения. Многозадачность реализуется горутинами и каналами, а архитектура выполнения - планировщиком Go.
На этом с конкурентностью всё.
1.6 Каналы
1.6.1. Теория
Канал - это структура данных для передачи данных между горутинами.
Хотя канал - это тип данных, разбираем его отдельно, а не в разделе типов данных, т.к. в перечне вопросов он выделен в отдельный пункт.
Каналы - это та вещь, которая делает язык популярным среди таких компаний, как Netflix, Ozon, WB, Google конечно же, Yandex и т.д.
За пониманием каналов кроется оптимизация приложений, а их устройство и работа ещё сложнее, чем у карт. Постараюсь коснуться поверхностно работы с каналами, но одновременно понять устройство и попрактиковаться с ними.
Важные свойства каналов:
- Потокобезопасность - с одним каналом одновременно может работать несколько горутин и программист может не задумываться об использовании примитивов синхронизации;
- Хранение элементов по принципу FIFO (первый пришёл, первый вышел);
- Передают данные между горутинами - для этого каналы и создавали;
- Умеют блокировать горутины (например, пока одна горутина пытается прочитать данные из пустого канала, она будет заблокирована, пока другая горутина не запишет что-то в канал).
Вот как эти свойства реализуются:
- Потокобезопасность обеспечивается наличием мьютекса в структуре канала.
- Хранение элементов по принципу FIFO обеспечивается буфером канала.
- Передача данных каналом обеспечивается напрямую из стека одной горутины в стек другой горутины и операциями с буфером;
- Блокировка и активизация горутин выполняется с помощью очередей, которые являются дочерними структурами канала, и планировщиком при обращении к нему горутин через функции.
Каналы хранятся в куче.
Куча или heap - часть оперативной памяти (ОЗУ) в виде области динамической памяти, которая выделяется процессу операционной системой при старте программы.
Куча у программы одна и существует в течение всего жизни программы, а стеков может быть один или больше: по числу горутин.
Работа кучи регулируется сборщиком мусора, но это отдельная сложная тема. О ней вкратце:
Сборщик мусора (Garbage Collector, GC) - механизм автоматизированного процесса управления памятью в куче, который освобождает неиспользуемую память, чтобы предотвратить утечки памяти.
Утечка памяти — это ситуация, когда программа не освобождает кучу, когда хранимые объекты больше не нужны. В результате память нельзя использовать повторно для других объектов в куче, что постепенно приведёт к исчерпанию всей ОЗУ.
Сборка мусора использует концепцию маркировки объектов (например, глобальных переменных или каналов) тремя цветами:
- Чёрный - объекты помечаются, как живые.
- Серый - объекты, ранее помеченные, как живые, но требующие повторной проверки.
- Белый - объекты, которые не помечены и будут собраны (память очищается для следующего использования).
Сборка выполняется за две фазы:
- Маркировка цветами объектов в куче;
- Сборка мусора.
На самом деле эта тема сложная, я смотрел фрагмент вебинара, на уровне мидла нужно будет знать это хорошо.
Возвращаемся к каналам. В большинстве случаев, когда нам необходимо взаимодействовать с горутиной, мы передаём в неё канал в качестве аргумента. Горутина получает этот канал как параметр и нам не нужно передавать его по-указателю и разыменовывать в горутине, т.к. это ссылочный тип данных и по-умолчанию копируется значение указателя.
Бывают буферизированные и небуферизированные каналы.
Когда данные пишутся в канал одной горутиной, и при этом есть другая горутина, читающая этот канал, данные передаются напрямую из стека в стек горутины, минуя буфер. Буфер нужен, когда нет горутины, которая читает из канала.
У канала могут быть три состояния:
- Открытый канал - в канал можно писать и из канала можно читать;
- Закрытый канал - прекращён приём данных;
- Заблокированный канал - процесс приёма и передачи данных временно недоступен, т.к. либо нет данных для чтения, либо недостаточно буфера для записи.
Как мы посмотрим в следующем параграфе про контекст, у горутины могут быть тоже три состояния:
- Выполнение (running) - когда горутина находится в потоке и исполняется ЦП;
- Ожидание (waiting) - когда горутина в очереди на поток;
- Блокировка - когда горутина нуждается в ресурсе для продолжения работы.
Когда одна горутина пишет в переполненный канал, она блокируется. Но если буфер канала не полон, горутина продолжает работу. Рассмотрим код:
В терминал выведется печать.
А если попробуем добавить больше элементов, то горутина заблокируется из-за переполнения:
В терминале будет следующее:
При этом возникла не просто блокировка, возник deadlock.
Дедлок - это ситуация, когда одна или несколько горутин блокируется, ожидая ресурса, который никогда не поступит. В результате наступит постоянная блокировка.
В примере, ресурс - это свободный буфер канала. Ещё пару слов о коде.
В коде происходит следующее: горутина пишет в переполненный канал и блокируется, пока другая горутина не прочитает из этого канала. Но горутины, читающей из канала нет, поэтому будет постоянная блокировка.
В примере выше всё просто. Но бывает так, что программа работает корректно, а дедлок может наступить только в определённой ситуации. Для отлова вероятных дедлоков есть разнообразные инструменты от простого логирования и тайм-аута выполнения горутины до специализированных библиотек.
На примере выше хорошо понять отличие буферизированного канала от небуферизированного. В буферезированном канале блокировка наступила после переполнения буфера. А в небуферизированном канале блокировка горутины наступит сразу при добавлении данных в канал:
Идём дальше, допустим у нас есть две горутины, одна из которых пишет, другая - читает:
В функции read мы читаем данные из канала в цикле через ключевое слово range. Оно позволяет читать канал до тех пор, пока в нём что-то есть.
А ещё поведение этой программы не определено. Вот вывод терминала при двух запусках программы:
Дело в том, что главная горутина main не была ни чем заблокирована и завершила работу, не дожидаясь, пока порождённая ей горутина успеет заверить все свои действия.
Рассмотрим такую ситуацию подробнее - когда работающая горутина отправляет данные в переполненный канал (или в небуферизированный канал), а затем будет вызвана вторая горутина, которая готова прочитать данные из канала.
1.6.2. Отправитель первый
- Горутина пытается записать в канал, но не может, т.к. он переполнен. Функция, которая отвечает за отправку данных в канал вызывает так называемую функцию gopark().
- Функция gopark() обращается к планировщику, который изменяет состояние горутины с "Выполнение" на "Блокировка".
- Планировщик Go разрывает связь горутины с её потоком.
- Поток становится свободным, и если в очереди этого потока есть какая-то горутина, то она отправляется в поток. Если нет, то планировщик отправляет половину горутин из соседней очереди другого потока в пустующую очередь потока. Чтобы не было простоев.
- Также наша горутина попала в так называемую очередь sendq (send queue). Такая очередь - это одно из полей канала. Поле sendq является тоже структурой (эмбендинг, привет), точнее - ссылкой на структуру, которая называется waitq (wait queue). А в структуре waitq есть два поля - указатели на начало и конец связанного списка. Элементы связанного списка - это структуры sudog. В полях структуры sudog множество полей, но для понимания там есть два важных поля - g и elem. G - это заблокированная горутина, elem - элемент, на котором была заблокирована горутина, пытаясь его отправить в канал.
- Всё так будет храниться, пока за данными из канала не придёт "читатель/reader".
- Когда "читатель" начнёт читать из канала, он первым делом прочитает не те данные, на которых горутина была заблокирована, а данные из буфера (если он конечно есть).
- Когда элемент буфера освобождается, это сигнал для пробуждения пишущей горутины, чтобы она могла проснуться и продолжить писать в канал.
- Читающая горутина просматривает поле канала sendq, которое представляет из себя очередь спящих горутин. И если находит в очереди что-то - читающая горутина возьмёт элемент, на котором была заблокирована пишущая горутина, и положит его в буфер канала.
- Далее читающая горутина обращается к планировщику Go через функцию goready(), которая активизирует пробуждение заблокированной горутины.
- Функция goready() меняет состояние "заблокирована" на "ожидание" первой горутины. Т.е. планировщик помещает её в очередь к потоку, и когда подойдёт время, выполнение горутины продолжится с того места, где она была заблокирована.
Теперь должно быть понятно, как горутина блокируется и как активируется. Теперь рассмотрим ситуацию, когда читающая канал горутина активизируется первой, а читать - нечего.
1.6.2. Читатель первый
- Канал пустой. Читатель (читающая канал горутина) не может получить искомые данные. Вызывается функция gopakr().
- Планировщик выводит читателя из потока.
- Читатель попадает в очередь recvq (recieve queue) - это аналог очереди sendq в структуре канала.
- Формируется структура sudog, которая является элементом очереди recvq. В поле g хранится сама горутина, а в поле elem хранится ячейка памяти, в которую нужно положить данные, прочитанные из канала.
- Когда пишущая в канал горутина передаёт данные в канал, вызывается функция sendDirect().
- Функция sendDirect() передаёт данные из стека пишущей в канал горутины в стек читающей из канала горутины, минуя буфер (если конечно, он есть). При этом для небуферизированного канала неважно, какая горутина первая - пишущая в канал, или читающая: данные всё равно будут передаваться напрямую из стека одной горутины в стек другой горутины (если есть и читатель и писатель, иначе - постоянная блокировка).
- Далее процесс пробуждения спящей горутины, как в предыдущем примере.
Теперь потренируемся работать с каналами.
1.6.3. Пример №1 с каналами: bool/struct{}
Пример с пустыми структурами, о которых говорил в разделе про структуры.
Сперва создадим канал с типом bool:
В коде из главной горутины main вызывается (создаётся) анонимная горутина. Без канала, запускаемая из main горутина скорее всего не выполнилась бы (поведение не определено). А с каналом горутина main блокируется, пока вызванная из main горутина не даст сигнал о том, что можно продолжить работу.
Тип bool занимает байт, это значит, что мы из стека одной горутины в стек другой горутины копируем байт информации. Да, это немного, но зачем, если можно для этого использовать структуру, которая не весит ни байта, ни бита:
Тот же функционал, но экономия по памяти.
1.6.4. Пример №2 с каналами: пишем в закрытый канал
Рассмотрим код:
Всё нам знакомо, мы частично заполнили канал. А теперь закроем канал с помощью встроенной функции close и запишем в него что-то ещё:
Терминал выдаст:
Так, ладно, в закрытый канал писать нельзя - будет паника. А можно ли читать из закрытого канала?
1.6.5. Пример №3 с каналами: читаем из закрытого канала
Рассмотрим код:
Итак, из закрытого канала можно прочитать данные, которые были в буфере. А затем канал будет отдавать данные типа канала по-умолчанию. Для строки это пустая строка.
Как определить, это тип по-умолчанию, или записанное значение из канала? Рассмотрим пример:
Здесь в двух случаях из канала прочитаны нули. Но в одном случае - это значение, переданное в канал, а в другом - начальный тип данных. Воспользуемся синтаксисом, как в картах:
val, ok := <- ch
Когда канал закрытый, мы получили булевый флаг false.
Можно обойтись без этой громоздкой проверки, если итерируемся через ключевое слово range:
Результат тот же, смысл так же понятен, кода меньше. Но циклы могут быть реализованы только над закрытыми каналами.
А вот если убрать строку 12 из кода о закрытии канала, то получим дедлок:
1.6.5. Пример №4 с каналами: нулевой канал
Рассмотрим код:
Когда мы объявляем канал через встроенную функцию make, у канала появляется какое-то значение. Когда объявляем канал без make, он будет нулевым. При попытке использовать такой канал, возникнет дедлок:
Вот что выведет терминал:
При попытке отправить (или прочитать) в нулевой канал из главной горутины произошёл выход из программы со статусом 2 / дедлок.
1.6.6. Пример №5 с каналами: select
Смоделируем ситуацию, когда мы проектируем сервис, в который приходят данные из двух источников. Эти источники работают с задержкой, и мы не знаем от какого источника данные придут быстрее. Нам достаточно данных одного источника, чтобы продолжить работу.
Создадим две анонимные горутины, которые будут эмулировать асинхронное получение данных, например через обращение к разным БД:
В результате главная горутина будет ждать, пока не получит данные из какого-то канала, обозначенного в select и продолжит работать с той информацией, которая пришла раньше.
Вопрос: что будет, если в обоих каналах будут лежать данные? Ответ: выбор select будет произвольным.
1.6.6. Пример №6 с каналами: сливаем несколько каналов в один
Подразумевается, что есть несколько горутин, которые пишут в свои каналы. Нужно данные из этих горутин получить и обработать ещё в одной горутине.
Так увлёкшись анонимными функциями, я написал весь код в main. При этом здесь 4 горутины:
Вывод здесь будет такой:
Почему тут всё именно так?
Например, можно было бы в main писать в несколько каналов без создания отдельных горутин. Это сработает, только если у нас будут буферизированные каналы, иначе возникнет дедлок:
Почему в анонимной функции, значение из которой присваиваем переменной chMerged, мы создали горутину? Почему бы просто в цикле не проитерироваться? Кстати, тут этих циклов целых два, для чего?
А для того, что в первом цикле мы получаем канал из среза каналов и итерируемся по нему, пока он не будет закрыт:
А если убрать горутину внутри анонимной функции, то возникнет дедлок:
Почему дедлок - мы читаем и пишем в один канал из одной горутины. Т.е., эта ситуация аналогична такой:
Или даже такой:
Результатом будет дедлок, т.к. при попытке записать в переполненный или небуферизированный канал из которого никто не читает, наступает блокировка. Только эту блокировку некому разблокировать.
Но можно сделать финт ушами, и в анонимной функции увеличить ёмкость канала, которую я зачем-то изначально сделал 5:
И терминал выдаст прежний результат:
Технически задача решена, но решена она в одной горутине:
Не знаю, как к этому отнесутся интервьюеры. Скорее всего попросят сделать всё в разных горутинах.
Ладно, всё в каналах не изучишь за короткое время, хотя бы хорошую базу посмотрели.
1.7. Контексты
Планировщик ОС меняет потоки на ЦП не мгновенно, а с задержкой. Планировщик Go меняет горутины в потоках быстрее примерно в пять раз, чем меняются потоки на ЦП, но тоже с некоторой задержкой. Эта задержка вызвана сохранением текущего контекста потока/горутины, который понадобится позднее для восстановления потока в том месте, где его прервал планировщик (и загрузкой контекста другого потока/горутины).
Таким образом, есть контекст на уровне ОС и контекст на уровне Go. Но в любом случае, определение будет одинаковым:
Контекст - это информация о потоке (горутине), необходимая для его приостановки и выполнения позднее.
1.7.1. Контекст потока
Контекст потока во время его выполнения, хранится в регистрах процессора (очень быстрой памяти небольшого размера, впаянной в ЦП).
Ключевые элементы контекста:
- Идентификатор потока;
- Счётчик - указатель на следующую инструкцию кода потока;
- Указателя на текущую вершину стека потока;
- Указатель на структуру данных "Блок управления процессом" (Pointer to the Process control block - PCB), к которому относится поток.
- Состояние потока (Running - выполняется, Ready - готов к выполнению, Waiting - ожидание (какого-то ресурса), Start - поток создан и конфигурируется для статуса Ready, Done - готов, контекст очищается)
У каждого потока есть свой стек - его размер в среднем 8 МБ.
При смене потока, планировщик ОС сохраняет контекст потока в специальную структуру данных ОС - "Блок управления потоком" (Thread control block). Эта структура хранится в разделе ОЗУ, управляемой ядром ОС (ядро - основа ОС). Каждой такой структуре присваивается уникальный номер потока.
Алгоритм примерно такой:
- Планировщик ОС загружает в регистры ЦП контекст потока из "Блока управления потоком";
- ЦП загружает информацию из своих регистров в арифметико-логическое устройство (АЛУ);
- ЦП выполняет код потока;
- ЦП загружает результат вычислений АЛУ в регистры результатов вычислений и берёт новую порцию кода для вычислений из регистров контекста - вычисляет - загружает в регистры результатов;
- Планировщик останавливает поток и копирует данные из регистров ЦП, где хранится информация о потоке, в свою "Блок управления потоком";
- Планировщик ОС загружает в регистры ЦП контекст другого потока из другого "Блока управления потоком";
- ЦП загружает информацию из своих регистров и выполняет код. И так далее, пока электрический ток поступает в ЦП.
1.7.2. Контекст горутин
У каждой горутины, как и у потока, есть свой стек, id горутины, состояние и другие данные. Изначально размер стека горутины 2 КБ.
Горутины могут быть в трёх (четырёх) состояниях:
- Выполнение - когда помещена в поток, т.е. исполняется код горутины;
- Ожидание - когда горутина находится в очереди к потоку;
- Блокировка - когда она пытается получить доступ к данным, которые заблокировала другая горутина.
- Завершена - когда код горутины исполнен. В отдельное состояние обычно не выделяют, но такое состояние тоже возможно - когда просто исчезает информация о ней, просто имеется результат работы горутины.
Когда одна горутина выставляет своё состояние, как "ожидание" или "блокировка", или если просто выполняется долго, планировщик извлекает горутину из потока и помещает туда другую горутину из очереди.
При извлечении горутины из потока, контекст горутины кладётся в специальную структуру. В разделе про каналы я писал, что там в одной из подструктур есть поле g, ссылающееся на структуру g - т.е., на горутину.
В структуре g 56 полей. Ключевые поля в структуре горутины:
- sched - структура контекста горутины, о ней ниже.
- stack - структура с указателем на начало и конец стека горутины;
- m - поток ОС, на котором выполняется горутина;
- goid - идентификатор горутины;
Структура sched состоит из 7 полей, вот ключевые на мой взгляд:
- sp - указатель вершины стека горутины;
- pc - указатель на адрес следующей для выполнения инструкции горутины;
- g - указатель на структуру горутины с более полными данными;
- ctxt - указатель для хранения и передачи параметров между функциями;
- ret - адрес возврата.
Итак, при смене контекста горутины, планировщик сохраняет в специальную структуру указатель вершины стека горутины, указатель на адрес следующей инструкции и другие данные.
Ещё интересная вещь. Веб-разработка подразумевает взаимодействие между элементами инфраструктуры по принципу in/out-bound. Это означает, что подразумевается получение запросов от клиентов, а далее будет происходить множество различных системных вызовов для выполнения запроса. Мы говорили об этом в параграфе "Конкурентность".
Системные вызовы — это интерфейс между приложением и операционной системой.
Системные вызовы могут быть такими: открыть файл, обращение к БД, получение текущего времени, экстренная остановка приложения (аналог Ctrl+C) и т.д. Есть дорогие по времени системные вызовы, и дешёвые. Дорогие предполагают остановку потока ОС. Дорогие системные вызовы приводят к снятию с ЦП потока, т.к. будет простой в потоке.
Так вот, когда Go отправляет дорогой системный вызов в виде команды горутины в поток, он создаёт новый поток и перекидывает все ожидающие горутины на этот новый поток, чтобы не прерывать исполнения своих горутин. Т.к. понимает, что текущий поток планировщик ОС удалит с ЦП в статус "Ожидание". А ОС пусть разбирается сама с потоком.
В целом, это всё, что успел понять по контекстам. Но, у меня гипотеза, что спрашивать будут не про контекст переключения, а про пакет context.
1.7.3. Пакет context
В Go есть пакет context, управляющий временем жизни горутин. Бывают ситуации, когда операции в горутине выполняются слишком долго, и нужно их прервать.
Классический пример его применения: клиент сделал запрос, Go запустил дорогие операции - обращение к удалённой БД или что-то в этом духе. А затем пользователь обновил страницу, или каким-то иным образом передумал получать ответ. А на стороне бэкенда продолжается обработка запроса, которая по итогу не нужна.
Пример №1. Контекст с отменой по времени
Рассмотрим пример:
Мы создали горутину и отправили туда контекст. Далее в исходной горутине мы решили, что эта операция не должна выполняться дольше 3 сек, и отправляем контекст отмены. А принимающая горутина в бесконечном цикле выполняет две вещи:
Пытается выполнить задачу, например, ждёт ответа от удалённого API:
Если ответ не получен в течении отведённого времени, мы завершаем горутину. Ключевое, мы использовали контекст, вэйтгруппу, бесконечный цикл и select с дефолтным кейсом.
При использовании отмены по времени мы используем четыре основных компонента - контекст, вэйтгруппу, цикл и конструкцию select.
Теперь, когда посмотрели простой пример, вернёмся к отмене запроса клиентом.
Пример №2. Контекст с отменой от клиента
Когда клиент (например, браузер) делает HTTP-запрос к серверу, устанавливается TCP-соединение. Это соединение управляется сервером, и в рамках этого подключения сервер может отслеживать его состояние, в т.ч. прерывание соединения.
Дальнейшая логика кода может быть примерно такой:
При этом в течение пяти секунд в браузере пользователь будет видеть что-то вроде процесса загрузки данных. Если нажмём в браузере ESC, или по другому отменим запрос, то контекст TCP-соединения укажет серверу на отмену запроса. И все вызванные горутины будут закрыты, не дожидаясь их выполнения.
Это выгодно для повышения производительности работы. Итак, при использовании контекста с отменой по решению клиента, мы используем не пустой контекст, а контекст с частью запроса клиента; а также конструкцию select.
Для отмены запроса клиента мы используем контекст с частью запроса клиента и конструкцию select.
Другие виды контекстов:
- context.WithDeadline() - указать время отмены. Это полезно, когда нужно гарантировать, что операция завершится в заданные сроки.
- context.WithValue() - передаёт значения между горутинами. Полезно, когда нужно сделать что-то вроде аутентификации при общении горутин.
Более-менее с обоими контекстами разобрались.
2. PostgreSQL
Переходим к СУБД. Пару слов о самой PostgreSQL.
PostgreSQL - это объектно-реляционная система управления базами данных с открытым исходным кодом и языком запросов SQL для управления данными.
Под объектной СУБД подразумевается, что она использует принципы ООП.
Под реляционной подразумевается, что в БД хранятся данные в таблицах, где столбцы - атрибуты, строки - данные.
Реляция - от англ. relation - отношение, и лат. - relatio - донесение, - подразумевает, что данные имеют определённые отношения между собой. Здесь подразумеваются отношения и внутри таблицы, и между таблицами в Р-СУБД. Подробнее - в 13 правилах Кодда - отца реляционных БД.
2.1. Первичный ключ
Первичный ключ - это столбец в таблице, который однозначно идентифицирует каждую строку.
Свойства первичного ключа:
- Уникальность - больше таких же ключей в таблице нет;
- Определённость - первичный ключ не может быть типа NULL;
- Производительность - на первичном ключе автоматически создаётся индекс.
Индекс - это структура данных, которая используется для потенциального ускорения поиска данных.
Существуют разные типы индексов: можно их указывать конкретно, или предоставить выбор СУБД. Примеры индексов: B-tree, Hash, GiST и т.д. Индексы требуют место для их хранения и немного замедляют скорость работы БД. Разумное количество индексов на одну таблицу - не более 5.
Пример создания первичного ключа, см. строку 2:
В целом, таблица может существовать и без первичного ключа, но это не лучший подход.
При этом SERIAL - это тип данных PostgreSQL, который используется для автоматической инкрементации при добавлении новых строк. Если мы используем не SERIAL, а свой тип данных типа INTEGER или TEXT, мы должны сами позаботиться об уникальности передаваемых в СУБД данных для вставки новых записей.
Первичный ключ может быть только один в таблице. Но можно определить составной первичный ключ. Назначение составного первичного ключа:
- Связь многие - ко - многим (см. о ней ниже). Самая распространённая ситуация применения составного ключа;
- В любой таблице, когда важна уникальность не одного, а нескольких элементов. Т.е., чтобы не было повторяющихся комбинаций данных.
Пример составного первичного ключа:
Нам важно обеспечить на уровне СУБД отсутствие повторений значений нескольких столбцов в разных записях.
2.2. Внешний ключ
Допустим, у нас есть две таблицы, где есть одинаковые колонки - например, ФИО человека. Одна таблица хранит основную информацию о человеке, другая - его увлечения. Мы хотим добиться ситуации, чтобы невозможно было вставить в таблицу увлечений информацию о человеке, которого нет в таблице "О человеке".
Внешний ключ - это ограничение, устанавливающее связь между строками в разных таблицах. Эти ограничения проверяются СУБД при операциях с данными.
Чтобы установить связь между таблицами, после ключевого слова REFERENCES указывается имя связанной таблицы и далее в скобках имя столбца из таблицы, на который будет указывать внешний ключ, см. строку 11:
При записи
group_id INT NOT NULL REFERENCES music_groups(g_id)
Мы можем создать запись в таблице songs, только если будет на что сослаться в таблице music_groups.
После выражения REFERENCES могут быть (а могут и не быть) дополнительные выражения. Самые распространённые ON DELETE CASCADE и ON DELETE RESTRICT:
- ON DELETE RESTRICT - ограничивающее удаление, предотвращающее удаление связанной строки. Чтобы удалить связанную строку, нужно сперва удалить строки дочерней таблицы, ссылающиеся на связанную строку.
- ON DELETE CASCADE указывает, что при удалении связанных строк зависимые от них будут автоматически удалены.
Помимо RESTRICT и CASCADE существуют NO ACTIONS, SET NULL и SET DEFAULT. А помимо ON DELETE существует ON UPDATE, но он используется редко.
Внешний ключ должен ссылаться на столбцы, либо являющиеся первичным ключом, либо образующие ограничение уникальности.
Внешний ключ можно создать при создании столбца, как в примере выше. Либо указать после столбца выражением FOREIGN KEY:
Это повышает наглядность, а также позволяет создать несколько внешних ключей в одной строке.
2.3. Связи
В предыдущем параграфе мы выяснили, что для установки связей между таблицами используется внешний ключ. Есть три вида связи:
- Один к одному (1:1) - каждая запись в одной таблице соответствует одной записи в другой таблице. Пример - информация о пользователе разделена на две таблицы для упрощения структуры данных.
- Один ко многим (1:N) - каждая запись в одной таблице может соответствовать одной или больше записям в другой таблиц. Пример - таблица авторы и таблица книги; каждому автору может соответствовать множество книг;
- Многие ко многим (N:M) - каждая запись в одной таблице может соответствовать одной или больше записям в другой таблице и наоборот. Пример - разные студенты ВУЗа могут учиться на разных курсах; на разных курсах могут обучаться разные студенты.
При связи 1:1 столбцы в обеих таблицах должны быть уникальными - в родительской и дочерней. Дочерняя - та, где прописываем REFERENCES, т.е. ссылаемся на родительскую.
Пример:
Внешний ключ установлен на столбце с уникальными записями (Primary key), связан со столбцом в котором уникальные записи.
При связи 1:N столбец в родительской таблице должен быть уникальным, а столбец дочерней таблицы - неуникальным.
Пример:
Внешний ключ установлен на столбце с неуникальными записями, связан со столбцом с уникальными записями.
При связи N:M мы создаём промежуточную таблицу с составным первичным ключом.
Пример:
Таблица student_courses - промежуточная таблица. Она связывает студентов и курсы.
Каждая запись в student_courses связывает одного студента с одним курсом. Таким образом, эта структура позволяет многим студентам записываться на один курс и одному студенту записываться на множество курсов.
В эту таблицу student_courses буду делаться записи примерно такого вида:
Где 1 и 3 - идентификаторы ранее созданных студентов и курсов. Таким образом сохранится целостность данных при их структурировании по разным таблицам.
Получить данные из нескольких связанных внешним ключом (любой связью) таблиц, можно с помощью соединения (JOIN).
2.4. JOIN
Можно почитать в официальной документации: *клик*
Запросы могут обращаться сразу к нескольким таблицам или обращаться к одной таблице так, что одновременно будут обрабатываться разные наборы её строк.
JOIN или соединения - это вид запроса, когда sql-запросы обращаются к разным таблицам для объединения строк, связанных внешним ключом.
Существуют подвиды JOIN-запросов: INNER, LEFT, RIGHT, FULL и CROSS. Разберём несколько из них.
Воспользуемся таблицами, рассмотренной в параграфе выше для связи многие-ко-многим. Предположим, мы заполнили их данными:
Есть несколько видов JOIN-запросов.
2.4.1. INNER JOIN
Запрос возвращает только те строки, которые имеют совпадения в обеих таблицах.
Составим запрос:
Результат:
Соединение по-умолчанию использует INNER JOIN, поэтому проще писать в sql-запросе без ключевого слова INNER:
Результат будет тот же.
2.4.2. LEFT JOIN (или LEFT OUTER JOIN)
Возвращает все строки из левой таблицы и совпадающие строки из правой таблицы. Если совпадения отсутствуют, результат будет содержать NULL для полей правой таблицы.
Пример запроса:
Результат:
2.4.3. RIGHT JOIN (или RIGHT OUTER JOIN)
LEFT наоборот. Возвращает все строки из правой таблицы и совпадающие строки из левой таблицы. Если совпадения отсутствуют, результат будет содержать NULL для полей левой таблицы.
Пример запроса:
Результат не могу продемонстрировать в виде иллюстрации, т.к. Дзен пишет "добавлено максимальное количество медиафайлов". Буду текстом:
name | title
Alice | Mathematics
Alice | Computer
Science | BobHistory
NULL | Computer Science
На этом подготовка с PostgreSQL завершена.
3. Задачи
Поскольку иллюстрации больше не прикрепляются, добавлять буду текстом и постараюсь лаконично сделать.
3.1. defer. Переменные
defer - это ключевое слово, которое позволяет отложить выполнение какой-либо функции, указанной в defer, до момента окончания работы функции, вызывающей defer.
defer отработает, если после его вызова произошла panic, т.е. os.Exit(2).
defer не отработает, если после него произошёл os.Exit(1) или что-то вроде log.Fatal(), т.к. под ним тоже os.Exit(1).
Основное использование defer
- Закрытие ресурсов: при открытии файла или соединения с базой данных, мы можем использовать defer для автоматического закрытия ресурса после завершения функции.
- Логгирование: используя defer, мы можем записывать информацию о завершении функции после её исполнения.
- Обработка ошибок: можно использовать отложенные вызовы для обработки ошибок или выполнения ручных действий в случае паники.
Пример использования defer:
3.1.1. Пример с defer №1: простой
func main() {
defer fmt.Println("Goodbye!")
fmt.Println("Hello!")
}
Сперва будет выведено Hello, затем Goodbye.
3.1.2. Пример с defer №2: простой захват переменной
Ключевой аспект defer - он захватывает переменные с тем значением, которые были у переменных в момент вызова defer.
func main() {
num := 100
defer fmt.Println(num)
num = num * num
fmt.Println(num)
}
Вывод будет 10000, а затем 100.
defer запомнил значение переменной в тот момент, где он был вызван и работает с ним.
3.1.3. Пример с defer №3: захват переменной через замыкание
Доработаем предыдущий пример, помещая в defer анонимную функцию:
func main() {
num := 100
defer func() {
fmt.Println(num)
}()
num = num * num
fmt.Println(num)
}
Казалось бы всё, как в предыдущем примере, но вывод будет:
10000 и 10000
Это классический пример замыкания: переменная num в анонимной функции обладает доступом ко всей области видимости функции main. Т.е. будут учтены все изменения по окончанию работы функции.
Замыкание (closure) - это функция, которая "захватывает" переменные из своей окружающей области видимости в момент своего создания.
Область видимости - механизм, определяющий, где в коде доступны переменные.
Есть несколько типов области видимости:
- Внутри фрагмента функции: циклы, конструкции if, блоки из фигурных скобок {}.
- Локальная - в пределах функции;
- Глобальная неимпортируемая - в пределах пакета;
- Глобальная импортируемая - доступна из любого участка кода.
Суть в чём - у defer своя область видимости, а у анонимной функции - своя область видимости переменной.
Поэтому, чтобы в дефер передавалось значение, которое должно измениться в конце функции, тогда нужно работать с ним через такой способ.
Кстати, область видимости через фигурные скобки {} нужна для ssa-оптимизации: когда по какой-то причине функция получается очень длинной. Это помогает компилятору эффективнее работать с кодом.
3.1.3. Пример с defer №4: возвращение переменной через замыкание
Изменим переменную в defer перед возвращением из функции:
func main() {
num := 100
num = changeNum(num)
fmt.Println(num)
}
func changeNum(num int) int {
defer func() { num = 555 }()
return num * num
}
В терминал будет выведено 10000, т.е. замыкание не изменит данные, которые возвращаются из функции.
Почему? По-сути, переменная num будет изменена, но вернётся именно то, что указано после return.
Чтобы вернуть то, что выполняется в замыкании, нужно сделать именованным возвращаемую переменную в сигнатуре функции:
func main() {
num := 100
num = changeNum(num)
fmt.Println(num)
}
func changeNum(num int) (n int) {
defer func() { n = 555 }()
return num * num
}
Вывод в терминал будет: 555.
Это бывает полезно при обработке ошибок.
3.2. defer. Порядок вызова
Тут всё просто - defer вызывается в обратном порядке по ходу кода:
func main() {
defer fmt.Println(100)
defer fmt.Println(200)
defer fmt.Println(300)
}
Вывод: 300, 200, 100
Если defer в цикле, порядок также будет обратный:
func main() {
for i := 0; i < 3; i++ {
defer fmt.Println(i)
}
}
Вывод: 2, 1, 0
Такой порядок вызван тем, что функции из defer кладутся в стек горутины. А стек работает по принципу LIFO: last in, first out. Т.е. последний defer, который попал в стек, отработает первым.
На этом всё с порядком вызова.
3.2. defer. Горутины и waitgroup
Напомню, горутина - это последовательность исполнения кода процесса, управляемый планировщиком Go.
Вэйтгруппа - это структура для синхронизации времени жизни горутин.
Вейтгруппа передаётся в качестве параметра по-указателю в горутину, чтобы она отработала корректно.
У вейтгруппы три метода: Add, Done, Wait.
Помните пример с кодом про неопределённое поведение программы в разделе про каналы?
func main() {
ch := make(chan int)
go read(ch)
ch <- 1
ch <- 2
log.Println("Программа завершена")
}
func read(ch chan int) {
for val := range ch {
fmt.Println("Читаем из канала:", val)
}
log.Println("Чтение из канала завершено")
}
Строка "Чтение из канала завершено" как правило не успевала выводиться в терминал, потому-что главная горутина завершалась раньше - её ничто не останавливало.
Доработаем код с вэйтгруппой. Для этого также я закрыл канал в главной горутине.
func main() {
ch := make(chan int)
var wg sync.WaitGroup
wg.Add(1)
go read(ch, &wg)
ch <- 1
ch <- 2
close(ch)
wg.Wait()
log.Println("Программа завершена")
}
func read(ch chan int, wg *sync.WaitGroup) {
for val := range ch {
fmt.Println("Читаем из канала:", val)
}
log.Println("Чтение из канала завершено")
wg.Done()
}
Сейчас вывод в терминал будет определённым: выведутся все строки.
Вейтгруппа позволяет однозначно дождаться выполнения всех задач в горутине. А передаётся в функцию она по-указателю, т.к. нам необходимо изменять значения внутри структуры вейтгруппа - чтобы всё работало.
В целом - это ключевая информация по вэйтгруппе.
3. Итоги
Итак, это была целый курс, в котором мы изучили разнообразную информацию по Go, PostgreSQL и компьютерным наукам. По ней можно хорошо прокачать навыки разработчика.
Добавлю, что техническое интервью, к которому я готовился и составил эту публикацию, уже прошёл. Вот темы, которые мне задали дополнительно:
- Три способа использовать интерфейс (те способы, что сказал я - не совсем то, что ждал интервьюер);
- Дженерики;
- Инверсия зависимостей;
- Как реализовать Graceful Shutdown;
- Цель транзакций, ACID, уровни изоляции.
Буду дальше продолжать поиск работы, помогает мне в этом акселератор Яндекс Практикума.
Спасибо, что дочитали эту публикацию до конца. Успехов, и будем на связи.
Бро, ты уже здесь? 👉 Подпишись на канал для начинающих IT-специалистов «Войти в IT» в Telegram, будем изучать IT вместе 👨💻👩💻👨💻