Конкурентность и многопоточность в Go: продвинутый уровень

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Конкурентность и многопоточность в Go: продвинутый уровень

Go (Golang) с самого начала проектировался как язык для написания масштабируемых и производительных приложений. В основе этого лежит мощная модель конкурентности, основанная на горутинах и каналах. Если вы уже освоили базовые примеры с go func() и простыми каналами, пришло время перейти на следующий уровень. В этой статье мы разберем продвинутые паттерны работы с конкурентностью в Go, которые помогут вам писать надежный, эффективный и безопасный код.

1. Управление жизненным циклом горутин: Context и WaitGroup

Одна из самых частых проблем — утечка горутин. Горутина, которая бесконечно ждет данные из канала, никогда не завершится, если канал закрыт неправильно. Для управления жизненным циклом горутин в Go есть два основных инструмента: sync.WaitGroup и context.Context.

sync.WaitGroup для ожидания завершения

Используйте sync.WaitGroup, когда вам нужно дождаться завершения фиксированного набора горутин. Это простой и эффективный механизм.

package main

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

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

func main() {
	var wg sync.WaitGroup

	for i := 1; i <= 3; i++ {
		wg.Add(1) // Увеличиваем счетчик перед запуском
		go worker(i, &wg)
	}

	wg.Wait() // Блокируемся, пока счетчик не станет 0
	fmt.Println("Все воркеры завершились")
}

context.Context для отмены и дедлайнов

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

package main

import (
	"context"
	"fmt"
	"time"
)

func longRunningTask(ctx context.Context) {
	for {
		select {
		case <-ctx.Done():
			fmt.Println("Горутина получила сигнал остановки")
			return
		default:
			time.Sleep(500 * time.Millisecond)
			fmt.Println("Работаю...")
		}
	}
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel() // Важно вызвать cancel для освобождения ресурсов

	go longRunningTask(ctx)

	time.Sleep(3 * time.Second)
	fmt.Println("Main завершился")
}

2. Каналы: буферизация, закрытие и паттерн Fan-Out/Fan-In

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

Буферизированные vs Небуферизированные

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

Паттерн Fan-Out/Fan-In

Это один из самых мощных паттернов. Fan-Out — распределение задач по нескольким горутинам (воркерам). Fan-In — сбор результатов от нескольких горутин в один канал.

package main

import (
	"fmt"
	"sync"
)

func producer(nums ...int) <-chan int {
	out := make(chan int)
	go func() {
		for _, n := range nums {
			out <- n
		}
		close(out)
	}()
	return out
}

func square(in <-chan int) <-chan int {
	out := make(chan int)
	go func() {
		for n := range in {
			out <- n * n
		}
		close(out)
	}()
	return out
}

func merge(channels ...<-chan int) <-chan int {
	var wg sync.WaitGroup
	out := make(chan int)

	output := func(c <-chan int) {
		for n := range c {
			out <- n
		}
		wg.Done()
	}

	wg.Add(len(channels))
	for _, c := range channels {
		go output(c)
	}

	go func() {
		wg.Wait()
		close(out)
	}()

	return out
}

func main() {
	// Fan-Out: запускаем два квадратных воркера
	c1 := square(producer(1, 2, 3))
	c2 := square(producer(4, 5, 6))

	// Fan-In: собираем результаты
	for result := range merge(c1, c2) {
		fmt.Println(result)
	}
}

3. Select и управление таймаутами

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

package main

import (
	"fmt"
	"time"
)

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

	go func() {
		time.Sleep(1 * time.Second)
		ch1 <- "Ответ от сервиса 1"
	}()

	go func() {
		time.Sleep(2 * time.Second)
		ch2 <- "Ответ от сервиса 2"
	}()

	select {
	case msg1 := <-ch1:
		fmt.Println("Получен:", msg1)
	case msg2 := <-ch2:
		fmt.Println("Получен:", msg2)
	case <-time.After(1 * time.Second):
		fmt.Println("Таймаут! Ни один сервис не ответил вовремя.")
	}
}

Этот паттерн часто используется для реализации circuit breaker или запросов к нескольким источникам данных с выбором самого быстрого.

4. Синхронизация: Мьютексы и Atomic операции

Несмотря на то, что в Go принято "не общаться через разделяемую память, а разделять память через общение", иногда без мьютексов не обойтись. Для защиты критических секций используйте sync.Mutex. Для простых счетчиков и флагов лучше подойдут sync/atomic.

sync.Mutex

package main

import (
	"fmt"
	"sync"
)

type Counter struct {
	mu    sync.Mutex
	value int                

Блоги

Рекомендаций по книгам