1 Введение в Goroutines

1.1 Основные концепции конкурентности и параллелизма

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

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

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

1.2 Goroutines в языке Go

Goroutines - это основное понятие для достижения конкурентного программирования в языке Go. Они являются легковесными потоками, управляемыми временем выполнения Go. С точки зрения пользователя, они похожи на потоки, но потребляют меньше ресурсов и запускаются быстрее.

Характеристики Goroutines включают:

  • Легковесность: Goroutines занимают меньше памяти стека по сравнению с традиционными потоками, и их размер стека может динамически расширяться или уменьшаться по мере необходимости.
  • Низкие накладные расходы: Затраты на создание и уничтожение Goroutines намного ниже, чем у традиционных потоков.
  • Простой механизм коммуникации: Каналы обеспечивают простой и эффективный механизм коммуникации между Goroutines.
  • Неблокирующий дизайн: Goroutines не блокируют другие Goroutines от выполнения некоторых операций. Например, пока одна Goroutine ожидает ввода-вывода, другие Goroutines могут продолжать выполнение.

2 Создание и управление Goroutines

2.1 Как создать Goroutine

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

Давайте рассмотрим простой пример:

package main

import (
	"fmt"
	"time"
)

// Определяем функцию для печати "Привет"
func sayHello() {
	fmt.Println("Привет")
}

func main() {
	// Запускаем новую Goroutine с использованием ключевого слова go
	go sayHello()

	// Основная Goroutine ждет некоторое время, чтобы позволить sayHello выполниться
	time.Sleep(1 * time.Second)
	fmt.Println("Основная функция")
}

В приведенном выше коде функция sayHello() будет выполнена асинхронно в новой Goroutine. Это означает, что функция main() не будет ждать, пока sayHello() завершится, прежде чем продолжить. Поэтому мы используем time.Sleep, чтобы приостановить основную Goroutine и позволить выполниться оператору print в sayHello. Это только для демонстрационных целей. В реальной разработке обычно используются каналы или другие методы синхронизации для координации выполнения различных Goroutines.

Примечание: В практических приложениях не следует использовать time.Sleep() для ожидания завершения Goroutine, поскольку это не надежный механизм синхронизации.

2.2 Механизм планирования Goroutine

В Go планирование Goroutines обрабатывается планировщиком времени выполнения Go, который отвечает за выделение времени выполнения на доступных логических процессорах. Планировщик Go использует технологию планирования M:N (несколько Goroutines отображаются на несколько потоков ОС) для достижения лучшей производительности на многоядерных процессорах.

GOMAXPROCS и логические процессоры

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

import "runtime"

func init() {
    runtime.GOMAXPROCS(2)
}

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

Операция планировщика

Планировщик работает с тремя важными сущностями: M (машина), P (процессор) и G (горутина). M представляет собой машину или поток, который служит абстракцией потоков ядра ОС. P представляет ресурсы, необходимые для выполнения горутины. У каждого P есть локальная очередь горутин. G представляет собой горутину, включая её стек выполнения, набор инструкций и другую информацию.

Принципы работы планировщика Go:

  • M должно иметь P для выполнения G. Если нет P, M будет возвращен в кэш потоков.
  • Когда G не блокируется другими G (например, при системных вызовах), он выполняется на той же M, насколько это возможно, что помогает сохранить локальные данные G "горячими" для более эффективного использования кэша процессора.
  • Когда G блокируется, M и P разъединяются, и P ищет новую M или будит новую M для обслуживания других G.
go func() {
    fmt.Println("Привет из горутины")
}()

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

Принудительное планирование горутин

На ранних этапах Go использовал кооперативное планирование, что означает, что горутины могли лишать другие горутины возможности выполнения, если они выполнялись долгое время без волонтаристской передачи управления. Теперь планировщик Go реализует принудительное планирование, позволяя приостанавливать долгие выполнения G, чтобы дать возможность выполняться другим G.

2.3 Управление жизненным циклом горутин

Для обеспечения надежности и производительности вашего приложения на Go важно понимать и правильно управлять жизненным циклом горутин. Запустить горутины просто, но без правильного управления они могут привести к проблемам, таким как утечки памяти и состязания.

Безопасный запуск горутин

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

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

func main() {
    // Здесь используется механизм каналов в Go. Вы можете просто представить себе канал как базовую очередь сообщений и использовать оператор "<-" для чтения и записи данных в очередь.
    done := make(chan bool, 1)
    go worker(done)
    
    // Ожидание завершения работы горутины
    <-done
}

В приведенном выше коде показан один из способов ожидания завершения горутины с использованием канала done.

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

Остановка горутин

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

  1. Использование каналов для отправки сигналов остановки: Горутины могут опрашивать каналы для проверки сигналов остановки.
stop := make(chan struct{})

go func() {
    for {
        select {
        case <-stop:
            fmt.Println("Получен сигнал остановки. Выключение...")
            return
        default:
            // выполнение обычной операции
        }
    }
}()

// Отправка сигнала остановки
stop <- struct{}{}
  1. Использование пакета context для управления жизненным циклом:
ctx, cancel := context.WithCancel(context.Background())

go func(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            fmt.Println("Получен сигнал остановки. Выключение...")
            return
        default:
            // выполнение обычной операции
        }
    }
}(ctx)

// когда нужно остановить горутину
cancel()

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