Конкурентность и многопоточность в Go: полное руководство для разработчиков

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

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

Начать курс

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

Язык Go (Golang) был разработан в Google с учетом современных требований к производительности и масштабируемости. Его главная "фишка" — встроенная поддержка конкурентности, которая реализована на уровне языка, а не через внешние библиотеки. В отличие от Python, где многопоточность ограничена GIL (Global Interpreter Lock), Go предлагает легковесные горутины, которые позволяют эффективно выполнять тысячи задач одновременно. В этой статье мы разберем ключевые концепции конкурентности и многопоточности в Go, включая горутины, каналы, мьютексы и группу ожидания, с практическими примерами кода.

1. Горутины: легковесные потоки Go

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

package main

import (
    "fmt"
    "time"
)

func say(s string) {
    for i := 0; i < 5; i++ {
        time.Sleep(100 * time.Millisecond)
        fmt.Println(s)
    }
}

func main() {
    go say("Hello")      // Запуск горутины
    say("World")         // Обычный вызов в главном потоке
}

В этом примере say("Hello") выполняется в отдельной горутине, а say("World") — в главной. Вывод будет перемешан, так как обе функции работают конкурентно. Важно помнить: если главная функция main завершится, все горутины будут принудительно остановлены.

2. Каналы: безопасная передача данных между горутинами

Каналы (channels) — это типизированные "трубы", через которые горутины общаются друг с другом. Они обеспечивают синхронизацию без явного использования мьютексов. Каналы бывают буферизированные (с емкостью) и небуферизированные (синхронные).

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

package main

import "fmt"

func sum(s []int, c chan int) {
    total := 0
    for _, v := range s {
        total += v
    }
    c <- total // отправка результата в канал
}

func main() {
    s := []int{7, 2, 8, -9, 4, 0}
    c := make(chan int)

    go sum(s[:len(s)/2], c)
    go sum(s[len(s)/2:], c)

    x, y := <-c, <-c // получение из канала

    fmt.Println(x, y, x+y)
}

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

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

package main

import "fmt"

func main() {
    ch := make(chan int, 2) // буфер на 2 элемента
    ch <- 1
    ch <- 2
    // ch <- 3 // это вызовет deadlock, так как буфер полон
    fmt.Println(<-ch)
    fmt.Println(<-ch)
}

Буферизированный канал позволяет отправлять данные без блокировки, пока буфер не заполнится. Это удобно, когда скорость отправки выше скорости обработки.

3. Синхронизация: WaitGroup и мьютексы

Хотя каналы покрывают большинство сценариев, иногда требуется более тонкий контроль над синхронизацией. Для этого в пакете sync есть WaitGroup (ожидание завершения группы горутин) и Mutex (взаимное исключение для защиты общих данных).

WaitGroup: дождаться всех горутин

package main

import (
    "fmt"
    "sync"
)

func worker(id int, wg *sync.WaitGroup) {
    defer wg.Done() // уменьшаем счетчик при завершении
    fmt.Printf("Worker %d starting\n", id)
    // имитация работы
    fmt.Printf("Worker %d done\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("All workers completed")
}

WaitGroup — это счётчик. Метод Add() увеличивает его, Done() уменьшает, а Wait() блокирует выполнение до тех пор, пока счётчик не обнулится. Это идеально подходит для запуска "огня и забыл" группы горутин.

Mutex: защита общих ресурсов

package main

import (
    "fmt"
    "sync"
)

type Counter struct {
    mu    sync.Mutex
    value int
}

func (c *Counter) Increment() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.value++
}

func (c *Counter) Value() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.value
}

func main() {
    var wg sync.WaitGroup
    counter := Counter{}

    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            counter.Increment()
        }()
    }

    wg.Wait()
    fmt.Println("Final counter:", counter.Value())
}

Без мьютекса 1000 горутин одновременно пытались бы изменить counter.value, что привело бы к гонке данных (data race). Mutex гарантирует, что только одна горутина может выполнять критическую секцию кода в каждый момент времени.

4. 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 <- "one"
    }()
    go func() {                

Блоги

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