Всем привет!

Этот учебник создан для того, чтобы каждый желающий мог освоить Go без лишних сложностей. Я постарался сделать курс доступным и интересным как для новичков, так и для тех, кто уже знаком с программированием. Мы изучим не только базовые аспекты языка, но и погрузимся в его системные глубины — разберёмся, как работает сборщик мусора (GC), планировщик (Scheduler) и другие внутренние механизмы Go.

Помимо самого Go, мы уделим внимание важным библиотекам и технологиям, которые помогают в реальных проектах: Kafka, SQL (в частности PostgreSQL), Redis, Docker, Podman, Kubernetes, и многому другому.

Почему этот учебник бесплатен?

Главная идея — сделать обучение Go проще и доступнее. Я не претендую на строгое следование каким-либо Style guide, а предоставляю информацию, основанную на своём личном опыте и проверенных источниках, таких как книги и статьи. Здесь вы найдёте свободный подход, без платных разделов и без навязывания единственно верных решений. Этот курс — скорее отправная точка, чтобы вы могли сами выбрать инструменты и подходы, которые вам подходят.

Если у вас появятся вопросы, предложения или критика, пишите мне в Telegram или оставляйте замечания в репозитории на GitHub. Я всегда рад обратной связи и готов сделать курс ещё лучше для вас!

Полезные ссылки:


Что такое Go?

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

Одна из ключевых особенностей Go — его минимализм. Язык содержит всего 25 ключевых слов (в сравнении с 35 в Python и 50 в Java), что делает его легче для освоения. Вам не придётся учить множество синтаксических конструкций, чтобы быстро погрузиться в разработку. Но за этой простотой стоит мощный набор возможностей для создания сложных и масштабируемых решений.

Для чего используется Go?

Go — универсальный язык, который позволяет решать самые разные задачи. Вы можете использовать его для создания практически любых типов приложений. Хотите разработать десктопное приложение? Воспользуйтесь фреймворком Wails. Мечтаете написать игру? Тогда на помощь придёт Ebitengine.

Хотя для таких задач существуют другие языки, Go активно используется там, где критически важны производительность и простота. Основные направления, в которых Go сейчас занимает лидирующие позиции, — это:

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

Также Go активно используется для:

  • Разработки backend-частей веб-приложений,
  • Создания CLI-утилит (командных интерфейсов),
  • Проектов в области информационной безопасности.

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

Особенности перехода на Go и изучения

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

В Go всего несколько базовых конструкций управления, таких как if, switch и for. Вместо традиционного объектно-ориентированного подхода с классами, Go предлагает использовать структуры (struct) и интерфейсы (interface) для организации кода.

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

Установка Go

Для изучения Go вам понадобится Go проект на своём компьютере, чтобы вы могли запускать код из примеров и выполнять дополнительные задания для лучшего изучения языка. Для начала перейдём на сайт Go и скачаем компилятор:

Windows:

  1. Скачайте *.msi файл с Go со страницы - тут.
  2. Откройте загруженный MSI-файл и следуйте инструкциям по установке Go. По умолчанию установщик установит Go в Program Files или Program Files (x86). Вы можете изменить расположение по мере необходимости.
  3. После установки вам нужно будет закрыть и повторно откройте все открытые командные строки, чтобы изменения в среде сделанные установщиком, отображаются в командной строке.

Убедитесь, что вы установили Go.

  1. В Windows щелкните меню «Пуск» .
  2. В поле поиска меню введите cmd и запустите консоль.
  3. В появившемся окне командной строки введите: go version

Linux

  1. Скачайте файл для linux с сайта - тут
  2. Удалите все предыдущие версии Go удалив папку /usr/local/go (Если ранее уже устанавливали), затем извлеките только что загруженный архив в /usr/local, чтобы получилась такая структура папок /usr/local/go:
rm -rf /usr/local/go && tar -C /usr/local -xzf  go1.23.1.linux-amd64.tar.gz

(Возможно, вам придется запустить команду от имени пользователя root или через sudo).

  1. Не распаковывайте архив в существующую папку go в /usr/local/go. Это может привести к проблемам установки Go.
  2. Добавьте /usr/local/go/bin в переменую среды. Вы можете сделать это, добавив следующую строку в свой $HOME/.profile или /etc/profile (для общесистемной установки):
export PATH=$PATH:/usr/local/go/bin

Примечание. Изменения, внесенные в файл профиля, могут не примениться. до следующего раза, когда вы войдете в свой компьютер. Чтобы применить изменения немедленно, просто запустите команды оболочки напрямую или выполните их из профиль с помощью такой команды, как source $HOME/.profile.

  1. Убедитесь, что вы установили Go, открыв командную строку и набрав следующая команда: go version

Mac

  1. Скачайте файл для mac с сайта - тут
  2. Откройте загруженный файл пакета и следуйте инструкциям по установке
  3. Пакет устанавливает Go в /usr/local/go. Каталог /usr/local/go/bin в вашу переменную среды (PATH). Возможно, вам придется перезапустить открытые сеансы терминала, чтобы изменения вступили в силу.
  4. Убедитесь, что вы установили Go, открыв командную строку и набрав следующая команда: go version

Создание проекта на go

Теперь после установки Go создадим свой первый проект на Go.

  1. Создайте папку для проекта (Она может иметь любое название, но лучше в стиле app1)
  2. Зайдите через консоль в папку (Способы перехода в папку в вашей ОС можно найти в интернете или же через консоль cd app1)
  3. В консоли пропишите go mod init app1 для инициализации приложения
  4. Создайте файл main.go
  5. Ваш проект готов!

Урок 1: Первое знакомство с Go

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

Почему Go?

Прежде чем мы начнём писать код, давайте поговорим о том, почему Go стоит изучать:

  • Простота и читаемость: Синтаксис Go минималистичен и понятен
  • Высокая производительность: Go компилируется в нативный код
  • Встроенная поддержка конкурентности: Горутины и каналы делают параллельное программирование простым
  • Богатая стандартная библиотека: Множество готовых решений "из коробки"
  • Кроссплатформенность: Один код работает на разных операционных системах
  • Сильная типизация: Помогает избежать ошибок на этапе компиляции

Ваша первая программа на Go

Вот наш код:

package main 

import "fmt"

func main() {
    fmt.Println("Hello, World!") // Выведет: Hello, World!
}

Давайте разберём его по частям и поймём, почему он работает именно так.

Разбор кода

1. package main

В Go код организуется в пакеты (packages). Пакет main — это особый пакет, который сообщает компилятору, что этот файл является точкой входа в программу. Без него программа не сможет быть запущена как исполняемый файл.

💡 Интересный факт: В Go нет понятия "классов" как в объектно-ориентированных языках. Вместо этого используются пакеты и структуры.

2. import "fmt"

Ключевое слово import позволяет подключать внешние пакеты. В нашем примере мы используем fmt — это библиотека для работы с вводом и выводом, которая входит в стандартную библиотеку Go.

🚀 Совет: Стандартная библиотека Go очень богата. Позже мы изучим такие пакеты как:

  • net/http для работы с веб
  • encoding/json для работы с JSON
  • os для работы с операционной системой
  • и многие другие

3. func main()

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

Важно: В Go нет понятия "конструктора" как в других языках. Инициализация обычно происходит в функции init(), которая выполняется автоматически перед main().

4. fmt.Println()

Функция fmt.Println() выводит текст в консоль и автоматически добавляет новую строку.

Разные способы вывода в Go:

// Простой вывод
fmt.Print("Hello, ") // Без перевода строки
fmt.Print("World!")  // Выведет: Hello, World!

// Вывод с переводом строки
fmt.Println("Hello, World!") // Выведет: Hello, World! и переведёт строку

// Форматированный вывод
name := "Иван"
age := 25
fmt.Printf("Привет, %s! Тебе %d лет.\n", name, age)
// Выведет: Привет, Иван! Тебе 25 лет.

Работа с вводом данных

Go предоставляет несколько способов для ввода данных. Давайте рассмотрим основные:

1. Простой ввод с fmt.Scan

package main

import "fmt"

func main() {
    var name string
    fmt.Println("Введите ваше имя:")
    fmt.Scan(&name)
    fmt.Printf("Привет, %s!\n", name)
}

2. Ввод нескольких значений

var firstName, lastName string
fmt.Println("Введите ваше имя и фамилию:")
fmt.Scan(&firstName, &lastName)
fmt.Printf("Привет, %s %s!\n", firstName, lastName)

3. Более продвинутый ввод с bufio

package main

import (
    "bufio"
    "fmt"
    "os"
)

func main() {
    reader := bufio.NewReader(os.Stdin)
    fmt.Println("Введите ваше имя:")
    name, _ := reader.ReadString('\n')
    fmt.Printf("Привет, %s!\n", name)
}

Практические задания

Задание 1: Калькулятор возраста

Напишите программу, которая:

  1. Запрашивает год рождения пользователя
  2. Вычисляет его возраст
  3. Выводит приветствие с указанием возраста

Задание 2: Конвертер температур

Создайте программу, которая:

  1. Запрашивает температуру в градусах Цельсия
  2. Конвертирует её в градусы Фаренгейта
  3. Выводит результат с точностью до двух знаков после запятой

Задание 3: Генератор паролей

Напишите простой генератор паролей, который:

  1. Запрашивает длину пароля
  2. Генерирует случайный пароль заданной длины
  3. Выводит результат

💡 Совет: Для генерации случайных чисел используйте пакет math/rand

🎯 Цель урока: К концу этого урока вы должны уметь:

  • Создавать простые программы на Go
  • Работать с вводом и выводом данных
  • Понимать базовую структуру Go-программы
  • Использовать основные функции пакета fmt

Переменные и типы данных в Go: просто и эффективно

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

Почему типизация в Go особенная?

Go — это язык со строгой статической типизацией, что означает:

  • Типы проверяются на этапе компиляции
  • Нет неявных преобразований типов
  • Каждая переменная имеет чётко определённый тип
  • Типы помогают писать более надёжный код

💡 Интересный факт: В Go нет классов и наследования, но есть мощная система типов, которая обеспечивает безопасность и производительность.

Объявление переменных

1. Классический способ (var)

var name string = "Иван"
var age int = 25

2. Краткая форма (:=)

name := "Иван"  // Go сам определит тип string
age := 25       // Go сам определит тип int

3. Множественное объявление

var (
    name string = "Иван"
    age  int    = 25
)

// Или краткая форма
name, age := "Иван", 25

Важно: Оператор := можно использовать только внутри функций!

Основные типы данных

1. Числовые типы

Целые числа

var (
    a int   = 42        // Зависит от платформы (32/64 бита)
    b int8  = 127       // -128 до 127
    c int16 = 32767     // -32768 до 32767
    d int32 = 2147483647 // -2147483648 до 2147483647
    e int64 = 9223372036854775807
)

Беззнаковые целые числа

var (
    a uint   = 42
    b uint8  = 255      // 0 до 255
    c uint16 = 65535    // 0 до 65535
    d uint32 = 4294967295
    e uint64 = 18446744073709551615
)

Числа с плавающей точкой

var (
    pi float32 = 3.14159
    e  float64 = 2.718281828459045
)

2. Строки и символы

var (
    name string = "Иван"
    char rune   = 'Я'  // Unicode символ
)

🚀 Совет: В Go строки неизменяемы и используют UTF-8 кодировку. Для работы с отдельными символами используйте тип rune.

3. Логические значения

var (
    isActive bool = true
    isAdmin  bool = false
)

Сложные типы данных

1. Массивы

// Фиксированный массив из 5 элементов
var numbers [5]int = [5]int{1, 2, 3, 4, 5}

// Массив можно инициализировать так
primes := [...]int{2, 3, 5, 7, 11, 13}

2. Срезы (динамические массивы)

// Создание среза
numbers := []int{1, 2, 3, 4, 5}

// Добавление элементов
numbers = append(numbers, 6, 7)

// Создание среза из массива
arr := [5]int{1, 2, 3, 4, 5}
slice := arr[1:4] // [2, 3, 4]

3. Карты (ассоциативные массивы)

// Создание карты
ages := map[string]int{
    "Иван": 25,
    "Пётр": 30,
}

// Добавление элемента
ages["Анна"] = 28

// Проверка существования ключа
age, exists := ages["Иван"]
if exists {
    fmt.Printf("Возраст: %d\n", age)
}

4. Структуры

type Person struct {
    Name    string
    Age     int
    Address string
}

// Создание экземпляра структуры
person := Person{
    Name:    "Иван",
    Age:     25,
    Address: "Москва",
}

Константы

const (
    Pi       = 3.14159
    Version  = "1.0.0"
    MaxUsers = 1000
)

💡 Интересный факт: В Go есть специальный тип iota для создания последовательностей констант:

const (
    Monday = iota + 1
    Tuesday
    Wednesday
    Thursday
    Friday
    Saturday
    Sunday
)

Практические задания

Задание 1: Калькулятор ИМТ

Создайте программу, которая:

  1. Запрашивает рост и вес пользователя
  2. Вычисляет индекс массы тела (ИМТ)
  3. Выводит результат с интерпретацией (недостаточный вес, норма, избыточный вес)

Задание 2: Конвертер валют

Напишите программу, которая:

  1. Хранит курсы валют в константах
  2. Запрашивает сумму и валюту для конвертации
  3. Выводит результат в выбранной валюте

Задание 3: Учёт книг

Создайте программу для учета книг в библиотеке:

  1. Определите структуру Book с полями: название, автор, год издания, жанр
  2. Создайте срез книг
  3. Реализуйте функции для добавления и поиска книг

Что дальше?

В следующем уроке мы:

  • Изучим операторы и выражения
  • Познакомимся с условными операторами
  • Начнём писать более сложные программы
  • Узнаем о приведении типов

🎯 Цель урока: К концу этого урока вы должны уметь:

  • Работать с разными типами данных в Go
  • Использовать различные способы объявления переменных
  • Создавать и использовать сложные типы данных
  • Понимать особенности типизации в Go

Основные конструкции управления в Go

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

Почему управляющие конструкции важны?

Управляющие конструкции — это основа любого алгоритма. В Go они:

  • Позволяют принимать решения
  • Управляют повторением действий
  • Делают код более гибким и мощным
  • Помогают обрабатывать различные сценарии

💡 Интересный факт: В Go нет цикла while, но его можно эмулировать с помощью for. Это сделано для упрощения языка и уменьшения количества ключевых слов.

Условные операторы

1. Оператор if

package main

import "fmt"

func main() {
    age := 20
    
    if age >= 18 {
        fmt.Println("Вы совершеннолетний.")
    } else {
        fmt.Println("Вы несовершеннолетний.")
    }
}

Важно: В Go условие в if должно быть булевым выражением. Нельзя использовать числа или другие типы напрямую.

2. Расширенный if с else if

func checkAge(age int) {
    if age < 13 {
        fmt.Println("Вы ребёнок.")
    } else if age >= 13 && age < 18 {
        fmt.Println("Вы подросток.")
    } else if age >= 18 && age < 60 {
        fmt.Println("Вы взрослый.")
    } else {
        fmt.Println("Вы пожилой человек.")
    }
}

3. Инициализация в условии

if age := getUserAge(); age >= 18 {
    fmt.Printf("Возраст: %d, доступ разрешён\n", age)
} else {
    fmt.Printf("Возраст: %d, доступ запрещён\n", age)
}

Оператор switch

1. Базовый switch

func getDayName(day int) string {
    switch day {
    case 1:
        return "Понедельник"
    case 2:
        return "Вторник"
    case 3:
        return "Среда"
    case 4:
        return "Четверг"
    case 5:
        return "Пятница"
    case 6:
        return "Суббота"
    case 7:
        return "Воскресенье"
    default:
        return "Неизвестный день"
    }
}

2. switch без выражения

func checkTime(hour int) {
    switch {
    case hour >= 5 && hour < 12:
        fmt.Println("Утро")
    case hour >= 12 && hour < 17:
        fmt.Println("День")
    case hour >= 17 && hour < 22:
        fmt.Println("Вечер")
    default:
        fmt.Println("Ночь")
    }
}

3. Множественные значения в case

func isWeekend(day int) bool {
    switch day {
    case 6, 7:
        return true
    default:
        return false
    }
}

Циклы

1. Классический цикл for

// Вывод чисел от 1 до 10
for i := 1; i <= 10; i++ {
    fmt.Println(i)
}

2. Цикл с условием (аналог while)

// Вывод чисел, пока они меньше 10
i := 1
for i < 10 {
    fmt.Println(i)
    i++
}

3. Бесконечный цикл

// Бесконечный цикл с выходом по условию
for {
    if someCondition() {
        break
    }
    // Действия
}

4. range для итерации

// Итерация по массиву
numbers := []int{1, 2, 3, 4, 5}
for index, value := range numbers {
    fmt.Printf("Индекс: %d, Значение: %d\n", index, value)
}

// Итерация по карте
ages := map[string]int{
    "Иван": 25,
    "Пётр": 30,
}
for name, age := range ages {
    fmt.Printf("%s: %d лет\n", name, age)
}

Управление циклом

1. break и continue

// Пример с break
for i := 1; i <= 10; i++ {
    if i == 5 {
        break // Выход из цикла
    }
    fmt.Println(i)
}

// Пример с continue
for i := 1; i <= 10; i++ {
    if i%2 == 0 {
        continue // Пропуск чётных чисел
    }
    fmt.Println(i)
}

2. Метки для вложенных циклов

outerLoop:
for i := 1; i <= 3; i++ {
    for j := 1; j <= 3; j++ {
        if i*j == 6 {
            break outerLoop // Выход из обоих циклов
        }
        fmt.Printf("%d * %d = %d\n", i, j, i*j)
    }
}

Практические задания

Задание 1: Калькулятор с меню

Создайте программу-калькулятор, которая:

  1. Выводит меню с операциями (+, -, *, /)
  2. Запрашивает два числа
  3. Выполняет выбранную операцию
  4. Позволяет продолжить или выйти

Задание 2: Игра "Угадай число"

Напишите игру, где:

  1. Компьютер загадывает число от 1 до 100
  2. Игрок пытается угадать число
  3. Программа подсказывает "больше" или "меньше"
  4. Считает количество попыток

Задание 3: Анализатор текста

Создайте программу, которая:

  1. Принимает текст от пользователя
  2. Подсчитывает количество:
    • Слов
    • Букв
    • Цифр
    • Специальных символов
  3. Выводит статистику

Что дальше?

В следующем уроке мы:

  • Изучим функции в Go
  • Познакомимся с методами
  • Узнаем о замыканиях
  • Начнём писать более сложные программы

🎯 Цель урока: К концу этого урока вы должны уметь:

  • Использовать все управляющие конструкции Go
  • Писать эффективные циклы
  • Применять условные операторы
  • Управлять потоком выполнения программы

Срезы и карты в Go: мощные структуры данных

В этом уроке мы изучим две фундаментальные структуры данных в Go — срезы (slices) и карты (maps). Эти инструменты являются основой для работы с коллекциями данных и широко используются в реальных проектах.

Почему срезы и карты важны?

Срезы и карты — это ключевые структуры данных в Go, которые:

  • Позволяют эффективно работать с коллекциями
  • Предоставляют гибкость в управлении данными
  • Оптимизированы для производительности
  • Используются в большинстве Go-программ

💡 Интересный факт: В Go нет встроенных динамических массивов, но срезы предоставляют всю необходимую функциональность и даже больше.

Срезы (Slices)

1. Что такое срез?

Срез — это динамическая обёртка над массивом, которая предоставляет:

  • Гибкий размер
  • Эффективное управление памятью
  • Удобные операции с данными
// Создание среза разными способами
slice1 := []int{1, 2, 3}           // Литерал среза
slice2 := make([]int, 3)           // С помощью make
slice3 := make([]int, 3, 5)        // С указанием длины и ёмкости

2. Основные операции со срезами

// Добавление элементов
numbers := []int{1, 2, 3}
numbers = append(numbers, 4, 5)    // [1, 2, 3, 4, 5]

// Удаление элемента
numbers = append(numbers[:2], numbers[3:]...) // [1, 2, 4, 5]

// Копирование среза
copySlice := make([]int, len(numbers))
copy(copySlice, numbers)

3. Длина и ёмкость

slice := make([]int, 3, 5)
fmt.Println(len(slice))  // 3 (текущая длина)
fmt.Println(cap(slice))  // 5 (максимальная ёмкость)

// При превышении ёмкости Go автоматически увеличивает срез
slice = append(slice, 1, 2, 3)
fmt.Println(cap(slice))  // 10 (удвоенная ёмкость)

Карты (Maps)

1. Что такое карта?

Карта — это коллекция пар "ключ-значение", которая:

  • Обеспечивает быстрый доступ к данным
  • Поддерживает уникальные ключи
  • Позволяет эффективно искать значения
// Создание карты
ages := map[string]int{
    "Иван": 25,
    "Пётр": 30,
}

// Добавление элемента
ages["Анна"] = 28

// Проверка существования ключа
if age, exists := ages["Иван"]; exists {
    fmt.Printf("Возраст: %d\n", age)
}

2. Операции с картами

// Удаление элемента
delete(ages, "Пётр")

// Итерация по карте
for name, age := range ages {
    fmt.Printf("%s: %d лет\n", name, age)
}

// Очистка карты
for key := range ages {
    delete(ages, key)
}

3. Особенности карт

// Нулевая карта
var emptyMap map[string]int
fmt.Println(emptyMap == nil) // true

// Создание карты с начальной ёмкостью
scores := make(map[string]int, 100)

// Использование struct{} для множеств
unique := make(map[string]struct{})
unique["value"] = struct{}{}

Практические примеры

1. Фильтрация данных

func filterEven(numbers []int) []int {
    result := []int{}
    for _, num := range numbers {
        if num%2 == 0 {
            result = append(result, num)
        }
    }
    return result
}

2. Подсчёт частоты слов

func countWords(text string) map[string]int {
    words := strings.Fields(text)
    frequency := make(map[string]int)
    
    for _, word := range words {
        frequency[word]++
    }
    
    return frequency
}

3. Кэширование результатов

type Cache struct {
    data map[string]interface{}
    mu   sync.RWMutex
}

func (c *Cache) Get(key string) (interface{}, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    value, exists := c.data[key]
    return value, exists
}

Практические задания

Задание 1: Анализатор текста

Создайте программу, которая:

  1. Принимает текст от пользователя
  2. Подсчитывает частоту каждого слова
  3. Выводит топ-5 самых часто встречающихся слов
  4. Сохраняет результаты в карту

Задание 2: Система управления задачами

Напишите программу, которая:

  1. Позволяет добавлять задачи
  2. Отмечать задачи как выполненные
  3. Удалять задачи
  4. Показывать список всех задач
  5. Использует срезы для хранения задач

Задание 3: Калькулятор статистики

Создайте программу, которая:

  1. Принимает список чисел
  2. Вычисляет:
    • Среднее значение
    • Медиану
    • Моду
    • Стандартное отклонение
  3. Использует срезы для хранения и обработки данных

Что дальше?

В следующем уроке мы:

  • Изучим указатели в Go
  • Познакомимся с интерфейсами
  • Узнаем о методах и получателях
  • Начнём писать более сложные программы

🎯 Цель урока: К концу этого урока вы должны уметь:

  • Эффективно работать со срезами и картами
  • Применять их в реальных задачах
  • Понимать особенности их реализации
  • Оптимизировать работу с коллекциями данных

Горутины: параллельное программирование в Go

В этом уроке мы изучим одну из самых мощных возможностей Go — горутины. Это лёгкие потоки выполнения, которые позволяют создавать высокопроизводительные параллельные программы.

Почему горутины важны?

Горутины — это фундаментальная концепция в Go, которая:

  • Позволяет эффективно использовать многоядерные процессоры
  • Упрощает написание параллельного кода
  • Обеспечивает высокую производительность
  • Уменьшает накладные расходы на создание потоков

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

Основы горутин

1. Создание горутины

package main

import (
    "fmt"
    "time"
)

func worker(id int) {
    fmt.Printf("Работник %d начал работу\n", id)
    time.Sleep(time.Second)
    fmt.Printf("Работник %d закончил работу\n", id)
}

func main() {
    // Запуск горутины
    go worker(1)
    
    // Даём время на выполнение
    time.Sleep(2 * time.Second)
}

2. Анонимные горутины

func main() {
    // Запуск анонимной горутины
    go func(id int) {
        fmt.Printf("Анонимный работник %d начал работу\n", id)
        time.Sleep(time.Second)
        fmt.Printf("Анонимный работник %d закончил работу\n", id)
    }(1)
    
    time.Sleep(2 * time.Second)
}

Синхронизация горутин

1. WaitGroup

package main

import (
    "fmt"
    "sync"
    "time"
)

func worker(id int, wg *sync.WaitGroup) {
    defer wg.Done() // Уменьшаем счётчик при завершении
    
    fmt.Printf("Работник %d начал\n", id)
    time.Sleep(time.Second)
    fmt.Printf("Работник %d закончил\n", id)
}

func main() {
    var wg sync.WaitGroup
    
    // Запускаем 5 работников
    for i := 1; i <= 5; i++ {
        wg.Add(1) // Увеличиваем счётчик
        go worker(i, &wg)
    }
    
    wg.Wait() // Ждём завершения всех работников
    fmt.Println("Все работники завершили работу")
}

2. Каналы для синхронизации

func worker(id int, done chan bool) {
    fmt.Printf("Работник %d начал\n", id)
    time.Sleep(time.Second)
    fmt.Printf("Работник %d закончил\n", id)
    done <- true // Сигнализируем о завершении
}

func main() {
    done := make(chan bool, 5) // Буферизованный канал
    
    // Запускаем работников
    for i := 1; i <= 5; i++ {
        go worker(i, done)
    }
    
    // Ждём завершения всех работников
    for i := 1; i <= 5; i++ {
        <-done
    }
}

Продвинутые техники

1. Обработка ошибок в горутинах

func worker(id int, errChan chan error) {
    defer func() {
        if r := recover(); r != nil {
            errChan <- fmt.Errorf("паника в работнике %d: %v", id, r)
        }
    }()
    
    // Работа, которая может вызвать панику
    if id == 3 {
        panic("что-то пошло не так")
    }
    
    fmt.Printf("Работник %d выполнил работу\n", id)
    errChan <- nil
}

2. Ограничение количества горутин

func processTasks(tasks []string, maxWorkers int) {
    semaphore := make(chan struct{}, maxWorkers)
    var wg sync.WaitGroup
    
    for _, task := range tasks {
        wg.Add(1)
        semaphore <- struct{}{} // Занимаем слот
        
        go func(t string) {
            defer func() {
                <-semaphore // Освобождаем слот
                wg.Done()
            }()
            
            // Обработка задачи
            fmt.Printf("Обработка: %s\n", t)
            time.Sleep(time.Second)
        }(task)
    }
    
    wg.Wait()
}

Практические примеры

1. Параллельная обработка данных

func processData(data []int) []int {
    result := make([]int, len(data))
    var wg sync.WaitGroup
    
    for i, v := range data {
        wg.Add(1)
        go func(idx int, value int) {
            defer wg.Done()
            // Тяжёлые вычисления
            result[idx] = value * value
        }(i, v)
    }
    
    wg.Wait()
    return result
}

2. Пул воркеров

type WorkerPool struct {
    tasks chan func()
    wg    sync.WaitGroup
}

func NewWorkerPool(size int) *WorkerPool {
    pool := &WorkerPool{
        tasks: make(chan func()),
    }
    
    pool.wg.Add(size)
    for i := 0; i < size; i++ {
        go func() {
            defer pool.wg.Done()
            for task := range pool.tasks {
                task()
            }
        }()
    }
    
    return pool
}

Практические задания

Задание 1: Параллельный поиск

Создайте программу, которая:

  1. Принимает список чисел
  2. Запускает несколько горутин для поиска заданного числа
  3. Возвращает результат, как только число найдено
  4. Использует каналы для синхронизации

Задание 2: Веб-скрапер

Напишите программу, которая:

  1. Принимает список URL
  2. Параллельно скачивает содержимое страниц
  3. Извлекает определённую информацию
  4. Сохраняет результаты в структурированном виде

Задание 3: Система обработки заказов

Создайте программу, которая:

  1. Принимает поток заказов
  2. Обрабатывает их параллельно
  3. Учитывает ограничения ресурсов
  4. Предоставляет статус обработки

Что дальше?

В следующем уроке мы:

  • Изучим каналы в Go
  • Познакомимся с паттернами параллельного программирования
  • Узнаем о конкурентных структурах данных
  • Начнём писать более сложные параллельные программы

🎯 Цель урока: К концу этого урока вы должны уметь:

  • Создавать и управлять горутинами
  • Синхронизировать параллельные операции
  • Обрабатывать ошибки в горутинах
  • Оптимизировать параллельные программы

Методы синхронизации между горутинами

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

Почему важна синхронизация?

Синхронизация горутин необходима для:

  • Предотвращения гонок данных (race conditions)
  • Координации работы между горутинами
  • Безопасного доступа к общим ресурсам
  • Эффективного использования системных ресурсов

💡 Интересный факт: В Go есть поговорка "Не общайтесь, разделяя память. Разделяйте память, общаясь". Это означает, что каналы предпочтительнее мьютексов для синхронизации.

Основные методы синхронизации

1. WaitGroup — ожидание завершения горутин

sync.WaitGroup — это простой и эффективный способ дождаться завершения группы горутин.

package main

import (
    "fmt"
    "sync"
    "time"
)

func worker(id int, wg *sync.WaitGroup) {
    defer wg.Done() // Уменьшаем счётчик при завершении
    
    fmt.Printf("Работник %d начал\n", id)
    time.Sleep(time.Second)
    fmt.Printf("Работник %d закончил\n", id)
}

func main() {
    var wg sync.WaitGroup
    
    // Запускаем 5 работников
    for i := 1; i <= 5; i++ {
        wg.Add(1) // Увеличиваем счётчик
        go worker(i, &wg)
    }
    
    wg.Wait() // Ждём завершения всех работников
    fmt.Println("Все работники завершили работу")
}

2. Mutex — защита общих данных

sync.Mutex используется для защиты общих данных от одновременного доступа.

package main

import (
    "fmt"
    "sync"
)

type SafeCounter struct {
    mu    sync.Mutex
    count int
}

func (c *SafeCounter) Increment() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.count++
}

func (c *SafeCounter) Value() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.count
}

func main() {
    counter := SafeCounter{}
    var wg sync.WaitGroup
    
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            counter.Increment()
        }()
    }
    
    wg.Wait()
    fmt.Println("Итоговый счётчик:", counter.Value())
}

3. RWMutex — оптимизированная блокировка

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

type SafeMap struct {
    mu   sync.RWMutex
    data map[string]string
}

func (m *SafeMap) Get(key string) string {
    m.mu.RLock()
    defer m.mu.RUnlock()
    return m.data[key]
}

func (m *SafeMap) Set(key, value string) {
    m.mu.Lock()
    defer m.mu.Unlock()
    m.data[key] = value
}

Каналы для синхронизации

1. Небуферизированные каналы

func worker(done chan bool) {
    fmt.Println("Работа начинается...")
    time.Sleep(time.Second)
    fmt.Println("Работа завершена")
    done <- true
}

func main() {
    done := make(chan bool)
    go worker(done)
    <-done // Ждём завершения
}

2. Буферизированные каналы

func main() {
    ch := make(chan int, 3)
    
    // Отправляем три значения без блокировки
    ch <- 1
    ch <- 2
    ch <- 3
    
    // Читаем значения
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
}

3. Select для работы с несколькими каналами

func main() {
    ch1 := make(chan string)
    ch2 := make(chan string)
    
    go func() {
        time.Sleep(time.Second)
        ch1 <- "сообщение 1"
    }()
    
    go func() {
        time.Sleep(2 * time.Second)
        ch2 <- "сообщение 2"
    }()
    
    select {
    case msg := <-ch1:
        fmt.Println(msg)
    case msg := <-ch2:
        fmt.Println(msg)
    case <-time.After(3 * time.Second):
        fmt.Println("таймаут")
    }
}

Продвинутые техники

1. sync.Map для конкурентного доступа

func main() {
    var m sync.Map
    
    // Запись
    m.Store("key", "value")
    
    // Чтение
    if value, ok := m.Load("key"); ok {
        fmt.Println(value)
    }
    
    // Перебор
    m.Range(func(key, value interface{}) bool {
        fmt.Println(key, value)
        return true
    })
}

2. sync.Cond для сложной синхронизации

type WorkerPool struct {
    cond *sync.Cond
    mu   sync.Mutex
    jobs int
}

func (p *WorkerPool) AddJob() {
    p.mu.Lock()
    p.jobs++
    p.cond.Signal()
    p.mu.Unlock()
}

func (p *WorkerPool) Worker() {
    p.mu.Lock()
    for p.jobs == 0 {
        p.cond.Wait()
    }
    p.jobs--
    p.mu.Unlock()
    
    // Выполнение работы
}

Практические примеры

1. Пул воркеров с каналами

type WorkerPool struct {
    tasks chan func()
    wg    sync.WaitGroup
}

func NewWorkerPool(size int) *WorkerPool {
    pool := &WorkerPool{
        tasks: make(chan func()),
    }
    
    pool.wg.Add(size)
    for i := 0; i < size; i++ {
        go func() {
            defer pool.wg.Done()
            for task := range pool.tasks {
                task()
            }
        }()
    }
    
    return pool
}

2. Ограничение количества горутин

func processTasks(tasks []string, maxWorkers int) {
    semaphore := make(chan struct{}, maxWorkers)
    var wg sync.WaitGroup
    
    for _, task := range tasks {
        wg.Add(1)
        semaphore <- struct{}{}
        
        go func(t string) {
            defer func() {
                <-semaphore
                wg.Done()
            }()
            
            // Обработка задачи
            fmt.Printf("Обработка: %s\n", t)
            time.Sleep(time.Second)
        }(task)
    }
    
    wg.Wait()
}

Практические задания

Задание 1: Параллельная обработка файлов

Создайте программу, которая:

  1. Читает несколько файлов параллельно
  2. Обрабатывает их содержимое
  3. Объединяет результаты
  4. Использует WaitGroup для синхронизации

Задание 2: Кэш с конкурентным доступом

Напишите потокобезопасный кэш, который:

  1. Хранит данные в map
  2. Использует RWMutex для оптимизации
  3. Поддерживает TTL для записей
  4. Обеспечивает атомарные операции

Задание 3: Система очередей

Создайте систему очередей, которая:

  1. Принимает задачи через каналы
  2. Обрабатывает их в пуле воркеров
  3. Ограничивает количество одновременных задач
  4. Предоставляет статус выполнения

Что дальше?

В следующем уроке мы:

  • Изучим паттерны параллельного программирования
  • Познакомимся с контекстами в Go
  • Узнаем о конкурентных структурах данных
  • Начнём писать более сложные параллельные программы

🎯 Цель урока: К концу этого урока вы должны уметь:

  • Синхронизировать горутины различными способами
  • Защищать общие данные от гонок
  • Использовать каналы для коммуникации
  • Оптимизировать параллельные программы

Структуры в Go: организация данных и поведение

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

Почему важны структуры?

Структуры в Go необходимы для:

  • Группировки связанных данных
  • Создания пользовательских типов
  • Инкапсуляции данных и поведения
  • Реализации объектно-ориентированного подхода
  • Создания сложных структур данных

💡 Интересный факт: В Go нет классов, но структуры с методами позволяют реализовать многие концепции объектно-ориентированного программирования.

Основы структур

1. Создание и использование структур

type Person struct {
    Name    string
    Age     int
    Address struct {
        City    string
        Country string
    }
}

func main() {
    // Создание структуры
    person := Person{
        Name: "Иван",
        Age:  30,
        Address: struct {
            City    string
            Country string
        }{
            City:    "Москва",
            Country: "Россия",
        },
    }
    
    // Доступ к полям
    fmt.Printf("%s живёт в %s\n", person.Name, person.Address.City)
}

2. Методы структур

type Rectangle struct {
    Width  float64
    Height float64
}

// Метод с получателем-значением
func (r Rectangle) Area() float64 {
    return r.Width * r.Height
}

// Метод с получателем-указателем
func (r *Rectangle) Scale(factor float64) {
    r.Width *= factor
    r.Height *= factor
}

func main() {
    rect := Rectangle{Width: 10, Height: 5}
    fmt.Println("Площадь:", rect.Area())
    
    rect.Scale(2)
    fmt.Println("Новая площадь:", rect.Area())
}

Продвинутые техники

1. Встраивание структур

type Animal struct {
    Name string
    Age  int
}

type Dog struct {
    Animal
    Breed string
}

func (a Animal) Speak() string {
    return "Я животное"
}

func (d Dog) Speak() string {
    return "Гав-гав!"
}

func main() {
    dog := Dog{
        Animal: Animal{Name: "Рекс", Age: 3},
        Breed:  "Овчарка",
    }
    
    fmt.Println(dog.Name)      // Доступ к встроенному полю
    fmt.Println(dog.Speak())   // Вызов переопределённого метода
}

2. Интерфейсы и структуры

type Speaker interface {
    Speak() string
}

type Cat struct {
    Name string
}

func (c Cat) Speak() string {
    return "Мяу!"
}

func MakeSound(s Speaker) {
    fmt.Println(s.Speak())
}

func main() {
    cat := Cat{Name: "Мурзик"}
    MakeSound(cat)
}

3. Теги структур

type User struct {
    ID        int    `json:"id"`
    Username  string `json:"username"`
    Password  string `json:"-"` // Поле будет пропущено при сериализации
    CreatedAt time.Time `json:"created_at"`
}

func main() {
    user := User{
        ID:        1,
        Username:  "admin",
        Password:  "secret",
        CreatedAt: time.Now(),
    }
    
    data, _ := json.Marshal(user)
    fmt.Println(string(data))
}

Практические примеры

1. Кэш с TTL

type Cache struct {
    mu    sync.RWMutex
    items map[string]struct {
        value      interface{}
        expiration time.Time
    }
}

func (c *Cache) Set(key string, value interface{}, ttl time.Duration) {
    c.mu.Lock()
    defer c.mu.Unlock()
    
    c.items[key] = struct {
        value      interface{}
        expiration time.Time
    }{
        value:      value,
        expiration: time.Now().Add(ttl),
    }
}

func (c *Cache) Get(key string) (interface{}, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    
    item, exists := c.items[key]
    if !exists || time.Now().After(item.expiration) {
        return nil, false
    }
    return item.value, true
}

2. Очередь задач

type Task struct {
    ID        string
    Payload   interface{}
    Priority  int
    CreatedAt time.Time
}

type TaskQueue struct {
    tasks chan Task
    wg    sync.WaitGroup
}

func NewTaskQueue(workers int) *TaskQueue {
    q := &TaskQueue{
        tasks: make(chan Task, 100),
    }
    
    q.wg.Add(workers)
    for i := 0; i < workers; i++ {
        go q.worker()
    }
    
    return q
}

func (q *TaskQueue) worker() {
    defer q.wg.Done()
    for task := range q.tasks {
        // Обработка задачи
        fmt.Printf("Обработка задачи %s\n", task.ID)
    }
}

Практические задания

Задание 1: Система управления библиотекой

Создайте структуры для:

  1. Книги (название, автор, год издания, статус)
  2. Читателя (имя, контакты, список взятых книг)
  3. Библиотеки (каталог книг, список читателей) Реализуйте методы для:
  • Выдачи и возврата книг
  • Поиска книг по различным критериям
  • Отслеживания статуса книг

Задание 2: Система управления задачами

Разработайте систему для:

  1. Создания и управления задачами
  2. Назначения задач пользователям
  3. Отслеживания прогресса
  4. Установки приоритетов и сроков Используйте:
  • Вложенные структуры
  • Методы с указателями
  • Интерфейсы для разных типов задач

Задание 3: Система мониторинга

Создайте систему для:

  1. Сбора метрик с серверов
  2. Хранения исторических данных
  3. Генерации отчётов
  4. Оповещения о проблемах Реализуйте:
  • Потокобезопасные структуры данных
  • Эффективное хранение временных рядов
  • Гибкую систему оповещений

Что дальше?

В следующем уроке мы:

  • Изучим интерфейсы в Go
  • Познакомимся с рефлексией
  • Узнаем о тегах структур
  • Начнём писать более сложные программы

🎯 Цель урока: К концу этого урока вы должны уметь:

  • Создавать и использовать структуры
  • Работать с методами и указателями
  • Применять встраивание структур
  • Использовать теги для сериализации

Интерфейсы в Go: гибкость и абстракция

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

Почему важны интерфейсы?

Интерфейсы в Go необходимы для:

  • Создания абстракций
  • Реализации полиморфизма
  • Упрощения тестирования
  • Разделения ответственности
  • Создания гибких API

💡 Интересный факт: В Go нет явного объявления реализации интерфейса. Если тип реализует все методы интерфейса, он автоматически считается его реализацией.

Основы интерфейсов

1. Объявление и реализация

// Определение интерфейса
type Speaker interface {
    Speak() string
    Listen() string
}

// Реализация интерфейса
type Person struct {
    Name string
}

func (p Person) Speak() string {
    return fmt.Sprintf("Привет, я %s", p.Name)
}

func (p Person) Listen() string {
    return fmt.Sprintf("%s слушает", p.Name)
}

func main() {
    person := Person{Name: "Иван"}
    fmt.Println(person.Speak())
    fmt.Println(person.Listen())
}

2. Использование интерфейсов

type Animal interface {
    Speak() string
    Move() string
}

type Dog struct {
    Name string
}

func (d Dog) Speak() string {
    return "Гав-гав!"
}

func (d Dog) Move() string {
    return "Бежит"
}

func DescribeAnimal(a Animal) {
    fmt.Printf("Животное говорит: %s\n", a.Speak())
    fmt.Printf("Животное двигается: %s\n", a.Move())
}

func main() {
    dog := Dog{Name: "Рекс"}
    DescribeAnimal(dog)
}

Продвинутые техники

1. Пустой интерфейс

type Storage interface {
    Store(key string, value interface{}) error
    Retrieve(key string) (interface{}, error)
}

type MemoryStorage struct {
    data map[string]interface{}
}

func (m *MemoryStorage) Store(key string, value interface{}) error {
    m.data[key] = value
    return nil
}

func (m *MemoryStorage) Retrieve(key string) (interface{}, error) {
    value, exists := m.data[key]
    if !exists {
        return nil, fmt.Errorf("ключ не найден")
    }
    return value, nil
}

2. Приведение типов

func ProcessValue(v interface{}) {
    switch value := v.(type) {
    case int:
        fmt.Printf("Целое число: %d\n", value)
    case string:
        fmt.Printf("Строка: %s\n", value)
    case bool:
        fmt.Printf("Булево значение: %v\n", value)
    default:
        fmt.Printf("Неизвестный тип: %T\n", value)
    }
}

3. Композиция интерфейсов

type Reader interface {
    Read() string
}

type Writer interface {
    Write(string)
}

type ReadWriter interface {
    Reader
    Writer
}

type File struct {
    content string
}

func (f *File) Read() string {
    return f.content
}

func (f *File) Write(text string) {
    f.content = text
}

Практические примеры

1. HTTP-клиент с интерфейсом

type HTTPClient interface {
    Get(url string) ([]byte, error)
    Post(url string, data []byte) ([]byte, error)
}

type RealHTTPClient struct {
    client *http.Client
}

func (c *RealHTTPClient) Get(url string) ([]byte, error) {
    resp, err := c.client.Get(url)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
    return io.ReadAll(resp.Body)
}

// Тестовый клиент для unit-тестов
type MockHTTPClient struct {
    Response []byte
    Error    error
}

func (c *MockHTTPClient) Get(url string) ([]byte, error) {
    return c.Response, c.Error
}

2. Система логирования

type Logger interface {
    Debug(msg string, fields ...interface{})
    Info(msg string, fields ...interface{})
    Error(msg string, fields ...interface{})
}

type ConsoleLogger struct {
    level string
}

func (l *ConsoleLogger) Debug(msg string, fields ...interface{}) {
    if l.level == "debug" {
        fmt.Printf("[DEBUG] %s %v\n", msg, fields)
    }
}

type FileLogger struct {
    file *os.File
}

func (l *FileLogger) Debug(msg string, fields ...interface{}) {
    fmt.Fprintf(l.file, "[DEBUG] %s %v\n", msg, fields)
}

Практические задания

Задание 1: Система платежей

Создайте систему для обработки различных типов платежей:

  1. Определите интерфейс PaymentProcessor
  2. Реализуйте разные типы платежей (кредитная карта, PayPal, банковский перевод)
  3. Создайте универсальный обработчик платежей
  4. Добавьте валидацию и обработку ошибок

Задание 2: Система кэширования

Разработайте систему кэширования с поддержкой разных бэкендов:

  1. Создайте интерфейс Cache
  2. Реализуйте кэш в памяти и Redis
  3. Добавьте поддержку TTL
  4. Реализуйте стратегии вытеснения

Задание 3: Система уведомлений

Создайте систему для отправки уведомлений:

  1. Определите интерфейс Notifier
  2. Реализуйте отправку через email, SMS и push-уведомления
  3. Добавьте поддержку шаблонов
  4. Реализуйте очередь уведомлений

Что дальше?

В следующем уроке мы:

  • Изучим работу с файлами
  • Познакомимся с пакетом io
  • Узнаем о буферизации
  • Начнём работать с потоками данных

🎯 Цель урока: К концу этого урока вы должны уметь:

  • Создавать и использовать интерфейсы
  • Работать с пустым интерфейсом
  • Применять приведение типов
  • Использовать композицию интерфейсов

Обработка ошибок в Go: panic, defer и recover

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

Почему важна обработка ошибок?

Правильная обработка ошибок необходима для:

  • Предотвращения аварийного завершения программ
  • Корректного освобождения ресурсов
  • Обеспечения предсказуемого поведения
  • Улучшения отладки
  • Создания надёжных систем

💡 Интересный факт: В Go нет традиционных исключений (try-catch). Вместо этого используются возврат ошибок и механизм panic/recover.

Основы обработки ошибок

1. Паника (panic)

func SafeOperation() {
    // Отложенная функция для восстановления после паники
    defer func() {
        if r := recover(); r != nil {
            fmt.Printf("Восстановлено после паники: %v\n", r)
        }
    }()

    // Критическая операция
    if err := CriticalOperation(); err != nil {
        panic(fmt.Sprintf("Критическая ошибка: %v", err))
    }
}

func CriticalOperation() error {
    // Имитация критической ошибки
    return fmt.Errorf("недостаточно ресурсов")
}

func main() {
    SafeOperation()
}

Объяснение:

  • Функция SafeOperation демонстрирует безопасное выполнение критических операций
  • defer с recover гарантирует, что даже при панике программа не завершится аварийно
  • CriticalOperation имитирует возникновение критической ошибки

Ожидаемый вывод:

Восстановлено после паники: Критическая ошибка: недостаточно ресурсов

2. Отложенные вызовы (defer)

func ProcessFile(filename string) error {
    // Открытие файла с обработкой ошибок
    file, err := os.Open(filename)
    if err != nil {
        return fmt.Errorf("ошибка открытия файла: %v", err)
    }
    // Гарантированное закрытие файла при выходе из функции
    defer file.Close()

    // Чтение данных из файла
    data, err := io.ReadAll(file)
    if err != nil {
        return fmt.Errorf("ошибка чтения файла: %v", err)
    }

    fmt.Printf("Прочитано %d байт из файла\n", len(data))
    return nil
}

func main() {
    if err := ProcessFile("example.txt"); err != nil {
        fmt.Printf("Ошибка: %v\n", err)
    }
}

Объяснение:

  • Функция ProcessFile демонстрирует безопасную работу с файлами
  • defer file.Close() гарантирует закрытие файла даже при возникновении ошибок
  • Обработка ошибок на каждом этапе работы с файлом

Ожидаемый вывод:

Прочитано 1024 байт из файла

или при ошибке:

Ошибка: ошибка открытия файла: файл не найден

Продвинутые техники

1. Множественные defer

func ComplexOperation() {
    fmt.Println("Начало операции")
    
    // Отложенные операции выполняются в обратном порядке
    defer fmt.Println("Очистка ресурсов")
    defer fmt.Println("Закрытие соединений")
    defer fmt.Println("Завершение транзакций")
    
    // Основная логика
    fmt.Println("Выполнение операции")
}

func main() {
    ComplexOperation()
}

Объяснение:

  • Демонстрирует порядок выполнения множественных defer
  • Показывает, как организовать последовательность очистки ресурсов
  • Важен порядок объявления defer - последний объявленный выполняется первым

Ожидаемый вывод:

Начало операции
Выполнение операции
Завершение транзакций
Закрытие соединений
Очистка ресурсов

2. Восстановление с контекстом

func SafeOperationWithContext() {
    defer func() {
        if r := recover(); r != nil {
            // Добавление контекста к ошибке
            err := fmt.Errorf("операция завершилась с ошибкой: %v", r)
            // Логирование ошибки
            log.Printf("Ошибка: %v", err)
            // Возврат ошибки выше
            panic(err)
        }
    }()

    // Опасная операция
    DangerousOperation()
}

func DangerousOperation() {
    panic("критическая ошибка в DangerousOperation")
}

func main() {
    SafeOperationWithContext()
}

Объяснение:

  • Показывает, как добавить контекст к ошибке при восстановлении
  • Демонстрирует многоуровневую обработку ошибок
  • Включает логирование для отладки

Ожидаемый вывод:

2023/01/01 12:00:00 Ошибка: операция завершилась с ошибкой: критическая ошибка в DangerousOperation

3. Отложенные функции с параметрами

func DeferredParameters() {
    value := "начальное значение"
    
    // Параметры функции в defer фиксируются в момент объявления
    defer func(v string) {
        fmt.Printf("Отложенное значение: %s\n", v)
    }(value)
    
    // Изменение значения не влияет на отложенную функцию
    value = "новое значение"
    fmt.Printf("Текущее значение: %s\n", value)
}

func main() {
    DeferredParameters()
}

Объяснение:

  • Демонстрирует, как параметры фиксируются в момент объявления defer
  • Показывает разницу между текущим и отложенным значением
  • Важно для понимания времени выполнения отложенных функций

Ожидаемый вывод:

Текущее значение: новое значение
Отложенное значение: начальное значение

Практические примеры

1. Транзакции в базе данных

type Transaction struct {
    db *sql.DB
}

func (t *Transaction) Execute() error {
    // Начало транзакции
    tx, err := t.db.Begin()
    if err != nil {
        return fmt.Errorf("ошибка начала транзакции: %v", err)
    }
    
    // Гарантированный откат при панике
    defer func() {
        if r := recover(); r != nil {
            tx.Rollback()
            panic(r)
        }
    }()
    
    // Выполнение операций
    if err := t.performOperations(tx); err != nil {
        tx.Rollback()
        return err
    }
    
    // Подтверждение транзакции
    return tx.Commit()
}

func main() {
    db := connectToDB()
    tx := &Transaction{db: db}
    
    if err := tx.Execute(); err != nil {
        fmt.Printf("Ошибка транзакции: %v\n", err)
    }
}

Объяснение:

  • Демонстрирует атомарность транзакций
  • Показывает безопасное выполнение операций с БД
  • Гарантирует откат при ошибках

Ожидаемый вывод при успехе:

Транзакция успешно выполнена

или при ошибке:

Ошибка транзакции: ошибка выполнения операций: недостаточно средств

2. Управление ресурсами

type ResourceManager struct {
    resources []Resource
    mu        sync.Mutex
}

func (rm *ResourceManager) Acquire() (Resource, error) {
    rm.mu.Lock()
    // Гарантированное освобождение мьютекса
    defer rm.mu.Unlock()
    
    if len(rm.resources) == 0 {
        return nil, fmt.Errorf("нет доступных ресурсов")
    }
    
    resource := rm.resources[0]
    rm.resources = rm.resources[1:]
    
    return resource, nil
}

func (rm *ResourceManager) Release(resource Resource) {
    rm.mu.Lock()
    defer rm.mu.Unlock()
    
    rm.resources = append(rm.resources, resource)
}

func main() {
    rm := &ResourceManager{
        resources: make([]Resource, 5),
    }
    
    res, err := rm.Acquire()
    if err != nil {
        fmt.Printf("Ошибка: %v\n", err)
        return
    }
    
    defer rm.Release(res)
    // Использование ресурса
}

Объяснение:

  • Демонстрирует безопасное управление пулом ресурсов
  • Показывает использование мьютексов для синхронизации
  • Гарантирует освобождение ресурсов

Ожидаемый вывод:

Ресурс успешно получен
Ресурс освобождён

Практические задания

Задание 1: Система обработки транзакций

Создайте систему для обработки финансовых транзакций:

  1. Реализуйте механизм отката транзакций
  2. Добавьте обработку критических ошибок
  3. Обеспечьте атомарность операций
  4. Реализуйте логирование ошибок

Ожидаемый результат:

  • Безопасное выполнение транзакций
  • Корректный откат при ошибках
  • Подробное логирование всех операций

Задание 2: Менеджер соединений

Разработайте систему управления сетевыми соединениями:

  1. Создайте пул соединений
  2. Реализуйте безопасное освобождение ресурсов
  3. Добавьте обработку разрывов соединений
  4. Обеспечьте переподключение при ошибках

Ожидаемый результат:

  • Эффективное использование соединений
  • Автоматическое восстановление при сбоях
  • Мониторинг состояния соединений

Задание 3: Система обработки файлов

Создайте систему для безопасной работы с файлами:

  1. Реализуйте безопасное открытие/закрытие файлов
  2. Добавьте обработку ошибок доступа
  3. Обеспечьте корректное освобождение ресурсов
  4. Реализуйте механизм восстановления после сбоев

Ожидаемый результат:

  • Надёжная работа с файлами
  • Корректная обработка ошибок доступа
  • Автоматическое освобождение ресурсов

Что дальше?

В следующем уроке мы:

  • Изучим работу с горутинами
  • Познакомимся с каналами
  • Узнаем о синхронизации
  • Начнём писать параллельные программы

🎯 Цель урока: К концу этого урока вы должны уметь:

  • Использовать panic и recover
  • Применять defer для управления ресурсами
  • Обрабатывать критические ошибки
  • Создавать надёжные системы

Работа с файловой системой в Go

В этом уроке мы изучим работу с файловой системой в Go. Это важный навык, который позволит вам:

  • Читать и записывать данные в файлы
  • Управлять директориями и путями
  • Обрабатывать ошибки при работе с файлами
  • Эффективно работать с большими файлами
  • Создавать надёжные системы хранения данных

💡 Интересный факт: Go предоставляет несколько способов работы с файлами, от простых однострочных операций до сложных буферизированных операций, что позволяет выбрать оптимальный подход для каждой задачи.

Основные пакеты для работы с файлами

В Go есть несколько важных пакетов для работы с файлами:

  • os - базовые операции с файлами и директориями
  • io - интерфейсы для ввода/вывода
  • bufio - буферизированные операции
  • path/filepath - работа с путями
  • ioutil - вспомогательные функции

Чтение файлов

1. Простое чтение файла

package main

import (
	"fmt"
	"io/ioutil"
	"os"
)

func main() {
	// Открываем файл
	file, err := os.Open("example.txt")
	if err != nil {
		fmt.Println("Ошибка при открытии файла:", err)
		return
	}
	// Гарантированное закрытие файла
	defer file.Close()

	// Чтение содержимого файла
	content, err := ioutil.ReadAll(file)
	if err != nil {
		fmt.Println("Ошибка при чтении файла:", err)
		return
	}

	fmt.Println("Содержимое файла:")
	fmt.Println(string(content))
}

Объяснение:

  • os.Open открывает файл в режиме только для чтения
  • defer file.Close() гарантирует закрытие файла даже при ошибках
  • ioutil.ReadAll читает всё содержимое файла за один раз
  • Преобразование string(content) необходимо, так как ReadAll возвращает байтовый срез

Ожидаемый вывод:

Содержимое файла:
Это содержимое файла example.txt
Вторая строка
Третья строка

2. Построчное чтение файла

package main

import (
	"bufio"
	"fmt"
	"os"
)

func main() {
	file, err := os.Open("example.txt")
	if err != nil {
		fmt.Println("Ошибка при открытии файла:", err)
		return
	}
	defer file.Close()

	// Создаём сканер для построчного чтения
	scanner := bufio.NewScanner(file)
	lineNumber := 1

	// Читаем файл построчно
	for scanner.Scan() {
		fmt.Printf("Строка %d: %s\n", lineNumber, scanner.Text())
		lineNumber++
	}

	// Проверяем ошибки сканера
	if err := scanner.Err(); err != nil {
		fmt.Println("Ошибка при чтении файла:", err)
	}
}

Объяснение:

  • bufio.NewScanner создаёт сканер для построчного чтения
  • scanner.Scan() читает следующую строку
  • scanner.Text() возвращает текущую строку
  • scanner.Err() проверяет наличие ошибок

Ожидаемый вывод:

Строка 1: Это содержимое файла example.txt
Строка 2: Вторая строка
Строка 3: Третья строка

Запись в файлы

1. Простая запись в файл

package main

import (
	"fmt"
	"os"
)

func main() {
	// Создаём новый файл (или перезаписываем существующий)
	file, err := os.Create("output.txt")
	if err != nil {
		fmt.Println("Ошибка при создании файла:", err)
		return
	}
	defer file.Close()

	// Записываем несколько строк
	lines := []string{
		"Первая строка",
		"Вторая строка",
		"Третья строка",
	}

	for _, line := range lines {
		_, err := file.WriteString(line + "\n")
		if err != nil {
			fmt.Println("Ошибка при записи в файл:", err)
			return
		}
	}

	fmt.Println("Запись в файл успешно завершена!")
}

Объяснение:

  • os.Create создаёт новый файл или перезаписывает существующий
  • file.WriteString записывает строку в файл
  • Добавляем \n для перевода строки
  • defer file.Close() гарантирует закрытие файла

Ожидаемый вывод:

Запись в файл успешно завершена!

Содержимое файла output.txt:

Первая строка
Вторая строка
Третья строка

2. Буферизированная запись

package main

import (
	"bufio"
	"fmt"
	"os"
)

func main() {
	file, err := os.Create("buffered_output.txt")
	if err != nil {
		fmt.Println("Ошибка при создании файла:", err)
		return
	}
	defer file.Close()

	// Создаём буферизированный писатель
	writer := bufio.NewWriter(file)
	
	// Записываем данные в буфер
	for i := 1; i <= 1000; i++ {
		fmt.Fprintf(writer, "Строка %d\n", i)
	}

	// Записываем буфер в файл
	if err := writer.Flush(); err != nil {
		fmt.Println("Ошибка при записи буфера:", err)
		return
	}

	fmt.Println("Буферизированная запись завершена!")
}

Объяснение:

  • bufio.NewWriter создаёт буферизированный писатель
  • fmt.Fprintf форматирует и записывает строку в буфер
  • writer.Flush() записывает буфер в файл
  • Буферизация повышает производительность при большом количестве записей

Ожидаемый вывод:

Буферизированная запись завершена!

Содержимое файла buffered_output.txt:

Строка 1
Строка 2
...
Строка 1000

Работа с директориями

1. Создание и удаление директорий

package main

import (
	"fmt"
	"os"
	"path/filepath"
)

func main() {
	// Создаём путь к новой директории
	dirPath := filepath.Join("test", "subdir", "data")
	
	// Создаём директорию со всеми родительскими директориями
	if err := os.MkdirAll(dirPath, 0755); err != nil {
		fmt.Println("Ошибка при создании директории:", err)
		return
	}

	fmt.Printf("Директория %s создана успешно\n", dirPath)

	// Удаляем директорию и все её содержимое
	if err := os.RemoveAll("test"); err != nil {
		fmt.Println("Ошибка при удалении директории:", err)
		return
	}

	fmt.Println("Директория и её содержимое удалены")
}

Объяснение:

  • filepath.Join создаёт корректный путь для текущей ОС
  • os.MkdirAll создаёт все необходимые директории
  • 0755 устанавливает права доступа (rwxr-xr-x)
  • os.RemoveAll удаляет директорию и всё её содержимое

Ожидаемый вывод:

Директория test/subdir/data создана успешно
Директория и её содержимое удалены

2. Получение информации о файлах

package main

import (
	"fmt"
	"os"
	"path/filepath"
)

func main() {
	// Получаем информацию о файле
	info, err := os.Stat("example.txt")
	if err != nil {
		fmt.Println("Ошибка при получении информации:", err)
		return
	}

	// Выводим информацию о файле
	fmt.Println("Имя файла:", info.Name())
	fmt.Println("Размер:", info.Size(), "байт")
	fmt.Println("Права доступа:", info.Mode())
	fmt.Println("Время изменения:", info.ModTime())
	fmt.Println("Это директория:", info.IsDir())

	// Получаем абсолютный путь
	absPath, err := filepath.Abs("example.txt")
	if err != nil {
		fmt.Println("Ошибка при получении пути:", err)
		return
	}
	fmt.Println("Абсолютный путь:", absPath)
}

Объяснение:

  • os.Stat возвращает информацию о файле
  • info.Name() возвращает имя файла
  • info.Size() возвращает размер в байтах
  • info.Mode() показывает права доступа
  • info.ModTime() возвращает время последнего изменения
  • filepath.Abs получает абсолютный путь к файлу

Ожидаемый вывод:

Имя файла: example.txt
Размер: 1024 байт
Права доступа: -rw-r--r--
Время изменения: 2023-01-01 12:00:00 +0000 UTC
Это директория: false
Абсолютный путь: /home/user/projects/example.txt

Практические задания

Задание 1: Анализатор логов

Создайте программу, которая:

  1. Читает лог-файл построчно
  2. Находит строки, содержащие ошибки
  3. Сохраняет найденные ошибки в отдельный файл
  4. Выводит статистику по ошибкам

Ожидаемый результат:

  • Отфильтрованные ошибки в отдельном файле
  • Статистика по типам ошибок
  • Временные метки ошибок

Задание 2: Менеджер резервных копий

Разработайте программу для:

  1. Создания резервных копий указанных файлов
  2. Хранения нескольких версий файлов
  3. Восстановления файлов из резервной копии
  4. Ведения лога операций

Ожидаемый результат:

  • Резервные копии файлов
  • История изменений
  • Возможность восстановления
  • Лог операций

Задание 3: Система мониторинга

Создайте систему для:

  1. Мониторинга изменений в директории
  2. Отслеживания новых файлов
  3. Проверки размера файлов
  4. Генерации отчётов

Ожидаемый результат:

  • Отслеживание изменений
  • Предупреждения о больших файлах
  • Ежедневные отчёты
  • История изменений

Что дальше?

В следующем уроке мы:

  • Изучим работу с сетью
  • Познакомимся с HTTP-клиентами и серверами
  • Узнаем о протоколах передачи данных
  • Начнём создавать сетевые приложения

🎯 Цель урока: К концу этого урока вы должны уметь:

  • Читать и записывать файлы
  • Работать с директориями
  • Использовать буферизацию
  • Обрабатывать ошибки при работе с файлами

Дженерики в Go: Гибкость и безопасность типов

В этом уроке мы изучим дженерики (Generics) в Go - мощный инструмент, который позволяет создавать гибкий и типобезопасный код. Дженерики были официально представлены в Go 1.18 и стали важной частью языка.

Почему важны дженерики?

Дженерики позволяют:

  • Создавать универсальный код для разных типов
  • Избегать дублирования кода
  • Сохранять безопасность типов
  • Повышать производительность
  • Улучшать читаемость кода

💡 Интересный факт: До появления дженериков в Go разработчики часто использовали интерфейсы interface{} или дублировали код для разных типов, что могло приводить к потере производительности и усложнению поддержки кода.

Основы дженериков

1. Простая дженерик-функция

package main

import "fmt"

// Обобщённая функция для сложения двух элементов
func Add[T any](a, b T) T {
    return a + b
}

func main() {
    // Работа с целыми числами
    sumInt := Add(3, 4)
    fmt.Printf("Сумма целых чисел: %d\n", sumInt)

    // Работа с числами с плавающей точкой
    sumFloat := Add(3.5, 2.1)
    fmt.Printf("Сумма чисел с плавающей точкой: %.2f\n", sumFloat)
}

Объяснение:

  • [T any] объявляет параметр типа T, который может быть любым типом
  • Функция Add принимает два параметра типа T и возвращает значение типа T
  • Компилятор автоматически определяет тип на основе аргументов

Ожидаемый вывод:

Сумма целых чисел: 7
Сумма чисел с плавающей точкой: 5.60

2. Ограничения типов

package main

import "fmt"

// Ограничиваем типы до числовых
func Multiply[T int | float64](a, b T) T {
    return a * b
}

func main() {
    // Умножение целых чисел
    productInt := Multiply(3, 4)
    fmt.Printf("Произведение целых чисел: %d\n", productInt)

    // Умножение чисел с плавающей точкой
    productFloat := Multiply(3.5, 2.5)
    fmt.Printf("Произведение чисел с плавающей точкой: %.2f\n", productFloat)
}

Объяснение:

  • [T int | float64] ограничивает тип T только целыми числами и числами с плавающей точкой
  • Функция Multiply может работать только с этими типами
  • Попытка использовать другие типы вызовет ошибку компиляции

Ожидаемый вывод:

Произведение целых чисел: 12
Произведение чисел с плавающей точкой: 8.75

Дженерики в структурах

1. Универсальный контейнер

package main

import "fmt"

// Универсальная структура для хранения данных
type Container[T any] struct {
    value T
}

// Метод для получения значения
func (c Container[T]) GetValue() T {
    return c.value
}

// Метод для установки значения
func (c *Container[T]) SetValue(value T) {
    c.value = value
}

func main() {
    // Контейнер для целых чисел
    intContainer := Container[int]{value: 42}
    fmt.Printf("Целочисленное значение: %d\n", intContainer.GetValue())

    // Контейнер для строк
    stringContainer := Container[string]{value: "Привет, мир!"}
    fmt.Printf("Строковое значение: %s\n", stringContainer.GetValue())

    // Изменение значения
    intContainer.SetValue(100)
    fmt.Printf("Новое значение: %d\n", intContainer.GetValue())
}

Объяснение:

  • Container[T] - универсальная структура, которая может хранить значение любого типа
  • Методы GetValue и SetValue работают с типом T
  • Структура может быть использована с разными типами данных

Ожидаемый вывод:

Целочисленное значение: 42
Строковое значение: Привет, мир!
Новое значение: 100

2. Пара значений разных типов

package main

import "fmt"

// Структура для хранения пары значений разных типов
type Pair[T, U any] struct {
    First  T
    Second U
}

func main() {
    // Пара целое число и строка
    pair1 := Pair[int, string]{
        First:  42,
        Second: "Ответ",
    }
    fmt.Printf("Пара 1: %d - %s\n", pair1.First, pair1.Second)

    // Пара строка и число с плавающей точкой
    pair2 := Pair[string, float64]{
        First:  "Пи",
        Second: 3.14159,
    }
    fmt.Printf("Пара 2: %s - %.5f\n", pair2.First, pair2.Second)
}

Объяснение:

  • Pair[T, U] принимает два параметра типа
  • Каждое поле структуры может быть своего типа
  • Позволяет создавать типобезопасные пары значений

Ожидаемый вывод:

Пара 1: 42 - Ответ
Пара 2: Пи - 3.14159

Дженерики с интерфейсами

1. Сравнимые типы

package main

import "fmt"

// Интерфейс для типов, поддерживающих сравнение
type Comparable interface {
    int | float64 | string
}

// Функция для нахождения минимального значения
func Min[T Comparable](a, b T) T {
    if a < b {
        return a
    }
    return b
}

func main() {
    // Сравнение целых чисел
    minInt := Min(3, 4)
    fmt.Printf("Минимальное целое число: %d\n", minInt)

    // Сравнение чисел с плавающей точкой
    minFloat := Min(3.5, 2.1)
    fmt.Printf("Минимальное число с плавающей точкой: %.2f\n", minFloat)

    // Сравнение строк
    minString := Min("яблоко", "банан")
    fmt.Printf("Минимальная строка: %s\n", minString)
}

Объяснение:

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

Ожидаемый вывод:

Минимальное целое число: 3
Минимальное число с плавающей точкой: 2.10
Минимальная строка: банан

2. Суммирование элементов

package main

import "fmt"

// Интерфейс для числовых типов
type Number interface {
    int | float64
}

// Функция для суммирования элементов слайса
func Sum[T Number](numbers []T) T {
    var sum T
    for _, num := range numbers {
        sum += num
    }
    return sum
}

func main() {
    // Суммирование целых чисел
    ints := []int{1, 2, 3, 4, 5}
    sumInt := Sum(ints)
    fmt.Printf("Сумма целых чисел: %d\n", sumInt)

    // Суммирование чисел с плавающей точкой
    floats := []float64{1.1, 2.2, 3.3, 4.4, 5.5}
    sumFloat := Sum(floats)
    fmt.Printf("Сумма чисел с плавающей точкой: %.2f\n", sumFloat)
}

Объяснение:

  • Number ограничивает типы до числовых
  • Функция Sum работает со слайсами любого числового типа
  • Обеспечивает типобезопасное суммирование

Ожидаемый вывод:

Сумма целых чисел: 15
Сумма чисел с плавающей точкой: 16.50

Практические задания

Задание 1: Универсальный стек

Создайте структуру стека с дженериками, которая:

  1. Поддерживает любые типы данных
  2. Имеет методы Push и Pop
  3. Предоставляет информацию о размере
  4. Поддерживает проверку на пустоту

Ожидаемый результат:

  • Типобезопасный стек
  • Эффективные операции
  • Чистый интерфейс

Задание 2: Фильтрация слайсов

Реализуйте функцию для фильтрации слайсов:

  1. Принимает слайс любого типа
  2. Принимает функцию-предикат
  3. Возвращает новый слайс с отфильтрованными элементами
  4. Сохраняет порядок элементов

Ожидаемый результат:

  • Универсальная функция фильтрации
  • Гибкие критерии фильтрации
  • Эффективная работа

Задание 3: Карта с дженериками

Создайте структуру карты с дженериками:

  1. Поддерживает любые типы ключей и значений
  2. Имеет методы для добавления и получения элементов
  3. Поддерживает удаление элементов
  4. Предоставляет информацию о размере

Ожидаемый результат:

  • Типобезопасная карта
  • Эффективные операции
  • Гибкое использование

Что дальше?

В следующем уроке мы:

  • Изучим работу с горутинами
  • Познакомимся с каналами
  • Узнаем о синхронизации
  • Начнём писать параллельные программы

🎯 Цель урока: К концу этого урока вы должны уметь:

  • Создавать дженерик-функции
  • Использовать ограничения типов
  • Работать с дженерик-структурами
  • Применять дженерики с интерфейсами

Горутины и каналы в Go: Параллельное программирование

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

Почему важны горутины и каналы?

Горутины и каналы позволяют:

  • Выполнять задачи параллельно
  • Эффективно использовать ресурсы системы
  • Создавать масштабируемые приложения
  • Упрощать синхронизацию
  • Писать чистый и понятный код

💡 Интересный факт: Горутины легче потоков операционной системы в 100-1000 раз, что позволяет создавать миллионы горутин без значительных накладных расходов.

Основы горутин

1. Простая горутина

package main

import (
    "fmt"
    "time"
)

// Функция, которая будет выполняться в горутине
func printNumbers() {
    for i := 1; i <= 5; i++ {
        fmt.Printf("Число: %d\n", i)
        time.Sleep(time.Millisecond * 500)
    }
}

func main() {
    // Запуск горутины
    go printNumbers()

    // Основной поток продолжает выполнение
    fmt.Println("Основной поток продолжает работу...")
    
    // Ждём, чтобы горутина успела выполниться
    time.Sleep(time.Second * 3)
}

Объяснение:

  • Ключевое слово go запускает функцию в отдельной горутине
  • Горутина выполняется параллельно с основным потоком
  • time.Sleep используется для демонстрации параллельного выполнения

Ожидаемый вывод:

Основной поток продолжает работу...
Число: 1
Число: 2
Число: 3
Число: 4
Число: 5

2. Множественные горутины

package main

import (
    "fmt"
    "time"
)

func worker(id int) {
    fmt.Printf("Работник %d начал работу\n", id)
    time.Sleep(time.Second)
    fmt.Printf("Работник %d закончил работу\n", id)
}

func main() {
    // Запуск нескольких горутин
    for i := 1; i <= 3; i++ {
        go worker(i)
    }

    // Ждём завершения всех горутин
    time.Sleep(time.Second * 2)
}

Объяснение:

  • Каждая горутина выполняет свою копию функции worker
  • Горутины выполняются параллельно
  • Порядок вывода может варьироваться

Ожидаемый вывод:

Работник 1 начал работу
Работник 2 начал работу
Работник 3 начал работу
Работник 1 закончил работу
Работник 2 закончил работу
Работник 3 закончил работу

Основы каналов

1. Простой канал

package main

import "fmt"

func main() {
    // Создание канала для передачи целых чисел
    ch := make(chan int)

    // Горутина для отправки данных
    go func() {
        ch <- 42 // Отправка значения в канал
    }()

    // Получение данных из канала
    value := <-ch
    fmt.Printf("Получено значение: %d\n", value)
}

Объяснение:

  • make(chan int) создаёт канал для целых чисел
  • Оператор <- используется для отправки и получения данных
  • Канал блокирует выполнение до получения данных

Ожидаемый вывод:

Получено значение: 42

2. Буферизованный канал

package main

import "fmt"

func main() {
    // Создание буферизованного канала с ёмкостью 3
    ch := make(chan int, 3)

    // Отправка нескольких значений
    ch <- 1
    ch <- 2
    ch <- 3

    // Получение значений
    fmt.Printf("Получено: %d\n", <-ch)
    fmt.Printf("Получено: %d\n", <-ch)
    fmt.Printf("Получено: %d\n", <-ch)
}

Объяснение:

  • Буферизованный канал может хранить несколько значений
  • Отправка не блокируется, пока буфер не заполнен
  • Получение происходит в порядке FIFO

Ожидаемый вывод:

Получено: 1
Получено: 2
Получено: 3

Продвинутые техники

1. Закрытие каналов

package main

import "fmt"

func main() {
    ch := make(chan int)

    // Горутина для отправки данных
    go func() {
        for i := 1; i <= 5; i++ {
            ch <- i
        }
        close(ch) // Закрытие канала
    }()

    // Чтение данных до закрытия канала
    for value := range ch {
        fmt.Printf("Получено: %d\n", value)
    }
}

Объяснение:

  • close(ch) закрывает канал
  • range автоматически завершается при закрытии канала
  • Закрытие канала сигнализирует о завершении отправки

Ожидаемый вывод:

Получено: 1
Получено: 2
Получено: 3
Получено: 4
Получено: 5

2. Select для множественных каналов

package main

import (
    "fmt"
    "time"
)

func main() {
    ch1 := make(chan string)
    ch2 := make(chan string)

    // Горутина для первого канала
    go func() {
        time.Sleep(time.Second)
        ch1 <- "Сообщение из первого канала"
    }()

    // Горутина для второго канала
    go func() {
        time.Sleep(time.Second * 2)
        ch2 <- "Сообщение из второго канала"
    }()

    // Ожидание сообщений из любого канала
    select {
    case msg1 := <-ch1:
        fmt.Println(msg1)
    case msg2 := <-ch2:
        fmt.Println(msg2)
    }
}

Объяснение:

  • select позволяет ждать сообщений из нескольких каналов
  • Выполняется первый доступный case
  • Полезно для таймаутов и обработки множественных каналов

Ожидаемый вывод:

Сообщение из первого канала

Практические задания

Задание 1: Параллельная обработка данных

Создайте программу, которая:

  1. Читает данные из файла
  2. Обрабатывает каждую строку в отдельной горутине
  3. Собирает результаты через канал
  4. Выводит статистику обработки

Ожидаемый результат:

  • Эффективная обработка данных
  • Корректная синхронизация
  • Чёткая структура кода

Задание 2: Пул горутин

Реализуйте пул горутин для:

  1. Обработки задач из очереди
  2. Ограничения количества одновременно работающих горутин
  3. Сбора результатов
  4. Обработки ошибок

Ожидаемый результат:

  • Контролируемое использование ресурсов
  • Масштабируемость
  • Надёжная обработка ошибок

Задание 3: Чат-сервер

Создайте простой чат-сервер, который:

  1. Принимает подключения от клиентов
  2. Передаёт сообщения между клиентами
  3. Управляет подключениями
  4. Обрабатывает отключения

Ожидаемый результат:

  • Многопользовательский чат
  • Устойчивость к ошибкам
  • Эффективная работа с сетью

Что дальше?

В следующем уроке мы:

  • Изучим более сложные паттерны синхронизации
  • Познакомимся с контекстами
  • Узнаем о работе с таймаутами
  • Начнём писать распределённые системы

🎯 Цель урока: К концу этого урока вы должны уметь:

  • Создавать и управлять горутинами
  • Работать с каналами
  • Использовать select для множественных каналов
  • Писать параллельные программы

Погружение в глубину

Метапрограммирование и рефлексия в Go: Мощные инструменты для профессионалов

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

Почему важны метапрограммирование и рефлексия?

Метапрограммирование и рефлексия позволяют:

  • Создавать универсальные решения
  • Работать с типами во время выполнения
  • Реализовывать сложные паттерны проектирования
  • Строить гибкие API
  • Автоматизировать рутинные задачи

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

Основы рефлексии

1. Работа с типами

package main

import (
    "fmt"
    "reflect"
)

type User struct {
    ID        int
    Name      string
    Email     string
    IsActive  bool
}

func main() {
    // Создаём экземпляр структуры
    user := User{
        ID:       1,
        Name:     "Иван",
        Email:    "ivan@example.com",
        IsActive: true,
    }

    // Получаем тип структуры
    t := reflect.TypeOf(user)
    fmt.Printf("Тип: %s\n", t.Name())

    // Итерируемся по полям структуры
    for i := 0; i < t.NumField(); i++ {
        field := t.Field(i)
        fmt.Printf("Поле %d: %s (%s)\n", i, field.Name, field.Type)
    }
}

Объяснение:

  • reflect.TypeOf получает тип значения
  • NumField() возвращает количество полей
  • Field(i) получает информацию о поле

Ожидаемый вывод:

Тип: User
Поле 0: ID (int)
Поле 1: Name (string)
Поле 2: Email (string)
Поле 3: IsActive (bool)

2. Работа со значениями

package main

import (
    "fmt"
    "reflect"
)

func main() {
    // Создаём слайс
    numbers := []int{1, 2, 3, 4, 5}

    // Получаем значение
    v := reflect.ValueOf(numbers)
    fmt.Printf("Тип: %s\n", v.Type())
    fmt.Printf("Длина: %d\n", v.Len())

    // Изменяем значение
    if v.Kind() == reflect.Slice {
        // Создаём новый слайс
        newSlice := reflect.MakeSlice(v.Type(), 0, v.Cap())
        
        // Добавляем элементы в обратном порядке
        for i := v.Len() - 1; i >= 0; i-- {
            newSlice = reflect.Append(newSlice, v.Index(i))
        }
        
        // Выводим результат
        fmt.Printf("Обратный порядок: %v\n", newSlice.Interface())
    }
}

Объяснение:

  • reflect.ValueOf получает значение
  • Kind() определяет базовый тип
  • MakeSlice создаёт новый слайс
  • Append добавляет элементы

Ожидаемый вывод:

Тип: []int
Длина: 5
Обратный порядок: [5 4 3 2 1]

Продвинутые техники

1. Динамическое создание структур

package main

import (
    "fmt"
    "reflect"
)

func createStruct(fields map[string]interface{}) interface{} {
    // Создаём слайс полей
    structFields := make([]reflect.StructField, 0, len(fields))
    
    for name, value := range fields {
        structFields = append(structFields, reflect.StructField{
            Name: name,
            Type: reflect.TypeOf(value),
        })
    }
    
    // Создаём тип структуры
    structType := reflect.StructOf(structFields)
    
    // Создаём экземпляр структуры
    structValue := reflect.New(structType).Elem()
    
    // Заполняем поля
    for name, value := range fields {
        field := structValue.FieldByName(name)
        if field.IsValid() && field.CanSet() {
            field.Set(reflect.ValueOf(value))
        }
    }
    
    return structValue.Interface()
}

func main() {
    // Создаём структуру динамически
    dynamicStruct := createStruct(map[string]interface{}{
        "ID":    1,
        "Name":  "Иван",
        "Score": 95.5,
    })
    
    // Выводим результат
    fmt.Printf("Динамическая структура: %+v\n", dynamicStruct)
}

Объяснение:

  • StructField определяет поле структуры
  • StructOf создаёт тип структуры
  • New создаёт указатель на структуру
  • Elem получает значение

Ожидаемый вывод:

Динамическая структура: {ID:1 Name:Иван Score:95.5}

2. Вызов методов через рефлексию

package main

import (
    "fmt"
    "reflect"
)

type Calculator struct {
    result float64
}

func (c *Calculator) Add(x float64) {
    c.result += x
}

func (c *Calculator) Subtract(x float64) {
    c.result -= x
}

func (c *Calculator) GetResult() float64 {
    return c.result
}

func main() {
    // Создаём калькулятор
    calc := &Calculator{}
    
    // Получаем тип и значение
    t := reflect.TypeOf(calc)
    v := reflect.ValueOf(calc)
    
    // Вызываем методы через рефлексию
    methods := []struct {
        name string
        args []reflect.Value
    }{
        {"Add", []reflect.Value{reflect.ValueOf(10.0)}},
        {"Add", []reflect.Value{reflect.ValueOf(5.0)}},
        {"Subtract", []reflect.Value{reflect.ValueOf(3.0)}},
    }
    
    for _, m := range methods {
        method := v.MethodByName(m.name)
        if method.IsValid() {
            method.Call(m.args)
        }
    }
    
    // Получаем результат
    result := v.MethodByName("GetResult").Call(nil)[0].Float()
    fmt.Printf("Результат: %.2f\n", result)
}

Объяснение:

  • MethodByName получает метод по имени
  • Call вызывает метод с аргументами
  • Float преобразует результат

Ожидаемый вывод:

Результат: 12.00

Практические задания

Задание 1: Универсальный сериализатор

Создайте функцию, которая:

  1. Принимает любой тип данных
  2. Сериализует его в JSON
  3. Поддерживает пользовательские теги
  4. Обрабатывает вложенные структуры

Ожидаемый результат:

  • Гибкий сериализатор
  • Поддержка кастомных форматов
  • Эффективная работа

Задание 2: Динамический валидатор

Реализуйте систему валидации, которая:

  1. Принимает структуру и правила
  2. Проверяет поля на соответствие правилам
  3. Возвращает список ошибок
  4. Поддерживает пользовательские валидаторы

Ожидаемый результат:

  • Универсальная валидация
  • Гибкие правила
  • Чёткие сообщения об ошибках

Задание 3: ORM-подобный слой

Создайте базовый ORM-слой, который:

  1. Работает с любой структурой
  2. Генерирует SQL-запросы
  3. Маппит результаты в структуры
  4. Поддерживает отношения

Ожидаемый результат:

  • Типобезопасный доступ к БД
  • Гибкий маппинг
  • Эффективные запросы

Что дальше?

В следующей главе мы:

  • Изучим продвинутые паттерны проектирования
  • Познакомимся с оптимизацией производительности
  • Узнаем о работе с памятью
  • Начнём писать высоконагруженные системы

🎯 Цель главы: К концу этой главы вы должны уметь:

  • Использовать рефлексию для решения сложных задач
  • Создавать динамические структуры
  • Работать с типами во время выполнения
  • Писать метапрограммы

Slice, map глубже

Давайте углубимся в темы slice и map в Go, чтобы лучше понять их работу, внутренние механизмы и применимость в реальных проектах.

Углубляемся в Slice

Внутреннее устройство Slice

Под капотом slice хранит три значения:

  1. Указатель на базовый массив: Срез не хранит данные напрямую, а лишь содержит указатель на базовый массив, который фактически хранит элементы.
  2. Длина (len): Количество элементов в срезе.
  3. Ёмкость (cap): Количество элементов, которое может хранить базовый массив, начиная с первого элемента среза. Ёмкость всегда больше или равна длине.
struct {
	array *[]T
	length int
	capacity int
}

Пример:

arr := [5]int{1, 2, 3, 4, 5}
slice := arr[1:4] // slice: [2, 3, 4], len(slice) = 3, cap(slice) = 4

В этом примере срез начинается с индекса 1 массива arr, поэтому он может "видеть" оставшиеся 4 элемента массива. Однако длина среза — это только 3 элемента: [2, 3, 4].

Расширение Slice

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

Пример:

slice := []int{1, 2, 3}
slice = append(slice, 4, 5, 6)
fmt.Println(slice) // [1, 2, 3, 4, 5, 6]

Если исходная ёмкость была меньше 6, Go создаст новый массив и скопирует туда старые элементы.

Работа со срезами

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

Пример:

arr := [5]int{1, 2, 3, 4, 5}
slice1 := arr[1:4] // slice1: [2, 3, 4]
slice2 := arr[2:5] // slice2: [3, 4, 5]
slice1[1] = 10     // Изменяем slice1
fmt.Println(arr)    // [1, 2, 10, 4, 5] - изменился массив
fmt.Println(slice2) // [10, 4, 5] - изменился и slice2

Чтобы избежать таких эффектов, можно копировать данные в новый срез с помощью функции copy.

Zero value Slice

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

var slice []int
fmt.Println(slice == nil) // true
slice = append(slice, 1)
fmt.Println(slice) // [1]

Особенность slice при передаче

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

Рассмотрим пример:

func main() {
    aqua := make([]int, 0, 2)
    appendSlice(aqua)
    fmt.Println(aqua) // Ожидаем [1], но будет []
}

func appendSlice(aqua []int) {
    aqua = append(aqua, 1)
}

В этом коде мы создаём слайс aqua и передаём его в функцию appendSlice, которая должна была добавить в него элемент. Однако, когда мы печатаем aqua в main, результат остаётся пустым.

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

Хотя слайс и содержит указатель на базовый массив, важно помнить, что сам слайс (структура, содержащая указатель, длину и ёмкость) передаётся в функцию по значению. Это означает, что в функции appendSlice мы работаем с копией слайса. Изменения, сделанные в этой копии, не отражаются на оригинальной переменной aqua в main.

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

Как это исправить?

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

func main() {
    aqua := make([]int, 0, 2)
    appendSlice(&aqua)
    fmt.Println(aqua) // Теперь будет [1]
}

func appendSlice(aqua *[]int) {
    *aqua = append(*aqua, 1)
}

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

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



Углубляемся в Map

Внутреннее устройство Map

Карта в Go представляет собой хеш-таблицу. Для каждого ключа вычисляется хеш, и на основе этого хеша определяется, в какое "ведро" (bucket) поместить этот ключ. Если несколько ключей имеют одинаковый хеш, они помещаются в одно ведро (так называемые коллизии), и внутри этого ведра Go использует линейный поиск для нахождения нужного ключа.

Эффективность карты основана на том, что поиск по хеш-таблице обычно выполняется за O(1) — константное время, хотя в случае коллизий время может увеличиться (Примерно будет O(N)).

type hmap struct {
  // ...more code
  count       int
  B           uint 8
  noverflow   uint 16
  hash0       uint 32
  buckets     unsafe.Pointer
  oldbuckets  unsafe.Pointer
}

Инициализация карты

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

Пример:

m := make(map[string]int, 100) // Создаём карту с начальной ёмкостью на 100 элементов

Удаление элементов из карты

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

delete(m, "apple") // Удалим элемент с ключом "apple"

Обход элементов карты

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

m := map[string]int{"apple": 1, "banana": 2}
for key, value := range m {
    fmt.Println(key, value)
}

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

Пример сортировки ключей карты:

keys := make([]string, 0, len(m))
for key := range m {
    keys = append(keys, key)
}
sort.Strings(keys) // Сортируем ключи
for _, key := range keys {
    fmt.Println(key, m[key])
}

Карты как нулевые значения

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

var m map[string]int
fmt.Println(m == nil) // true
// m["apple"] = 5 // Это вызовет панику!

Чтобы избежать этого, всегда нужно инициализировать карту перед использованием.

Ограничения ключей и значений

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

Работа с мапами (карта) в Go может быть весьма удобной, однако существуют несколько особенностей, на которые стоит обратить внимание, чтобы избежать распространённых ошибок. В этой статье мы рассмотрим основные аспекты работы с мапами и их эвакуацию.

Эвакуация данных в мапах

Эвакуация - это процесс когда map переносит свои значения из одной области памяти в другую. Это происходит из-за того что число значений в каждом отдельном bucket максимально равно 8.

В тот момент времени, когда среднее количество значений в bucket составляет 6.5, go понимает, что размер map не удовлетворяет необходимому. Начинается процесс расширения map.

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

Заключение

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


Дополнительные материалы

Глубокий урок: "Scheduler в Go"

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

Что такое планировщик?

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

Планировщик Go использует M:N модель, что означает, что M горутин могут выполняться на N системных потоках. Это позволяет эффективно использовать ресурсы и минимизировать накладные расходы на создание и переключение потоков.

Архитектура планировщика Go

Планировщик в Go состоит из трех основных компонентов:

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

  2. M (Machine): Это системный поток, который может выполнять горутины. Каждая M соответствует потоку операционной системы, который может быть использован для выполнения G.

  3. P (Processor): Это логический процессор, который управляет выполнением горутин. Каждый P содержит очередь горутин, ожидающих выполнения, и может быть связан с одной M.

Модель M:N

Модель M:N описывает, как планировщик управляет горутинами. Например:

  • M — это системные потоки, которые могут выполнять горутины. Количество M зависит от конфигурации системы и доступных ресурсов.

  • N — это количество горутин, которые могут быть запущены одновременно. Это число может быть очень большим, и на практике может достигать миллионов.

Планировщик распределяет горутины G по потокам M, обеспечивая высокую производительность и низкие затраты на переключение контекста.

Алгоритм работы планировщика

  1. Запуск горутины: Когда вы запускаете новую горутину с помощью ключевого слова go, она помещается в очередь P, связанного с текущим потоком M.

  2. Выполнение горутины: Если P имеет свободные ресурсы, планировщик берет горутину из очереди и начинает ее выполнение на системном потоке M.

  3. Блокировка: Если горутина блокируется (например, при выполнении операции ввода-вывода), планировщик временно приостанавливает ее и выбирает другую горутину для выполнения. Блокировка может происходить также при ожидании данных из канала или при использовании мьютексов.

  4. Переключение контекста: Когда горутина завершает выполнение или блокируется, планировщик переключает выполнение на другую горутину, находящуюся в очереди.

  5. Управление ресурсами: Планировщик автоматически управляет количеством доступных M и P, подстраиваясь под текущее состояние системы и нагрузку.

Примеры использования планировщика

Пример 1: Параллельные вычисления

package main

import (
    "fmt"
    "sync"
)

func main() {
    var wg sync.WaitGroup
    jobs := []int{1, 2, 3, 4, 5}

    for _, job := range jobs {
        wg.Add(1)
        go func(n int) {
            defer wg.Done()
            fmt.Printf("Processing job %d\n", n)
        }(job)
    }

    wg.Wait()
}

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

Пример 2: Блокирующие операции

package main

import (
    "fmt"
    "time"
)

func main() {
    ch := make(chan string)

    go func() {
        time.Sleep(2 * time.Second)
        ch <- "Message from goroutine"
    }()

    fmt.Println("Waiting for message...")
    msg := <-ch // Ожидание сообщения из канала
    fmt.Println(msg)
}

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

Профилирование работы планировщика

Для анализа производительности вашего приложения и работы планировщика вы можете использовать инструменты профилирования, такие как pprof и runtime/trace. Эти инструменты помогут выявить узкие места и оптимизировать использование ресурсов.

Пример использования pprof:

go run main.go
go tool pprof -http=:8080 cpu.prof

Это позволит вам визуализировать использование CPU и взаимодействие горутин.

Заключение

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

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

Продвинутые паттерны проектирования и архитектура в Go

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

Почему важны паттерны и архитектура?

Правильно выбранные паттерны и архитектура позволяют:

  • Создавать гибкие и расширяемые системы
  • Упрощать поддержку кода
  • Повышать тестируемость
  • Улучшать производительность
  • Упрощать масштабирование

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

Основные паттерны

1. Фабрика с опциями

package main

import (
    "fmt"
    "time"
)

// Опции для конфигурации сервера
type ServerOptions struct {
    Host        string
    Port        int
    Timeout     time.Duration
    MaxConn     int
    EnableTLS   bool
}

// Функция-опция для изменения конфигурации
type Option func(*ServerOptions)

// Конструкторы опций
func WithHost(host string) Option {
    return func(o *ServerOptions) {
        o.Host = host
    }
}

func WithPort(port int) Option {
    return func(o *ServerOptions) {
        o.Port = port
    }
}

func WithTimeout(timeout time.Duration) Option {
    return func(o *ServerOptions) {
        o.Timeout = timeout
    }
}

// Создание сервера с опциями
func NewServer(opts ...Option) *ServerOptions {
    // Значения по умолчанию
    server := &ServerOptions{
        Host:      "localhost",
        Port:      8080,
        Timeout:   time.Second * 30,
        MaxConn:   100,
        EnableTLS: false,
    }

    // Применяем опции
    for _, opt := range opts {
        opt(server)
    }

    return server
}

func main() {
    // Создаём сервер с кастомными опциями
    server := NewServer(
        WithHost("api.example.com"),
        WithPort(443),
        WithTimeout(time.Second * 60),
    )

    fmt.Printf("Сервер: %+v\n", server)
}

Объяснение:

  • Паттерн "Фабрика с опциями" позволяет гибко конфигурировать объекты
  • Каждая опция - это функция, модифицирующая конфигурацию
  • Значения по умолчанию обеспечивают работоспособность без явной конфигурации

Ожидаемый вывод:

Сервер: {Host:api.example.com Port:443 Timeout:1m0s MaxConn:100 EnableTLS:false}

2. Middleware

package main

import (
    "fmt"
    "net/http"
    "time"
)

// Тип для middleware
type Middleware func(http.HandlerFunc) http.HandlerFunc

// Логирование запросов
func Logging() Middleware {
    return func(next http.HandlerFunc) http.HandlerFunc {
        return func(w http.ResponseWriter, r *http.Request) {
            start := time.Now()
            defer func() {
                fmt.Printf("Запрос: %s %s, время: %v\n",
                    r.Method, r.URL.Path, time.Since(start))
            }()
            next(w, r)
        }
    }
}

// Проверка аутентификации
func Auth() Middleware {
    return func(next http.HandlerFunc) http.HandlerFunc {
        return func(w http.ResponseWriter, r *http.Request) {
            token := r.Header.Get("Authorization")
            if token == "" {
                http.Error(w, "Unauthorized", http.StatusUnauthorized)
                return
            }
            next(w, r)
        }
    }
}

// Применение middleware
func Chain(f http.HandlerFunc, middlewares ...Middleware) http.HandlerFunc {
    for i := len(middlewares) - 1; i >= 0; i-- {
        f = middlewares[i](f)
    }
    return f
}

// Обработчик
func handler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Hello, World!")
}

func main() {
    // Создаём обработчик с middleware
    http.HandleFunc("/", Chain(handler, Logging(), Auth()))
    
    // Запускаем сервер
    fmt.Println("Сервер запущен на :8080")
    http.ListenAndServe(":8080", nil)
}

Объяснение:

  • Middleware позволяет добавлять функциональность к обработчикам
  • Каждый middleware - это функция, принимающая и возвращающая HandlerFunc
  • Chain применяет middleware в правильном порядке

Архитектурные паттерны

1. Clean Architecture

package main

import (
    "fmt"
    "time"
)

// Domain layer
type User struct {
    ID        int
    Name      string
    Email     string
    CreatedAt time.Time
}

// Repository interface
type UserRepository interface {
    FindByID(id int) (*User, error)
    Save(user *User) error
}

// Use case
type UserService struct {
    repo UserRepository
}

func NewUserService(repo UserRepository) *UserService {
    return &UserService{repo: repo}
}

func (s *UserService) GetUser(id int) (*User, error) {
    return s.repo.FindByID(id)
}

// Infrastructure layer
type InMemoryUserRepository struct {
    users map[int]*User
}

func NewInMemoryUserRepository() *InMemoryUserRepository {
    return &InMemoryUserRepository{
        users: make(map[int]*User),
    }
}

func (r *InMemoryUserRepository) FindByID(id int) (*User, error) {
    user, exists := r.users[id]
    if !exists {
        return nil, fmt.Errorf("user not found")
    }
    return user, nil
}

func (r *InMemoryUserRepository) Save(user *User) error {
    r.users[user.ID] = user
    return nil
}

func main() {
    // Создаём зависимости
    repo := NewInMemoryUserRepository()
    service := NewUserService(repo)

    // Создаём тестового пользователя
    user := &User{
        ID:        1,
        Name:      "Иван",
        Email:     "ivan@example.com",
        CreatedAt: time.Now(),
    }
    repo.Save(user)

    // Получаем пользователя
    foundUser, err := service.GetUser(1)
    if err != nil {
        fmt.Printf("Ошибка: %v\n", err)
        return
    }
    fmt.Printf("Найден пользователь: %+v\n", foundUser)
}

Объяснение:

  • Clean Architecture разделяет код на слои
  • Каждый слой зависит только от внутренних слоёв
  • Интерфейсы определяют контракты между слоями
  • Легко тестировать и модифицировать

Ожидаемый вывод:

Найден пользователь: &{ID:1 Name:Иван Email:ivan@example.com CreatedAt:2024-03-21 10:00:00 +0000 UTC}

2. CQRS (Command Query Responsibility Segregation)

package main

import (
    "fmt"
    "sync"
)

// Команды
type CreateUserCommand struct {
    Name  string
    Email string
}

type UpdateUserCommand struct {
    ID    int
    Name  string
    Email string
}

// Запросы
type GetUserQuery struct {
    ID int
}

// Модель
type User struct {
    ID    int
    Name  string
    Email string
}

// Command Handler
type CommandHandler struct {
    mu    sync.RWMutex
    users map[int]*User
}

func NewCommandHandler() *CommandHandler {
    return &CommandHandler{
        users: make(map[int]*User),
    }
}

func (h *CommandHandler) HandleCreate(cmd CreateUserCommand) (*User, error) {
    h.mu.Lock()
    defer h.mu.Unlock()

    id := len(h.users) + 1
    user := &User{
        ID:    id,
        Name:  cmd.Name,
        Email: cmd.Email,
    }
    h.users[id] = user
    return user, nil
}

// Query Handler
type QueryHandler struct {
    commandHandler *CommandHandler
}

func NewQueryHandler(ch *CommandHandler) *QueryHandler {
    return &QueryHandler{commandHandler: ch}
}

func (h *QueryHandler) HandleGet(query GetUserQuery) (*User, error) {
    h.commandHandler.mu.RLock()
    defer h.commandHandler.mu.RUnlock()

    user, exists := h.commandHandler.users[query.ID]
    if !exists {
        return nil, fmt.Errorf("user not found")
    }
    return user, nil
}

func main() {
    // Создаём обработчики
    cmdHandler := NewCommandHandler()
    queryHandler := NewQueryHandler(cmdHandler)

    // Создаём пользователя
    user, err := cmdHandler.HandleCreate(CreateUserCommand{
        Name:  "Иван",
        Email: "ivan@example.com",
    })
    if err != nil {
        fmt.Printf("Ошибка: %v\n", err)
        return
    }

    // Получаем пользователя
    foundUser, err := queryHandler.HandleGet(GetUserQuery{ID: user.ID})
    if err != nil {
        fmt.Printf("Ошибка: %v\n", err)
        return
    }
    fmt.Printf("Найден пользователь: %+v\n", foundUser)
}

Объяснение:

  • CQRS разделяет операции на команды и запросы
  • Команды изменяют состояние
  • Запросы только читают данные
  • Разделение упрощает масштабирование

Ожидаемый вывод:

Найден пользователь: &{ID:1 Name:Иван Email:ivan@example.com}

Практические задания

Задание 1: Микросервисная архитектура

Создайте базовую структуру микросервиса, которая:

  1. Использует Clean Architecture
  2. Поддерживает CQRS
  3. Включает middleware для логирования и мониторинга
  4. Имеет конфигурацию через опции

Ожидаемый результат:

  • Масштабируемая архитектура
  • Чёткое разделение ответственности
  • Гибкая конфигурация
  • Лёгкость тестирования

Задание 2: Event Sourcing

Реализуйте систему с Event Sourcing, которая:

  1. Хранит все изменения как события
  2. Восстанавливает состояние из событий
  3. Поддерживает проекции для быстрого чтения
  4. Обеспечивает атомарность операций

Ожидаемый результат:

  • Надёжное хранение данных
  • Возможность аудита
  • Высокая производительность
  • Масштабируемость

Задание 3: Шаблон Repository

Создайте универсальный репозиторий, который:

  1. Работает с любыми типами данных
  2. Поддерживает CRUD операции
  3. Включает кэширование
  4. Обеспечивает транзакционность

Ожидаемый результат:

  • Универсальный доступ к данным
  • Эффективное кэширование
  • Надёжные транзакции
  • Чистый интерфейс

🎯 Цель главы: К концу этой главы вы должны уметь:

  • Применять продвинутые паттерны проектирования
  • Создавать масштабируемые архитектуры
  • Разделять ответственность в коде
  • Писать поддерживаемые системы

Небезопасный Go: Работа с пакетом unsafe

В этой главе мы изучим пакет unsafe в Go - мощный инструмент для низкоуровневого программирования, который позволяет обходить некоторые ограничения системы типов. Однако с большой силой приходит большая ответственность: неправильное использование unsafe может привести к нестабильности программы и трудноуловимым ошибкам.

Почему нужен пакет unsafe?

Пакет unsafe необходим для:

  • Работы с необработанной памятью
  • Преобразования типов без проверки
  • Оптимизации производительности
  • Взаимодействия с C-кодом
  • Реализации низкоуровневых структур данных

⚠️ Важно: Использование unsafe нарушает гарантии безопасности типов Go и может привести к неопределённому поведению. Используйте его только когда это действительно необходимо.

Основные возможности unsafe

1. Указатели и арифметика указателей

package main

import (
    "fmt"
    "unsafe"
)

func main() {
    // Создаём массив
    arr := [4]int{10, 20, 30, 40}
    
    // Получаем указатель на первый элемент
    ptr := unsafe.Pointer(&arr[0])
    
    // Выполняем арифметику указателей
    for i := 0; i < 4; i++ {
        // Получаем указатель на i-й элемент
        elemPtr := unsafe.Pointer(uintptr(ptr) + uintptr(i)*unsafe.Sizeof(arr[0]))
        
        // Преобразуем обратно в *int
        elem := (*int)(elemPtr)
        
        fmt.Printf("Элемент %d: %d\n", i, *elem)
    }
}

Объяснение:

  • unsafe.Pointer позволяет преобразовывать указатели между типами
  • uintptr используется для арифметики указателей
  • unsafe.Sizeof возвращает размер типа в байтах
  • Арифметика указателей требует осторожности

2. Преобразование типов

package main

import (
    "fmt"
    "unsafe"
)

type MyStruct struct {
    a int
    b float64
    c string
}

func main() {
    // Создаём структуру
    s := MyStruct{
        a: 42,
        b: 3.14,
        c: "hello",
    }
    
    // Получаем указатель на структуру
    ptr := unsafe.Pointer(&s)
    
    // Преобразуем в массив байт
    bytes := (*[unsafe.Sizeof(s)]byte)(ptr)
    
    // Выводим байты
    fmt.Printf("Байты структуры: %v\n", bytes[:])
    
    // Изменяем значение через указатель
    intPtr := (*int)(unsafe.Pointer(&s.a))
    *intPtr = 100
    
    fmt.Printf("Изменённое значение: %d\n", s.a)
}

Объяснение:

  • Можно преобразовывать указатели между любыми типами
  • Можно получать доступ к полям структуры через указатели
  • Нужно быть осторожным с выравниванием и размерами типов

3. Работа с необработанной памятью

package main

import (
    "fmt"
    "unsafe"
)

func main() {
    // Выделяем память
    size := unsafe.Sizeof(int(0))
    ptr := unsafe.Pointer(new(int))
    
    // Записываем значение
    *(*int)(ptr) = 42
    
    // Читаем значение
    value := *(*int)(ptr)
    fmt.Printf("Значение: %d\n", value)
    
    // Освобождаем память (в реальном коде нужно использовать runtime.GC)
    ptr = nil
}

Объяснение:

  • Можно выделять и освобождать память
  • Нужно следить за утечками памяти
  • Важно правильно управлять жизненным циклом объектов

Продвинутые техники

1. Структуры с переменным размером

package main

import (
    "fmt"
    "unsafe"
)

type DynamicArray struct {
    len  int
    cap  int
    data [1]int // Начальный элемент
}

func NewDynamicArray(capacity int) *DynamicArray {
    // Вычисляем размер структуры
    size := unsafe.Sizeof(DynamicArray{}) + 
            unsafe.Sizeof(int(0)) * uintptr(capacity-1)
    
    // Выделяем память
    ptr := unsafe.Pointer(new([1]byte))
    arr := (*DynamicArray)(ptr)
    
    // Инициализируем поля
    arr.len = 0
    arr.cap = capacity
    
    return arr
}

func (a *DynamicArray) Append(value int) {
    if a.len >= a.cap {
        panic("массив переполнен")
    }
    
    // Получаем указатель на элемент
    elemPtr := unsafe.Pointer(uintptr(unsafe.Pointer(&a.data[0])) + 
                            uintptr(a.len)*unsafe.Sizeof(int(0)))
    
    // Записываем значение
    *(*int)(elemPtr) = value
    a.len++
}

func main() {
    arr := NewDynamicArray(10)
    
    for i := 0; i < 5; i++ {
        arr.Append(i * 10)
    }
    
    fmt.Printf("Длина: %d, Ёмкость: %d\n", arr.len, arr.cap)
}

Объяснение:

  • Можно создавать структуры с переменным размером
  • Нужно правильно вычислять размеры и смещения
  • Важно следить за границами массива

2. Взаимодействие с C-кодом

package main

/*
#include <stdlib.h>
#include <string.h>

void* allocate_memory(size_t size) {
    return malloc(size);
}

void free_memory(void* ptr) {
    free(ptr);
}
*/
import "C"
import (
    "fmt"
    "unsafe"
)

func main() {
    // Выделяем память через C
    size := C.size_t(100)
    ptr := C.allocate_memory(size)
    defer C.free_memory(ptr)
    
    // Преобразуем указатель
    goPtr := unsafe.Pointer(ptr)
    
    // Используем память
    bytes := (*[100]byte)(goPtr)
    for i := 0; i < 100; i++ {
        bytes[i] = byte(i)
    }
    
    fmt.Printf("Первый байт: %d\n", bytes[0])
}

Объяснение:

  • Можно работать с C-функциями
  • Нужно правильно управлять памятью
  • Важно следить за типами указателей

Практические задания

Задание 1: Собственная реализация слайса

Создайте собственную реализацию слайса, которая:

  1. Использует unsafe для работы с памятью
  2. Поддерживает динамическое расширение
  3. Обеспечивает безопасный доступ
  4. Оптимизирует производительность

Ожидаемый результат:

  • Эффективная работа с памятью
  • Безопасный доступ к элементам
  • Хорошая производительность
  • Минимальные накладные расходы

Задание 2: Низкоуровневый парсер

Реализуйте низкоуровневый парсер, который:

  1. Работает с необработанной памятью
  2. Эффективно обрабатывает данные
  3. Минимизирует аллокации
  4. Обеспечивает безопасность

Ожидаемый результат:

  • Высокая производительность
  • Минимальное использование памяти
  • Безопасная работа
  • Чистый интерфейс

Задание 3: Оптимизированный пул объектов

Создайте пул объектов, который:

  1. Использует unsafe для управления памятью
  2. Поддерживает переиспользование объектов
  3. Обеспечивает безопасность
  4. Оптимизирует производительность

Ожидаемый результат:

  • Эффективное управление памятью
  • Высокая производительность
  • Безопасность использования
  • Минимальные накладные расходы

🎯 Цель главы: К концу этой главы вы должны уметь:

  • Понимать принципы работы unsafe
  • Безопасно использовать низкоуровневые операции
  • Оптимизировать производительность
  • Писать эффективный код

Системное программирование в Go и сигналы

Библиотеки Go