
Оптимизация go параллелизм, используя работник
8 августа 2025 г.Итак, вы узнали все о Goroutines и каналах Go, и вы рады погрузиться в одновременное программирование. Но подожди! Прежде чем начнете нерескаться о тысячах goroutines, давайте сделаем шаг назад и поймем, как это сделать эффективно. В этой статье мы рассмотрим концепциюРабочий бассейнИ как это может помочь вам управлять параллелизмом в Go Bless Offuling вашей системе.
Допустим, вы переходите в новое место. Ваши вещи в движущихся коробках, а фургон ждет на улице. Вы могли бы попытаться нести коробки по одному, но это займет вечно. Что вы можете сделать? Если вы в хорошей форме, вы можете нести две коробки за раз, но давайте посмотрим правде в глаза, мы - картофель. Вместо этого вы приглашаете своих друзей и просите их помочь. Теперь у вас есть команда людей, несущих коробки, и работа выполняется намного быстрее. Круто, но сколько друзей? Есть только одна дверь, и если слишком много людей пытаются нести коробки одновременно, они будут столкнуться друг с другом и замедлить процесс.
В Go Goroutines похожи на тех, кто помогает вам двигаться. Это легкие нити, которые могут работать одновременно, позволяя вам выполнять несколько задач одновременно без накладных расходов традиционных нитей. Однако, как приглашение, приглашение, слишком много друзей может привести к хаосу, нерестование слишком много рубцов может привести к проблемам с производительностью. Здесь пригодится рабочий бассейн.
1. Goroutines и каналы: быстрый резюме
Но прежде чем мы погрузимся в рабочие бассейны, давайте быстро повторим наши строительные блоки:GoroutinesиканалыПолем Goroutines - это легкие потоки, управляемые временем выполнения GO. Они позволяют вам выполнять функции одновременно без сложности самостоятельно управления потоками. Каналы используются для общения между goroutines, что позволяет им безопасно синхронизировать и обмениваться данными. Есть и другие способы общения, но это самый безопасный и идиоматический путь в соответствии с «Не общайтесь, делясь памятью; делиться памятью путем общения».
Вот основной пример использования Goroutine с каналом:
ch := make(chan int)
go func() {
ch <- someWork() // Send result to channel when ready
}()
// Main goroutine continues other work...
otherWork()
// When we need the result, we receive from the channel
result := <-ch // This blocks until the goroutine sends a value
fmt.Printf("Got result: %d\n", result)
В этом примере основной Goroutine может продолжать выполнять другие задачи, покаsomeWork()
работает одновременно. Когда необходим результат,<-ch
блокирует основной goroutine, пока работник Goroutine не отправит значение через канал.
Канал может бытьблокировкаилине блокировка: Блокирующий канал будет ждать, пока значение не будет отправлено или получено, в то время как не блокирующий канал вернется немедленно, если значение не будет доступно. В примере выше используется блокирующий канал.
2. Параллелизм ≠ параллелизм
Важно понимать, что параллелизм не означает параллелизм. GO позволяет вам запускать много goroutines, но он не может запускать больше параллельных потоков, чем количество доступных ядер ЦП. Время выполнения Go будет появляться и запуститьодновременныйGoroutines, но не все из них будут бежатьпараллельв то же время. Да, все они будут запланированы, но не обязательно все одновременно.
Кроме того, Goroutines вводят некоторые накладные расходы, такие как пространство для стека и работа по планированию. Если вы появляетесь в тысячах Goroutines, вы можете в конечном итоге повредить производительность, вместо того, чтобы улучшить ее. Go отлично подходит для эффективного управления Goroutines, но все еще важно помнить, сколько вы создаете.
3. Параллелизация алгоритма разделения и завоевания
Семейство алгоритмов, которые могут больше всего пользоваться параллелизмом,-это рекурсивные алгоритмы разделения и подтверждения. Эти алгоритмы разбивают проблему на более мелкие подпроекты, решают их независимо, а затем объединяют результаты.
Самым классическим примером алгоритма разделения и подтверждения являетсяQuicksortПолем Вы делите набор данных на два раздела, сортируете каждый раздел, а затем объединяете результаты.
3.1 Последовательный Quicksort
Посмотрим на непараллельную версию QuickSort:
func quickSort(arr []int) []int {
// Halt condition: if the array has less than 0 or 1 element, it's sorted
if len(arr) == 1 || len(arr) == 0 {
return arr
}
// Divide the array into two partitions
// Everything less than the pivot goes to the left, everything greater (or equal) goes to the right
pivot := arr[len(arr)/2]
left := []int{}
right := []int{}
for _, v := range arr {
if v < pivot {
left = append(left, v)
} else if v > pivot {
right = append(right, v)
}
}
// Recursively sort the partitions
left = quickSort(left)
right = quickSort(right)
// Combine the left array, the pivot element, and the right array
// Here both left and right are already sorted because of the recursive calls
return append(append(left, pivot), right...)
}
Если мы посмотрим на этот код, мы увидим, что сортировка левых и правых разделов может быть сделана параллельно. Мы можем создать два goroutines, чтобы одновременно сортировать левые и правые разделы, что может значительно ускорить процесс сортировки для больших наборов данных. Мы не можем многое сделать с шагом слияния, но это все еще улучшение.
3.2 Параллельный Quicksort - наивный подход
Вот простая параллельная версия с использованием RAW GOROUTINES:
func quickSort(arr []int) []int {
if len(arr) == 1 || len(arr) == 0 {
return arr
}
pivot := arr[len(arr)/2]
left := []int{}
right := []int{}
for _, v := range arr {
if v < pivot {
left = append(left, v)
} else if v > pivot {
right = append(right, v)
}
}
// Create channels to receive the sorted partitions
leftCh := make(chan []int)
rightCh := make(chan []int)
// Sort the left and right partitions in goroutines
go func() {
leftCh <- quickSort(left)
}()
go func() {
rightCh <- quickSort(right)
}()
// Wait for both goroutines to finish and collect the results
left = <-leftCh
right = <-rightCh
return append(append(left, pivot), right...)
}
В этой версии мы породим две горутины, чтобы одновременно сортировать левые и правые разделы. Это может быстро выходить из -под контроля. Хотя с осторожным (или счастливым) выбором поворотных элементов, глубина рекурсии будетO(log n)
, в худшем случае, это может подняться доO(n)
Полем И на каждом уровне рекурсии мы породим две goroutines, что означает, что количество Goroutines растет в геометрической прогрессии, поэтому легко пройти количество доступных ядер процессора. И помните, что у каждого Goroutine есть свое собственное место в стеке и планирование накладных расходов.
3.3 Оптимизированный параллельный Quicksort с рабочим пулом
Чтобы избежать проблем с нерестом слишком много goroutines, мы можем использоватьРабочий бассейнПолем Рабочий бассейн - это схема дизайна, где ограниченное количество рабочих выполняет задачи из очереди. Эта одновременность к тому, что CPU может справиться с тем, что CPU может справиться, и предотвращает отбросы из слишком большого количества Goroutines. Мы также можем вернуться к последовательной реализации, если работники не доступны, вместо того, чтобы просто ждать бесплатного слота работника. Этот запасной также полезен для небольших наборов данных, где накладные расходы нерестов goroutines перевешивают преимущества параллелизма.
Вот как мы можем внедрить бассейн работников для QuickSort:
package main
import (
"fmt"
"runtime"
)
// Global worker pool - semaphore to limit concurrent goroutines
var workerPool chan struct{}
func init() {
// Initialize with number of CPU cores
workerPool = make(chan struct{}, runtime.NumCPU())
}
func quickSortWithPool(arr []int) []int {
if len(arr) <= 1 {
return arr
}
// Use sequential for small arrays to avoid overhead
if len(arr) < 1000 {
return quickSortSequential(arr)
}
// Partition the array
pivot := arr[len(arr)/2]
left := []int{}
right := []int{}
for _, v := range arr {
if v < pivot {
left = append(left, v)
} else if v > pivot {
right = append(right, v)
}
}
// Channels to receive results
leftCh := make(chan []int, 1)
rightCh := make(chan []int, 1)
// Try to get a worker for left partition
select {
case workerPool <- struct{}{}: // Got worker slot
go func() {
defer func() { <-workerPool }() // Release slot when done
leftCh <- quickSortWithPool(left)
}()
default: // No workers available - use sequential
leftCh <- quickSortSequential(left)
}
// Try to get a worker for right partition
select {
case workerPool <- struct{}{}: // Got worker slot
go func() {
defer func() { <-workerPool }() // Release slot when done
rightCh <- quickSortWithPool(right)
}()
default: // No workers available - use sequential
rightCh <- quickSortSequential(right)
}
// Wait for both results
sortedLeft := <-leftCh
sortedRight := <-rightCh
// Combine results
return append(append(sortedLeft, pivot), sortedRight...)
}
Мы уверены, что никогда не появляемся больше, чем количество доступных ядер ЦП. Есть несколько дополнительных оптимизаций, которые мы можем сделать, такие как возвращение к последовательности после определенной глубины рекурсии, партийной обработки или распределения на месте (для лучшего места в кеше). Есть большой потенциал в лучшем выборе поворота. Но основная идея состоит в том, чтобы использовать бассейн работников, чтобы ограничить количество одновременных goroutines и избежать подавляющей системы.
4. Брингеринг
В сопровождающем репозитории кода вы можете найти реализации последовательного QuickSort, наивного параллельного QuickSort и версии пула рабочих, с тонкой оберткой для запуска их в рандомизированном наборе данных из 100 000 элементов. Если вы запустите их, результаты будут что -то вроде этого:
$ go run sequential/main.go
Sorted Data: [0 2 7 9 10 20 22 26 29 38] ... [999931 999939 999945 999964 999967 999972 999979 999984 999988 999997]
Elapsed time: 32.9421ms
$ go run parallel/main.go
Sorted Data: [0 2 7 9 10 20 22 26 29 38] ... [999931 999939 999945 999964 999967 999972 999979 999984 999988 999997]
Elapsed time: 66.6936ms
$ go run workerpool/main.go
Sorted Data: [0 2 7 9 10 20 22 26 29 38] ... [999931 999939 999945 999964 999967 999972 999979 999984 999988 999997]
Elapsed time: 31.0905ms
Результаты показывают, что наивный параллельный Quicksort значительно медленнее, чем последовательная версия, в то время как работник-пул значительно улучшает производительность параллельного алгоритма, просто превзойдя однопоточное выполнение.
Плохая производительность наивной параллельной версии может стать неожиданностью, но она прекрасно демонстрирует нашу точку зрения: небрежно нереставшие горутины могут привести к серьезному деградации производительности. Кроме того, простой, последовательный Quicksort все отлично, не тривиально придумывать что -то быстрее.
Краткое содержание
В этой статье мы изучили, что происходит, когда мы позволяем Goroutines выходить из -под контроля и что мы можем с этим поделать.
Наш бассейн работников очень прост: надежная реализация будет использовать более сложную очередь задач с отменой работы, тайм -аутами черезcontext.Context
и т.д. мы не охватывали шаблоны управления памятью, например, использованиеsync.Pool
повторно использовать распределения памяти для левого и правого разделения. И список можно продолжать.
Параллелизм - это обширная тема, и есть много моделей и методов для изучения, мы только начали царапать поверхность здесь.
Образец бассейна работников является мощным инструментом для управления Goroutines, но не единственный. Ключевым выводом является: контролируемый параллелизм каждый раз бьет хаотическое параллелизм.
Оригинал