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

Последовательный узкое место: почему мы не можем иметь хорошие вещи (пока)

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

Рассмотрим этот классический сценарий: вашему веб-серверу необходимо обработать несколько клиентских запросов. Наивный подход? Обрабатывать их по одному. Клиент А закончил? Отлично, теперь внимание получает Клиент Б. Тем временем Клиенты с С по Z смотрят на свои экраны, гадая, не сломался ли интернет. Это последовательное выполнение, и оно настолько же эффективно, как однополосное шоссе в час пик.

Go посмотрел на эту проблему и подумал: «Что, если мы сделаем параллелизм настолько тривиальным, что вы бы на самом деле его использовали?» Так появились goroutines.

Goroutines: Лёгкая магия параллелизма

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

Синтаксис красиво прост. Чтобы запустить goroutine, вы буквально просто пишете go перед вызовом функции. Вот и всё. Вы только что разблокировали параллельное выполнение.

package main
import (
	"fmt"
	"time"
)
func printNumbers(label string) {
	for i := 1; i <= 5; i++ {
		fmt.Printf("%s: %d\n", label, i)
		time.Sleep(100 * time.Millisecond)
	}
}
func main() {
	// Последовательное выполнение — скучно
	// printNumbers("Последовательный")
	// Параллельное выполнение — увлекательно!
	go printNumbers("Задача A")
	go printNumbers("Задача B")
	// Дать goroutines время на завершение
	time.Sleep(1 * time.Second)
	fmt.Println("Main finished")
}

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

Но вот в чём загвоздка: time.Sleep(1 * time.Second) — это костыль. Что если задачи займут больше времени? Что если они займут меньше времени? Мы действуем вслепую. Здесь возникает наша первая проблема: синхронизация goroutines.

Проблема синхронизации: не оставляйте свои goroutines в подвешенном состоянии

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

Здесь на сцену выходит sync.WaitGroup, как ответственный сопровождающий на экскурсии, который подсчитывает головы перед посадкой в автобус.

package main
import (
	"fmt"
	"sync"
	"time"
)
func processOrder(orderID int, wg *sync.WaitGroup) {
	defer wg.Done() // Уменьшить счётчик при выходе из функции
	fmt.Printf("Processing order %d\n", orderID)
	time.Sleep(500 * time.Millisecond)
	fmt.Printf("Order %d completed\n", orderID)
}
func main() {
	var wg sync.WaitGroup
	orders := []int{101, 102, 103, 104, 105}
	for _, orderID := range orders {
		wg.Add(1) // Увеличить счётчик
		go processOrder(orderID, &wg)
	}
	wg.Wait() // Блокировать до тех пор, пока счётчик не достигнет нуля
	fmt.Println("Все заказы обработаны")
}

Шаблон WaitGroup прост:

  1. Add(n) увеличивает внутренний счётчик на n
  2. Done() уменьшает счётчик (вызывайте это, когда goroutine заканчивает)
  3. Wait() блокирует до тех пор, пока счётчик не достигнет нуля Это ваш первый настоящий инструмент в наборе инструментов для параллелизма, и он удивительно эффективен для простых случаев.

Каналы: заставляем goroutines общаться друг с другом

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

Каналы — это типизированные каналы для передачи данных между goroutines. Думайте о них как о почтовых ящиках, где одна goroutine может отправлять данные, а другая — получать их.

package main
import (
	"fmt"
	"time"
)
func worker(id int, jobs <-chan int, results chan<- int) {
	for job := range jobs {
		fmt.Printf("Worker %d started job %d\n", id, job)
		time.Sleep(time.Second)
		result := job * 2
		fmt.Printf("Worker %d finished job %d\n", id, job)
		results <- result
	}
}
func main() {
	jobs := make(chan int, 5)
	results := make(chan int)
	// Запуск 3 рабочих goroutines
	for i := 1; i <= 3; i++ {
		go worker(i, jobs, results)
	}
	// Отправка заданий
	go func() {
		for j := 1; j <= 9; j++ {
			jobs <- j
		}
		close(jobs)
	}()
	// Сбор результатов
	for i := 0; i < 9; i++ {
		fmt.Printf("Result: %d\n", <-results)
	}
}

Обратите внимание на типы каналов: <-chan int (только для приёма) и chan<- int (только для отправки). Эта направленная типизация предотвращает ошибки, когда функция может ошибочно отправить данные по каналу, предназначенному только для приёма. Компилятор буквально не позволит вам этого сделать.

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

Буферизованные каналы: небольшая очередь имеет большое значение

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

messages := make(chan string, 2)
messages <- "first"
messages <- "second"
// Эти две отправки происходят немедленно, потому что канал имеет ёмкость 2
fmt.Println(<-messages) // "first"
fmt.Println(<-messages) // "second"

Буферизованные каналы полезны для паттернов «производитель-потребитель», где скорость производства может кратковременно превышать скорость потребления.

Взаимное исключение: защита общего состояния

Не всё можно решить с помощью каналов. Иногда у вас есть общее состояние, которое несколько goroutines должны читать и изменять. Для таких случаев sync.Mutex (блокировка взаимного исключения) — ваш друг.

package main
import (
	"fmt"
	"sync"
)
type SafeCounter struct {
	mu    sync.Mutex
	count int
}
func (sc *SafeCounter) Increment() {
	sc.mu.Lock()
	defer sc.mu.Unlock()
	sc.count++
}
func (sc *SafeCounter) Value() int {
	sc.mu.Lock()
	defer sc.mu.Unlock()
	return sc.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