Язык Go — параллелизация вычислений
Параллелизация в Go — разбивка работы по CPU-ядрам, runtime.NumCPU, GOMAXPROCS и разница между конкурентностью и параллелизмом.
Содержание
Горутины и каналы — инструменты конкурентности. Но если задача делится на независимые части, те же инструменты позволяют задействовать все ядра процессора и получить реальное ускорение.
Когда параллелизация имеет смысл
Параллелизация работает когда задача делится на независимые части: части не обращаются к общим данным, порядок их выполнения не важен, результаты можно собрать после.
Типичные примеры: обработка пикселей изображения, трансформация элементов большого массива, независимые 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 конкурентный, не параллельный: параллелизм — возможный эффект, но не гарантия