· 11 мин 👁 1.5k Средний

Язык Go — параллелизация вычислений

Параллелизация в Go — разбивка работы по CPU-ядрам, runtime.NumCPU, GOMAXPROCS и разница между конкурентностью и параллелизмом.

параллелизмгорутиныNumCPUGOMAXPROCSконкурентность
Содержание

Горутины и каналы — инструменты конкурентности. Но если задача делится на независимые части, те же инструменты позволяют задействовать все ядра процессора и получить реальное ускорение.

Когда параллелизация имеет смысл

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

Типичные примеры: обработка пикселей изображения, трансформация элементов большого массива, независимые HTTP-запросы, рендеринг фреймов.

Базовый паттерн: разбивка вектора по ядрам

Есть дорогая операция над каждым элементом вектора. Элементы независимы — считаем их параллельно:

type Vector []float64

// DoSome обрабатывает срез вектора от i до n-1.
// Когда закончит — отправляет сигнал в канал c.
func (v Vector) DoSome(i, n int, u Vector, c chan int) {
    for ; i < n; i++ {
        v[i] += u.Op(v[i]) // тяжёлая операция над каждым элементом
    }
    c <- 1 // сообщаем что эта часть завершена (значение не важно — только сигнал)
}

DoAll разбивает вектор на равные части по числу CPU и запускает каждую в отдельной горутине:

const numCPU = 4 // жёстко заданное число ядер — так делать не стоит, см. ниже

func (v Vector) DoAll(u Vector) {
    c := make(chan int, numCPU) // буфер на numCPU: горутины не блокируются при отправке сигнала

    for i := 0; i < numCPU; i++ {
        // каждая горутина получает свой диапазон индексов [i*len/numCPU, (i+1)*len/numCPU)
        go v.DoSome(i*len(v)/numCPU, (i+1)*len(v)/numCPU, u, c)
    }

    // собираем сигналы завершения — ждём пока все горутины отчитаются
    for i := 0; i < numCPU; i++ {
        <-c // каждое чтение разблокируется когда одна из горутин завершит свою часть
    }
    // все части обработаны
}

Порядок завершения горутин не важен — просто считаем сколько сигналов пришло.

runtime.NumCPU: не хардкодим число ядер

Жёстко заданная константа numCPU = 4 — плохая идея: программа будет работать неоптимально на машинах с другим числом ядер. Правильный подход — спросить у рантайма:

var numCPU = runtime.NumCPU() // число физических ядер процессора на текущей машине

func (v Vector) DoAll(u Vector) {
    c := make(chan int, numCPU)
    for i := 0; i < numCPU; i++ {
        go v.DoSome(i*len(v)/numCPU, (i+1)*len(v)/numCPU, u, c)
    }
    for i := 0; i < numCPU; i++ {
        <-c
    }
}

GOMAXPROCS: сколько ядер Go может использовать

runtime.GOMAXPROCS — сколько OS-потоков Go может запускать одновременно. По умолчанию равно runtime.NumCPU(), но может быть переопределено:

// узнать текущее значение (0 — только запрос, без изменения)
current := runtime.GOMAXPROCS(0)
fmt.Println("GOMAXPROCS:", current)

// ограничить двумя ядрами (например, для тестирования)
runtime.GOMAXPROCS(2)

// задать через переменную окружения до запуска программы:
// GOMAXPROCS=2 go run main.go

Для параллелизации нужно использовать именно GOMAXPROCS, а не NumCPU — пользователь или оркестратор мог ограничить ресурсы:

// правильно: уважаем заданное пользователем ограничение
var numCPU = runtime.GOMAXPROCS(0)

На практике с Go 1.5+ GOMAXPROCS по умолчанию равно числу ядер, так что для большинства задач разницы нет. Но явное использование GOMAXPROCS(0) сигнализирует: «мы знаем о ресурсных ограничениях и уважаем их».

Практический пример: параллельная обработка изображения

Применим паттерн к чему-то ощутимому — инвертирование пикселей изображения:

func invertPixels(img []uint8, start, end int, done chan struct{}) {
    for i := start; i < end; i++ {
        img[i] = 255 - img[i] // инвертируем каждый байт
    }
    done <- struct{}{} // сигнал завершения
}

func ProcessImage(img []uint8) {
    numWorkers := runtime.GOMAXPROCS(0)
    done := make(chan struct{}, numWorkers)
    chunkSize := len(img) / numWorkers

    for i := 0; i < numWorkers; i++ {
        start := i * chunkSize
        end := start + chunkSize
        if i == numWorkers-1 {
            end = len(img) // последний воркер берёт остаток
        }
        go invertPixels(img, start, end, done)
    }

    for i := 0; i < numWorkers; i++ {
        <-done
    }
}

WaitGroup как альтернатива сигнальному каналу

Паттерн с каналом-счётчиком работает, но sync.WaitGroup часто читается чище:

func (v Vector) DoAllWG(u Vector) {
    numWorkers := runtime.GOMAXPROCS(0)
    var wg sync.WaitGroup

    for i := 0; i < numWorkers; i++ {
        wg.Add(1)
        i := i // shadowing: локальная копия для каждой горутины
        go func() {
            defer wg.Done()
            start := i * len(v) / numWorkers
            end := (i + 1) * len(v) / numWorkers
            for j := start; j < end; j++ {
                v[j] += u.Op(v[j])
            }
        }()
    }

    wg.Wait() // блокируемся до завершения всех горутин
}

Когда использовать канал, а когда WaitGroup:

  • канал — если воркеры возвращают результаты или нужна дополнительная коммуникация
  • WaitGroup — если нужно просто дождаться завершения всех горутин без передачи данных

Конкурентность ≠ параллелизм

Важное разграничение, которое часто путают.

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

Параллелизм — несколько вычислений буквально выполняются одновременно на разных ядрах. Это про исполнение.

Конкурентная программа на одном ядре:
[горутина A]──►[горутина B]──►[горутина A]──►[горутина B]
  переключение контекста — не параллельно, но конкурентно

Параллельная программа на четырёх ядрах:
Ядро 1: [горутина A]────────────────►
Ядро 2: [горутина B]────────────────►
Ядро 3: [горутина C]────────────────►
Ядро 4: [горутина D]────────────────►
  выполняются буквально одновременно

Go — конкурентный язык. Горутины — инструмент конкурентности. Когда GOMAXPROCS > 1, конкурентная программа может выполняться параллельно — но это следствие, а не цель.

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

Итоги

  • Параллелизация работает только для независимых частей задачи
  • Разбивайте работу по runtime.GOMAXPROCS(0), а не по runtime.NumCPU() — уважайте ресурсные ограничения
  • Буферизованный канал как счётчик завершений: make(chan int, numWorkers) — горутины не блокируются при сигнале
  • sync.WaitGroup — альтернатива каналу когда воркеры не возвращают данные
  • Последний воркер должен взять остаток при нечётном делении: if i == last { end = len(v) }
  • Конкурентность — про структуру программы; параллелизм — про одновременное выполнение на нескольких ядрах
  • Go конкурентный, не параллельный: параллелизм — возможный эффект, но не гарантия